mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Portal (#6391)
# Description of Changes ## What & why This PR introduces the **Stirling developer portal** — a new control-plane frontend that sits alongside the existing PDF editor — plus the shared design system and workspace structure needed to host both apps in one frontend. The portal is the parent product surface: where users connect sources, compose pipelines, wire agents, and manage usage / billing / infrastructure, with the PDF editor as one capability inside it. This PR lays the **foundation** — workspace reshape, design system, app shell, navigation, and a mock-driven home — rather than wiring real backends (those surfaces are placeholders for follow-up phases). ## What's in this PR **1. Frontend repo reshape (`frontend/src/` → `frontend/editor/`)** The existing editor app moved under `frontend/editor/`, so `editor`, `portal`, and `shared` are siblings in one workspace. All references were updated accordingly: `LICENSE`, `.dockerignore`, `.gitignore`, build/sign shell scripts, the GH language-check script, the Taskfile, and Docker config. **No editor source logic changed — path references only.** **2. New shared design system (`frontend/shared/`)** - **Design tokens** in `tokens.css` as the single runtime source of truth (light/dark, category accents, gradients). `tokens.ts` now holds only the `Tier` type — the old JS palette mirror was removed (nothing consumed it and it had drifted). - ~30 framework-light **components** (Card, Button, Input, Select, Tabs, Modal, Drawer, Toast, MetricCard, StatusBadge, Skeleton, EmptyState, …) with Storybook stories. - **Typed data catalogues**: `endpoints.ts` (10 verticals / 64 endpoints) and `ops.ts`. **3. New developer portal app (`frontend/portal/`)** - App shell: `Header`, `Sidebar`, `AssistantPanel`, search modal, notifications, tier switcher, theme toggle, MSW toggle. - **Tier-aware** home (free / pay-as-you-go / enterprise): KPI strip, 30-day usage chart, onboarding checklist, quick actions, recent activity, region health, product grid, and a curated **"Popular use cases"** teaser. - **Documents** view hosting the full, tab-filterable endpoint catalogue. - Placeholder views for Sources / Pipelines / Agents / Editor / Infrastructure / Usage & Billing / Developer Docs / Settings (follow-up phases). - **MSW-mocked** API layer: `api/*` issues real `fetch`, intercepted by mocks in dev/Storybook; pointing at a real backend is just a matter of not registering MSW. `react-router` URLs; Tier / View / UI contexts. **4. Tooling & guardrails** - ESLint extended to `portal` + `shared`, with **layering-boundary rules**: `shared/` may depend only on third-party packages and itself (no `@app` / `@portal` / `@core` / `@proprietary` / Tauri), so it stays cleanly extractable into a standalone package later. - `dpdm` circular-dependency check now walks editor + portal + shared (the old glob matched only 2 files). - New **devDependencies only** — Storybook (+ a11y/docs/themes addons), MSW. No runtime dependencies added. - New tasks: `frontend:dev:portal`, `frontend:build:portal`. ## Testing done locally - `tsc` for both `portal` and `shared` projects — clean - `eslint --max-warnings=0` across the whole frontend — clean - `dpdm` circular-dependency check — no cycles - Editor builds clean: `vite build editor --mode core` (✓ built, only the pre-existing >500 kB chunk-size advisory) - Editor runs in dev (core mode) with **zero console errors**; portal runs in dev across all three tiers ## Notes for reviewers - The change is overwhelmingly **additive**: `shared/` and `portal/` are brand-new; the existing editor is path-reference changes only. - The portal is intentionally **mock-driven** at this stage — real backends and the remaining views land in follow-up phases. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b355ccec9e
commit
919f0ade99
+3
-2
@@ -26,8 +26,9 @@ version_builds/
|
|||||||
node_modules/
|
node_modules/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/editor/dist/
|
||||||
frontend/playwright-report/
|
frontend/dist-portal/
|
||||||
|
frontend/editor/playwright-report/
|
||||||
.npm/
|
.npm/
|
||||||
.yarn/
|
.yarn/
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -212,7 +212,7 @@ out/
|
|||||||
*.asc
|
*.asc
|
||||||
|
|
||||||
# Allow test fixture certificates (synthetic, no real credentials)
|
# Allow test fixture certificates (synthetic, no real credentials)
|
||||||
!frontend/src/core/tests/test-fixtures/certs/**
|
!frontend/editor/src/core/tests/test-fixtures/certs/**
|
||||||
|
|
||||||
# SSH Keys
|
# SSH Keys
|
||||||
*.pub
|
*.pub
|
||||||
@@ -254,7 +254,7 @@ node_modules/
|
|||||||
*compact*.json
|
*compact*.json
|
||||||
test_batch.json
|
test_batch.json
|
||||||
*.backup.*.json
|
*.backup.*.json
|
||||||
frontend/public/locales/*/translation.backup*.json
|
frontend/editor/public/locales/*/translation.backup*.json
|
||||||
|
|
||||||
# Development/build artifacts
|
# Development/build artifacts
|
||||||
.gradle-cache/
|
.gradle-cache/
|
||||||
|
|||||||
+44
-1
@@ -112,6 +112,12 @@ tasks:
|
|||||||
- task: dev:_run
|
- task: dev:_run
|
||||||
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
|
||||||
|
|
||||||
|
dev:portal:
|
||||||
|
desc: "Start developer portal dev server"
|
||||||
|
deps: [install]
|
||||||
|
cmds:
|
||||||
|
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Build
|
# Build
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -156,6 +162,24 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- npx vite build editor --mode prototypes
|
- npx vite build editor --mode prototypes
|
||||||
|
|
||||||
|
build:portal:
|
||||||
|
desc: "Build developer portal"
|
||||||
|
deps: [install]
|
||||||
|
cmds:
|
||||||
|
- npx vite build portal
|
||||||
|
|
||||||
|
storybook:
|
||||||
|
desc: "Start Storybook dev server"
|
||||||
|
deps: [install]
|
||||||
|
cmds:
|
||||||
|
- npx storybook dev -p 6006 {{.CLI_ARGS}}
|
||||||
|
|
||||||
|
storybook:build:
|
||||||
|
desc: "Build static Storybook"
|
||||||
|
deps: [install]
|
||||||
|
cmds:
|
||||||
|
- npx storybook build {{.CLI_ARGS}}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Code quality
|
# Code quality
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -165,7 +189,10 @@ tasks:
|
|||||||
deps: [install]
|
deps: [install]
|
||||||
cmds:
|
cmds:
|
||||||
- npx eslint --max-warnings=0
|
- npx eslint --max-warnings=0
|
||||||
- npx dpdm editor/src --circular --no-warning --no-tree --exit-code circular:1
|
# Globs (not a bare dir) so dpdm walks the whole tree — `editor/src`
|
||||||
|
# alone matched only 2 files. dpdm expands the braces itself, so this is
|
||||||
|
# shell-agnostic. Covers editor, portal, and the shared design system.
|
||||||
|
- 'npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1'
|
||||||
|
|
||||||
lint:fix:
|
lint:fix:
|
||||||
desc: "Auto-fix lint issues"
|
desc: "Auto-fix lint issues"
|
||||||
@@ -236,6 +263,18 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
|
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
|
||||||
|
|
||||||
|
typecheck:portal:
|
||||||
|
desc: "Typecheck developer portal build variant"
|
||||||
|
deps: [install]
|
||||||
|
cmds:
|
||||||
|
- npx tsc --noEmit --project portal/tsconfig.json
|
||||||
|
|
||||||
|
typecheck:shared:
|
||||||
|
desc: "Typecheck the shared design system"
|
||||||
|
deps: [install]
|
||||||
|
cmds:
|
||||||
|
- npx tsc --noEmit --project shared/tsconfig.json
|
||||||
|
|
||||||
typecheck:all:
|
typecheck:all:
|
||||||
desc: "Typecheck all build variants"
|
desc: "Typecheck all build variants"
|
||||||
cmds:
|
cmds:
|
||||||
@@ -245,6 +284,8 @@ tasks:
|
|||||||
- task: typecheck:desktop
|
- task: typecheck:desktop
|
||||||
- task: typecheck:scripts
|
- task: typecheck:scripts
|
||||||
- task: typecheck:prototypes
|
- task: typecheck:prototypes
|
||||||
|
- task: typecheck:portal
|
||||||
|
- task: typecheck:shared
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Quality Gate
|
# Quality Gate
|
||||||
@@ -265,7 +306,9 @@ tasks:
|
|||||||
- task: lint
|
- task: lint
|
||||||
- task: format:check
|
- task: format:check
|
||||||
- task: build
|
- task: build
|
||||||
|
- task: build:portal
|
||||||
- task: test
|
- task: test
|
||||||
|
- task: storybook:build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Test
|
# Test
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ if that directory exists, is licensed under the license defined in "app/propriet
|
|||||||
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
|
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
|
||||||
* All content that resides under the "engine/" directory of this repository,
|
* All content that resides under the "engine/" directory of this repository,
|
||||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
* All content that resides under the "frontend/editor/src/proprietary/" directory of this repository,
|
||||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
if that directory exists, is licensed under the license defined in "frontend/editor/src/proprietary/LICENSE".
|
||||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
* All content that resides under the "frontend/editor/src/desktop/" directory of this repository,
|
||||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
|
||||||
* All content that resides under the "frontend/src/saas/" directory of this repository,
|
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
|
||||||
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
|
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
|
||||||
* All content that resides under the "frontend/src/prototypes/" directory of this repository,
|
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
|
||||||
if that directory exists, is licensed under the license defined in "frontend/src/prototypes/LICENSE".
|
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
|
||||||
|
* All content that resides under the "frontend/portal/" directory of this repository,
|
||||||
|
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
|
||||||
* Content outside of the above mentioned directories or restrictions above is
|
* Content outside of the above mentioned directories or restrictions above is
|
||||||
available under the MIT License as defined below.
|
available under the MIT License as defined below.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ RUN npm ci
|
|||||||
COPY frontend .
|
COPY frontend .
|
||||||
|
|
||||||
# Build the application (vite root is editor/, output lands in editor/dist/)
|
# Build the application (vite root is editor/, output lands in editor/dist/)
|
||||||
RUN npx vite --root editor build
|
RUN npx vite build editor
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
|
FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
|
/dist-portal
|
||||||
|
/storybook-static
|
||||||
|
|
||||||
/editor/build
|
/editor/build
|
||||||
/editor/dist
|
/editor/dist
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ editor/src-tauri/**/target/
|
|||||||
editor/src-tauri/gen/
|
editor/src-tauri/gen/
|
||||||
node_modules/
|
node_modules/
|
||||||
editor/public/vendor/
|
editor/public/vendor/
|
||||||
|
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
|
||||||
|
portal/public/mockServiceWorker.js
|
||||||
editor/public/pdfjs*/
|
editor/public/pdfjs*/
|
||||||
editor/public/js/thirdParty/
|
editor/public/js/thirdParty/
|
||||||
editor/public/css/cookieconsent.css
|
editor/public/css/cookieconsent.css
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
|
||||||
|
* addon list is just the extras we want: theme switching + a11y auditing.
|
||||||
|
*
|
||||||
|
* Story files live next to their components in shared/ and portal/src/.
|
||||||
|
* MDX docs pages live in portal/src/docs/.
|
||||||
|
*/
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: [
|
||||||
|
"../portal/src/**/*.mdx",
|
||||||
|
"../portal/src/**/*.stories.@(ts|tsx)",
|
||||||
|
"../shared/**/*.mdx",
|
||||||
|
"../shared/**/*.stories.@(ts|tsx)",
|
||||||
|
],
|
||||||
|
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
|
||||||
|
framework: {
|
||||||
|
name: "@storybook/react-vite",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
reactDocgen: "react-docgen-typescript",
|
||||||
|
},
|
||||||
|
// Serve the MSW worker file from portal/public so Storybook can intercept
|
||||||
|
// network calls the same way the dev portal does.
|
||||||
|
staticDirs: ["../portal/public"],
|
||||||
|
viteFinal: async (config) => {
|
||||||
|
// Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
|
||||||
|
// story imports resolve without needing the portal's vite config.
|
||||||
|
config.resolve = config.resolve ?? {};
|
||||||
|
config.resolve.alias = {
|
||||||
|
...(config.resolve.alias ?? {}),
|
||||||
|
"@portal": resolve(__dirname, "../portal/src"),
|
||||||
|
"@shared": resolve(__dirname, "../shared"),
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Storybook compiles .storybook/* with the classic JSX runtime, so the JSX in
|
||||||
|
// the decorators below transpiles to React.createElement and needs React in
|
||||||
|
// scope. (The app + story files use the automatic runtime via the portal vite
|
||||||
|
// config; this import is specifically for the preview config file.)
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import type { Decorator, Preview } from "@storybook/react-vite";
|
||||||
|
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { withThemeByDataAttribute } from "@storybook/addon-themes";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// Reference React so the import isn't dropped as unused by the bundler — the
|
||||||
|
// classic runtime needs it present even though it's not named in the JSX.
|
||||||
|
void React;
|
||||||
|
|
||||||
|
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||||
|
import { ThemeProvider } from "@portal/contexts/ThemeContext";
|
||||||
|
import { UIProvider } from "@portal/contexts/UIContext";
|
||||||
|
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||||
|
import { handlers } from "@portal/mocks/handlers";
|
||||||
|
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import "@shared/tokens/tokens.css";
|
||||||
|
import "@shared/tokens/base.css";
|
||||||
|
|
||||||
|
// Start MSW once. Storybook runs in a browser so this uses the service worker.
|
||||||
|
initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
|
||||||
|
* Without this the toolbar would just change a label; with it, every story
|
||||||
|
* that calls useTier() reflects the active toolbar value.
|
||||||
|
*/
|
||||||
|
function TierBridge({
|
||||||
|
tier,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tier: Tier;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <TierProvider initialTier={tier}>{children}</TierProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forces the TierProvider to re-mount whenever the toolbar tier changes. */
|
||||||
|
function TierKey({
|
||||||
|
tier,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tier: Tier;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TierBridge key={tier} tier={tier}>
|
||||||
|
{children}
|
||||||
|
</TierBridge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps useTheme() and the data-theme attribute in sync. */
|
||||||
|
function ThemeWatcher() {
|
||||||
|
useEffect(() => {
|
||||||
|
// The addon-themes decorator already sets data-theme on <html>.
|
||||||
|
// We just read it on mount so ThemeProvider picks it up.
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withProviders: Decorator = (Story, context) => {
|
||||||
|
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||||
|
// withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
|
||||||
|
// Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
|
||||||
|
// default surfaces) follows the dark toggle alongside the SUI CSS variables.
|
||||||
|
// The global initialises to "" (before any toolbar interaction), so treat
|
||||||
|
// 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";
|
||||||
|
return (
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
|
||||||
|
<TierKey tier={tier}>
|
||||||
|
<UIProvider>
|
||||||
|
<ThemeWatcher />
|
||||||
|
<Story />
|
||||||
|
</UIProvider>
|
||||||
|
</TierKey>
|
||||||
|
</MantineProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
loaders: [mswLoader],
|
||||||
|
parameters: {
|
||||||
|
layout: "padded",
|
||||||
|
controls: {
|
||||||
|
matchers: { color: /(background|color)$/i, date: /Date$/i },
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
default: "app",
|
||||||
|
values: [
|
||||||
|
{ name: "app", value: "var(--color-bg)" },
|
||||||
|
{ name: "surface", value: "var(--color-surface)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
a11y: {
|
||||||
|
// Run axe automatically against the story root; violations show in the
|
||||||
|
// Accessibility panel. `context` replaced `element` in addon-a11y 9.x.
|
||||||
|
context: "#storybook-root",
|
||||||
|
config: {},
|
||||||
|
options: {},
|
||||||
|
test: "todo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globalTypes: {
|
||||||
|
tier: {
|
||||||
|
name: "Tier",
|
||||||
|
description: "Subscription tier — drives useTier() everywhere",
|
||||||
|
defaultValue: "pro",
|
||||||
|
toolbar: {
|
||||||
|
icon: "star",
|
||||||
|
items: [
|
||||||
|
{ value: "free", title: "Free" },
|
||||||
|
{ value: "pro", title: "Pay-as-you-go" },
|
||||||
|
{ value: "enterprise", title: "Enterprise" },
|
||||||
|
],
|
||||||
|
dynamicTitle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
withProviders,
|
||||||
|
withThemeByDataAttribute({
|
||||||
|
themes: { light: "light", dark: "dark" },
|
||||||
|
defaultTheme: "light",
|
||||||
|
attributeName: "data-theme",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
@@ -5,6 +5,5 @@
|
|||||||
"moduleResolution": "node16",
|
"moduleResolution": "node16",
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts", "./**/*.mts"],
|
"include": ["./**/*.ts", "./**/*.mts"]
|
||||||
"exclude": []
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "../../",
|
"baseUrl": "../../",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/core/*"]
|
"@app/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
|
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"baseUrl": "../../",
|
"baseUrl": "../../",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/proprietary/*", "src/core/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
|
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/core/*"]
|
"@app/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": ["src/proprietary", "src/desktop", "node_modules"]
|
"exclude": ["src/proprietary", "src/desktop"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/core/**/*.test.ts*",
|
"src/core/**/*.test.ts*",
|
||||||
"src/core/**/*.spec.ts*",
|
"src/core/**/*.spec.ts*",
|
||||||
"src/proprietary/**/*.test.ts*",
|
"src/proprietary/**/*.test.ts*",
|
||||||
"src/proprietary/**/*.spec.ts*",
|
"src/proprietary/**/*.spec.ts*"
|
||||||
"node_modules"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
/* Specify a set of entries that re-map imports to additional lookup locations. */
|
/* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/desktop/*", "src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
},
|
},
|
||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["src/core/**/*.test.ts*", "src/core/**/*.spec.ts*", "src/desktop"]
|
||||||
"src/core/**/*.test.ts*",
|
|
||||||
"src/core/**/*.spec.ts*",
|
|
||||||
"src/desktop",
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/prototypes/*", "src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
@@ -13,7 +14,6 @@
|
|||||||
"src/proprietary/**/*.test.ts*",
|
"src/proprietary/**/*.test.ts*",
|
||||||
"src/proprietary/**/*.spec.ts*",
|
"src/proprietary/**/*.spec.ts*",
|
||||||
"src/desktop",
|
"src/desktop",
|
||||||
"src/saas",
|
"src/saas"
|
||||||
"node_modules"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
|
"@app/*": ["src/saas/*", "src/proprietary/*", "src/core/*"],
|
||||||
"@proprietary/*": ["src/proprietary/*"],
|
"@proprietary/*": ["src/proprietary/*"],
|
||||||
"@core/*": ["src/core/*"]
|
"@core/*": ["src/core/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
@@ -12,7 +13,6 @@
|
|||||||
"src/core/**/*.spec.ts*",
|
"src/core/**/*.spec.ts*",
|
||||||
"src/proprietary/**/*.test.ts*",
|
"src/proprietary/**/*.test.ts*",
|
||||||
"src/proprietary/**/*.spec.ts*",
|
"src/proprietary/**/*.spec.ts*",
|
||||||
"src/desktop",
|
"src/desktop"
|
||||||
"node_modules"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,30 @@ import globals from "globals";
|
|||||||
import { defineConfig } from "eslint/config";
|
import { defineConfig } from "eslint/config";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
const srcGlobs = ["editor/src/**/*.{js,mjs,jsx,ts,tsx}"];
|
const srcGlobs = [
|
||||||
|
"editor/src/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
|
"portal/src/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
|
"portal/main.tsx",
|
||||||
|
"shared/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
|
];
|
||||||
const nodeGlobs = [
|
const nodeGlobs = [
|
||||||
"scripts/**/*.{js,ts,mjs,mts}",
|
"scripts/**/*.{js,ts,mjs,mts}",
|
||||||
"editor/scripts/**/*.{js,ts,mjs,mts}",
|
"editor/scripts/**/*.{js,ts,mjs,mts}",
|
||||||
"editor/*.config.{js,ts,mjs}",
|
"editor/*.config.{js,ts,mjs}",
|
||||||
|
"portal/*.config.{js,ts,mjs}",
|
||||||
"*.config.{js,ts,mjs}",
|
"*.config.{js,ts,mjs}",
|
||||||
|
".storybook/*.{js,ts,mjs,mts,tsx}",
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseRestrictedImportPatterns = [
|
const baseRestrictedImportPatterns = [
|
||||||
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
|
{
|
||||||
|
regex: "^\\.",
|
||||||
|
message:
|
||||||
|
"Use a workspace alias (@app/* for editor, @portal/* for portal, @shared/*) instead of relative imports.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
regex: "^src/",
|
regex: "^src/",
|
||||||
message: "Use @app/* imports instead of absolute src/ imports.",
|
message: "Use a workspace alias instead of absolute src/ imports.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -36,6 +47,7 @@ export default defineConfig(
|
|||||||
"editor/src-tauri",
|
"editor/src-tauri",
|
||||||
"editor/playwright-report",
|
"editor/playwright-report",
|
||||||
"editor/test-results",
|
"editor/test-results",
|
||||||
|
"portal/public",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
@@ -92,6 +104,45 @@ export default defineConfig(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// The shared/ layer is the seed of a future packages/shared-ui — it must
|
||||||
|
// only depend on third-party packages and on itself. If it ever imports
|
||||||
|
// from editor or portal layers, extraction to a standalone package later
|
||||||
|
// becomes a rewrite instead of a `git mv`.
|
||||||
|
{
|
||||||
|
files: ["shared/**/*.{js,mjs,jsx,ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
...baseRestrictedImportPatterns,
|
||||||
|
{
|
||||||
|
regex: "^@app/",
|
||||||
|
message:
|
||||||
|
"shared/ must not depend on the editor layer (@app/* resolves into editor/src/).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: "^@portal/",
|
||||||
|
message:
|
||||||
|
"shared/ must not depend on the portal layer. Use @shared/* or third-party imports only.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: "^@core/",
|
||||||
|
message: "shared/ must not depend on editor/src/core/.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: "^@proprietary/",
|
||||||
|
message: "shared/ must not depend on editor/src/proprietary/.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regex: "^@tauri-apps/",
|
||||||
|
message: "shared/ must remain web-compatible (no Tauri APIs).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
|
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
@@ -99,6 +150,8 @@ export default defineConfig(
|
|||||||
"editor/src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
|
"editor/src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
"editor/src/saas/**/*.{js,mjs,jsx,ts,tsx}",
|
"editor/src/saas/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
"editor/src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
|
"editor/src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
|
"portal/src/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
|
"shared/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|||||||
Generated
+1715
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,10 @@
|
|||||||
"@iconify-json/material-symbols": "^1.2.53",
|
"@iconify-json/material-symbols": "^1.2.53",
|
||||||
"@iconify/utils": "^3.1.0",
|
"@iconify/utils": "^3.1.0",
|
||||||
"@playwright/test": "^1.55.0",
|
"@playwright/test": "^1.55.0",
|
||||||
|
"@storybook/addon-a11y": "^9.1.20",
|
||||||
|
"@storybook/addon-docs": "^9.1.20",
|
||||||
|
"@storybook/addon-themes": "^9.1.20",
|
||||||
|
"@storybook/react-vite": "^9.1.20",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.8.0",
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
@@ -128,6 +132,8 @@
|
|||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"license-checker": "^25.0.1",
|
"license-checker": "^25.0.1",
|
||||||
"madge": "^8.0.0",
|
"madge": "^8.0.0",
|
||||||
|
"msw": "^2.14.6",
|
||||||
|
"msw-storybook-addon": "^2.0.7",
|
||||||
"postcss": "^8.5.12",
|
"postcss": "^8.5.12",
|
||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
@@ -135,6 +141,7 @@
|
|||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"puppeteer": "^24.25.0",
|
"puppeteer": "^24.25.0",
|
||||||
"rollup-plugin-visualizer": "^7.0.1",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
|
"storybook": "^9.1.20",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.44.1",
|
"typescript-eslint": "^8.44.1",
|
||||||
@@ -153,5 +160,10 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"devalue": "^5.8.1"
|
"devalue": "^5.8.1"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"portal/public"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
Stirling PDF User License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Stirling PDF Inc.
|
||||||
|
|
||||||
|
License Scope & Usage Rights
|
||||||
|
|
||||||
|
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||||
|
|
||||||
|
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||||
|
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||||
|
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||||
|
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||||
|
covering the appropriate number of licensed users.
|
||||||
|
|
||||||
|
Trial and Minimal Use
|
||||||
|
|
||||||
|
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||||
|
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||||
|
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||||
|
|
||||||
|
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||||
|
|
||||||
|
Modifications and Derivative Works
|
||||||
|
|
||||||
|
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||||
|
|
||||||
|
* May not be deployed in production environments without a valid User License;
|
||||||
|
* May not be distributed or sublicensed;
|
||||||
|
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||||
|
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||||
|
|
||||||
|
Prohibited Actions
|
||||||
|
|
||||||
|
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||||
|
|
||||||
|
* Use the Software in production environments;
|
||||||
|
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||||
|
* Remove or alter any licensing or copyright notices;
|
||||||
|
* Circumvent access restrictions or licensing requirements.
|
||||||
|
|
||||||
|
Third-Party Components
|
||||||
|
|
||||||
|
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||||
|
their original license terms as provided by their respective owners.
|
||||||
|
|
||||||
|
Disclaimer
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||||
|
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en-GB">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<meta name="description" content="Stirling developer portal" />
|
||||||
|
<title>Stirling — Developer Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "@portal/App";
|
||||||
|
import { readMocksPreference } from "@portal/mocks/preference";
|
||||||
|
|
||||||
|
// Mantine's prebuilt styles load first so SUI tokens/base can override on
|
||||||
|
// conflicts — SUI is the primary design language, Mantine the escape hatch.
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import "@shared/tokens/tokens.css";
|
||||||
|
import "@shared/tokens/base.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw new Error("No #root element");
|
||||||
|
|
||||||
|
// Start MSW before React mounts when the user has mocks enabled. The
|
||||||
|
// preference defaults to ON in dev / OFF in production. The toggle in the
|
||||||
|
// header flips it at runtime.
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
if (readMocksPreference()) {
|
||||||
|
// Dynamic import keeps MSW + every handler + every fixture out of any
|
||||||
|
// chunk that doesn't need to actually run the worker.
|
||||||
|
const { startMockWorker } = await import("@portal/mocks/browser");
|
||||||
|
await startMockWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = "2.14.6";
|
||||||
|
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
|
||||||
|
const activeClientIds = new Set();
|
||||||
|
|
||||||
|
addEventListener("install", function () {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("activate", function (event) {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("message", async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, "id");
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case "KEEPALIVE_REQUEST": {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: "KEEPALIVE_RESPONSE",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "INTEGRITY_CHECK_REQUEST": {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: "INTEGRITY_CHECK_RESPONSE",
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "MOCK_ACTIVATE": {
|
||||||
|
activeClientIds.add(clientId);
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: "MOCKING_ENABLED",
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CLIENT_CLOSED": {
|
||||||
|
activeClientIds.delete(clientId);
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("fetch", function (event) {
|
||||||
|
const requestInterceptedAt = Date.now();
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === "only-if-cached" &&
|
||||||
|
event.request.mode !== "same-origin"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event);
|
||||||
|
const requestCloneForEvents = event.request.clone();
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents);
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = response.clone();
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: "RESPONSE",
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: responseClone.type,
|
||||||
|
status: responseClone.status,
|
||||||
|
statusText: responseClone.statusText,
|
||||||
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||||
|
body: responseClone.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId);
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === "top-level") {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
});
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === "visible";
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone();
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers);
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get("accept");
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(",").map((value) => value.trim());
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== "msw/passthrough",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set("accept", filteredValues.join(", "));
|
||||||
|
} else {
|
||||||
|
headers.delete("accept");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request);
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: "REQUEST",
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case "MOCK_RESPONSE": {
|
||||||
|
return respondWithMock(clientMessage.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "PASSTHROUGH": {
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response);
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mockedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||||
|
import { TierProvider } from "@portal/contexts/TierContext";
|
||||||
|
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||||
|
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||||
|
import { AppShell } from "@portal/components/AppShell";
|
||||||
|
import { AssistantButton } from "@portal/components/AssistantButton";
|
||||||
|
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||||
|
import { SearchModal } from "@portal/components/SearchModal";
|
||||||
|
import { ViewRouter } from "@portal/ViewRouter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
|
||||||
|
* components follow the same light/dark switch as the SUI primitives. Must sit
|
||||||
|
* inside <ThemeProvider> to read useTheme().
|
||||||
|
*/
|
||||||
|
function PortalMantineProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<MantineProvider theme={mantineTheme} forceColorScheme={theme}>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch
|
||||||
|
* into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search
|
||||||
|
* palette.
|
||||||
|
*/
|
||||||
|
function GlobalShortcuts() {
|
||||||
|
const { toggleSearch, closeSearch } = useUI();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||||
|
if (isCmdK) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [toggleSearch, closeSearch]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<PortalMantineProvider>
|
||||||
|
<TierProvider initialTier="pro">
|
||||||
|
<BrowserRouter>
|
||||||
|
<UIProvider>
|
||||||
|
<GlobalShortcuts />
|
||||||
|
<AppShell>
|
||||||
|
<ViewRouter />
|
||||||
|
</AppShell>
|
||||||
|
<AssistantButton />
|
||||||
|
<AssistantPanel />
|
||||||
|
<SearchModal />
|
||||||
|
</UIProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</TierProvider>
|
||||||
|
</PortalMantineProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { Home } from "@portal/views/Home";
|
||||||
|
import { Documents } from "@portal/views/Documents";
|
||||||
|
import { Placeholder } from "@portal/views/Placeholder";
|
||||||
|
import { VIEW_PATHS, type ViewId } from "@portal/contexts/ViewContext";
|
||||||
|
|
||||||
|
const PLACEHOLDER_PHASES: Partial<Record<ViewId, string>> = {
|
||||||
|
editor: "Phase 8 — Editor",
|
||||||
|
sources: "Phase 5 — Sources & Agents",
|
||||||
|
pipelines: "Phase 4 — Pipelines",
|
||||||
|
infrastructure: "Phase 7 — Infrastructure",
|
||||||
|
usage: "Phase 8 — Usage & Billing",
|
||||||
|
docs: "Phase 8 — Developer Docs",
|
||||||
|
settings: "Settings — modal overlay",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ViewRouter() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path={VIEW_PATHS.home} element={<Home />} />
|
||||||
|
<Route
|
||||||
|
path={VIEW_PATHS.pipelines}
|
||||||
|
element={
|
||||||
|
<Placeholder view="pipelines" phase={PLACEHOLDER_PHASES.pipelines} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={VIEW_PATHS.editor}
|
||||||
|
element={
|
||||||
|
<Placeholder view="editor" phase={PLACEHOLDER_PHASES.editor} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={VIEW_PATHS.sources}
|
||||||
|
element={
|
||||||
|
<Placeholder view="sources" phase={PLACEHOLDER_PHASES.sources} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path={VIEW_PATHS.documents} element={<Documents />} />
|
||||||
|
<Route
|
||||||
|
path={VIEW_PATHS.infrastructure}
|
||||||
|
element={
|
||||||
|
<Placeholder
|
||||||
|
view="infrastructure"
|
||||||
|
phase={PLACEHOLDER_PHASES.infrastructure}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={VIEW_PATHS.usage}
|
||||||
|
element={<Placeholder view="usage" phase={PLACEHOLDER_PHASES.usage} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={VIEW_PATHS.docs}
|
||||||
|
element={<Placeholder view="docs" phase={PLACEHOLDER_PHASES.docs} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={VIEW_PATHS.settings}
|
||||||
|
element={
|
||||||
|
<Placeholder view="settings" phase={PLACEHOLDER_PHASES.settings} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Unknown paths land on Home. */}
|
||||||
|
<Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { httpJson } from "@portal/api/http";
|
||||||
|
|
||||||
|
/** GET /v1/assistant/suggestions */
|
||||||
|
export async function fetchAssistantSuggestions(): Promise<readonly string[]> {
|
||||||
|
return httpJson<readonly string[]>("/v1/assistant/suggestions");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/assistant/messages */
|
||||||
|
export async function getAssistantReply(input: string): Promise<string> {
|
||||||
|
const res = await httpJson<{ reply: string }>("/v1/assistant/messages", {
|
||||||
|
method: "POST",
|
||||||
|
body: { input },
|
||||||
|
});
|
||||||
|
return res.reply;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { httpJson } from "@portal/api/http";
|
||||||
|
import type { Vertical } from "@shared/data/endpoints";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Endpoint,
|
||||||
|
EndpointSchema,
|
||||||
|
EndpointTierGate,
|
||||||
|
Vertical,
|
||||||
|
VerticalKey,
|
||||||
|
} from "@shared/data/endpoints";
|
||||||
|
|
||||||
|
/** GET /v1/endpoints — verticals plus their endpoints. */
|
||||||
|
export async function fetchVerticals(): Promise<Vertical[]> {
|
||||||
|
return httpJson<Vertical[]>("/v1/endpoints");
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { httpJson } from "@portal/api/http";
|
||||||
|
import type {
|
||||||
|
ActivityEvent,
|
||||||
|
KpiEntry,
|
||||||
|
OnboardingStep,
|
||||||
|
RegionHealth,
|
||||||
|
UsageSeriesResponse,
|
||||||
|
} from "@portal/mocks/home";
|
||||||
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ActivityEvent,
|
||||||
|
ActivityKind,
|
||||||
|
KpiEntry,
|
||||||
|
OnboardingStep,
|
||||||
|
RegionHealth,
|
||||||
|
UsagePoint,
|
||||||
|
UsageSeriesResponse,
|
||||||
|
} from "@portal/mocks/home";
|
||||||
|
|
||||||
|
/** GET /v1/analytics/usage?window=30d */
|
||||||
|
export async function fetchUsageSeries(): Promise<UsageSeriesResponse> {
|
||||||
|
return httpJson<UsageSeriesResponse>("/v1/analytics/usage?window=30d");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/activity?limit=8 */
|
||||||
|
export async function fetchRecentActivity(): Promise<ActivityEvent[]> {
|
||||||
|
return httpJson<ActivityEvent[]>("/v1/activity?limit=8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/home/kpis?tier=… */
|
||||||
|
export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
|
||||||
|
return httpJson<KpiEntry[]>(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/regions/health (Enterprise) */
|
||||||
|
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
|
||||||
|
return httpJson<RegionHealth[]>("/v1/regions/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/onboarding (Free) */
|
||||||
|
export async function fetchOnboarding(): Promise<OnboardingStep[]> {
|
||||||
|
return httpJson<OnboardingStep[]>("/v1/onboarding");
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Shared HTTP plumbing for the portal's service layer.
|
||||||
|
*
|
||||||
|
* Every `api/*.ts` module calls {@link httpJson}, which issues a real `fetch`.
|
||||||
|
* In dev and Storybook those requests are intercepted by the MSW handlers in
|
||||||
|
* `mocks/` and answered with fixture data; pointing at a real backend is just
|
||||||
|
* a matter of not registering MSW. Consumers don't change either way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HttpRequestOptions {
|
||||||
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
body?: unknown;
|
||||||
|
/** Extra headers; Content-Type and Accept are set automatically. */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly statusText: string,
|
||||||
|
public readonly body: unknown,
|
||||||
|
) {
|
||||||
|
super(`${status} ${statusText}`);
|
||||||
|
this.name = "HttpError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin JSON fetch wrapper used by every api module. In dev/Storybook the
|
||||||
|
* request is served by MSW; against a real backend it hits the network.
|
||||||
|
*/
|
||||||
|
export async function httpJson<T>(
|
||||||
|
path: string,
|
||||||
|
options: HttpRequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: options.method ?? "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
...(options.body !== undefined
|
||||||
|
? { "Content-Type": "application/json" }
|
||||||
|
: {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
// ignore — non-JSON error response
|
||||||
|
}
|
||||||
|
throw new HttpError(res.status, res.statusText, body);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { httpJson } from "@portal/api/http";
|
||||||
|
import type {
|
||||||
|
Notification,
|
||||||
|
NotificationCategory,
|
||||||
|
} from "@portal/mocks/notifications";
|
||||||
|
|
||||||
|
export type { Notification, NotificationCategory };
|
||||||
|
|
||||||
|
/** GET /v1/notifications */
|
||||||
|
export async function fetchNotifications(): Promise<Notification[]> {
|
||||||
|
return httpJson<Notification[]>("/v1/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/notifications/mark-all-read */
|
||||||
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
|
await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { HttpError, httpJson } from "@portal/api/http";
|
||||||
|
import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops";
|
||||||
|
|
||||||
|
export type { FeaturedOp, OpResultMap };
|
||||||
|
|
||||||
|
/** GET /v1/ops/featured */
|
||||||
|
export async function fetchFeaturedOps(): Promise<FeaturedOp[]> {
|
||||||
|
return httpJson<FeaturedOp[]>("/v1/ops/featured");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnknownOpError extends Error {
|
||||||
|
constructor(public readonly opId: string) {
|
||||||
|
super(`Unknown op: ${opId}`);
|
||||||
|
this.name = "UnknownOpError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/ops/{opId}/run */
|
||||||
|
export async function runSingleOp(
|
||||||
|
opId: string,
|
||||||
|
sample: string,
|
||||||
|
): Promise<{ result: OpResultMap; durationMs: number }> {
|
||||||
|
try {
|
||||||
|
return await httpJson<{ result: OpResultMap; durationMs: number }>(
|
||||||
|
`/v1/ops/${encodeURIComponent(opId)}/run`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: { sample },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError && err.status === 404) {
|
||||||
|
throw new UnknownOpError(opId);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { httpJson } from "@portal/api/http";
|
||||||
|
import type { QuickAction } from "@portal/mocks/search";
|
||||||
|
|
||||||
|
export type { QuickAction };
|
||||||
|
|
||||||
|
/** GET /v1/search/quick-actions */
|
||||||
|
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
||||||
|
return httpJson<QuickAction[]>("/v1/search/quick-actions");
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.portal-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-shell__main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0; /* prevent grid blowout on narrow content */
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-shell__view {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: fadeInUp var(--motion-enter) both;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Sidebar } from "@portal/components/Sidebar";
|
||||||
|
import { Header } from "@portal/components/Header";
|
||||||
|
import "@portal/components/AppShell.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column layout: fixed-width sidebar on the left, sticky header + scrolling
|
||||||
|
* main column on the right. The Sidebar and Header read their state from
|
||||||
|
* context, so this shell stays prop-free.
|
||||||
|
*/
|
||||||
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="portal-shell">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="portal-shell__main">
|
||||||
|
<Header />
|
||||||
|
<main className="portal-shell__view">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.portal-assistant-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--color-blue) 0%,
|
||||||
|
var(--color-purple) 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 0.5rem 1.25rem rgba(59, 130, 246, 0.35),
|
||||||
|
inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2);
|
||||||
|
transition:
|
||||||
|
transform var(--motion-base),
|
||||||
|
box-shadow var(--motion-base);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant-btn:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow:
|
||||||
|
0 0.75rem 1.75rem rgba(59, 130, 246, 0.45),
|
||||||
|
inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant-btn:active {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
import { SparklesIcon } from "@portal/components/icons";
|
||||||
|
import "@portal/components/AssistantButton.css";
|
||||||
|
|
||||||
|
export function AssistantButton() {
|
||||||
|
const { assistantOpen, toggleAssistant } = useUI();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"portal-assistant-btn" + (assistantOpen ? " is-active" : "")}
|
||||||
|
onClick={toggleAssistant}
|
||||||
|
aria-label={assistantOpen ? "Close assistant" : "Open assistant"}
|
||||||
|
aria-expanded={assistantOpen}
|
||||||
|
title="Assistant"
|
||||||
|
>
|
||||||
|
{assistantOpen ? (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<SparklesIcon size={22} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
.portal-assistant {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 5.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 23.75rem;
|
||||||
|
height: 32.5rem;
|
||||||
|
max-height: calc(100vh - 7rem);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 35;
|
||||||
|
animation: fadeInUp var(--motion-enter) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--color-blue) 0%,
|
||||||
|
var(--color-purple) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast),
|
||||||
|
color var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-assistant__close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__messages {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__suggestions {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__suggestions-eyebrow {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-section-label);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__suggestions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__suggestion {
|
||||||
|
padding: 0.4375rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
background var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-assistant__suggestion:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-blue-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
animation: fadeInUp var(--motion-enter) both;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__bubble--user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--color-blue);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
border-bottom-right-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__bubble--assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-bottom-left-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__typing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.portal-assistant__typing span {
|
||||||
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-5);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.portal-assistant__typing span:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
.portal-assistant__typing span:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border-input);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.4375rem 0.625rem;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-assistant__input:focus {
|
||||||
|
border-color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-assistant__send {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--grad-blue-btn);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
transition: opacity var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-assistant__send:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||||
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
|
||||||
|
function ForceOpen() {
|
||||||
|
const { openAssistant } = useUI();
|
||||||
|
useEffect(() => {
|
||||||
|
openAssistant();
|
||||||
|
}, [openAssistant]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof AssistantPanel> = {
|
||||||
|
title: "Portal/Assistant/AssistantPanel",
|
||||||
|
component: AssistantPanel,
|
||||||
|
parameters: { layout: "fullscreen" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||||
|
<ForceOpen />
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof AssistantPanel>;
|
||||||
|
|
||||||
|
export const SuggestionsOnly: Story = {};
|
||||||
|
|
||||||
|
export const SlowReply: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.post("/v1/assistant/messages", async () => {
|
||||||
|
await delay(3000);
|
||||||
|
return HttpResponse.json({
|
||||||
|
reply:
|
||||||
|
"After a long pause: here's a deliberately slow reply to demo the typing indicator under real network latency.",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReplyFails: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.post("/v1/assistant/messages", () =>
|
||||||
|
HttpResponse.json({ error: "rate limit exceeded" }, { status: 429 }),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
import { useAsync } from "@portal/hooks/useAsync";
|
||||||
|
import {
|
||||||
|
fetchAssistantSuggestions,
|
||||||
|
getAssistantReply,
|
||||||
|
} from "@portal/api/assistant";
|
||||||
|
import { CloseIcon, SendIcon, SparklesIcon } from "@portal/components/icons";
|
||||||
|
import "@portal/components/AssistantPanel.css";
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssistantPanel() {
|
||||||
|
const { assistantOpen, closeAssistant } = useUI();
|
||||||
|
const { data: suggestions } = useAsync<readonly string[]>(
|
||||||
|
() => fetchAssistantSuggestions(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [typing, setTyping] = useState(false);
|
||||||
|
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const nextIdRef = useRef(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (assistantOpen) {
|
||||||
|
const t = setTimeout(() => inputRef.current?.focus(), 60);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [assistantOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current?.scrollTo({
|
||||||
|
top: messagesRef.current.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [messages, typing]);
|
||||||
|
|
||||||
|
async function send(text: string) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || typing) return;
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: nextIdRef.current++,
|
||||||
|
role: "user",
|
||||||
|
text: trimmed,
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
|
setInput("");
|
||||||
|
setTyping(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reply = await getAssistantReply(trimmed);
|
||||||
|
const assistantMsg: Message = {
|
||||||
|
id: nextIdRef.current++,
|
||||||
|
role: "assistant",
|
||||||
|
text: reply,
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, assistantMsg]);
|
||||||
|
} catch (err) {
|
||||||
|
const failMsg: Message = {
|
||||||
|
id: nextIdRef.current++,
|
||||||
|
role: "assistant",
|
||||||
|
text:
|
||||||
|
err instanceof Error
|
||||||
|
? `Couldn't reach the assistant: ${err.message}`
|
||||||
|
: "Couldn't reach the assistant.",
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, failMsg]);
|
||||||
|
} finally {
|
||||||
|
setTyping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assistantOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="portal-assistant" role="dialog" aria-label="Assistant">
|
||||||
|
<header className="portal-assistant__header">
|
||||||
|
<div className="portal-assistant__header-left">
|
||||||
|
<SparklesIcon size={16} />
|
||||||
|
<span className="portal-assistant__title">Assistant</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-assistant__close"
|
||||||
|
onClick={closeAssistant}
|
||||||
|
aria-label="Close assistant"
|
||||||
|
>
|
||||||
|
<CloseIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="portal-assistant__messages" ref={messagesRef}>
|
||||||
|
{messages.length === 0 && suggestions && (
|
||||||
|
<div className="portal-assistant__suggestions">
|
||||||
|
<div className="portal-assistant__suggestions-eyebrow">
|
||||||
|
Try asking
|
||||||
|
</div>
|
||||||
|
<div className="portal-assistant__suggestions-list">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className="portal-assistant__suggestion"
|
||||||
|
onClick={() => send(s)}
|
||||||
|
disabled={typing}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={
|
||||||
|
"portal-assistant__bubble portal-assistant__bubble--" + m.role
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{typing && (
|
||||||
|
<div className="portal-assistant__bubble portal-assistant__bubble--assistant">
|
||||||
|
<span className="portal-assistant__typing" aria-label="Typing">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="portal-assistant__input-row"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send(input);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Ask about Stirling…"
|
||||||
|
aria-label="Ask the assistant"
|
||||||
|
className="portal-assistant__input"
|
||||||
|
disabled={typing}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="portal-assistant__send"
|
||||||
|
disabled={!input.trim() || typing}
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
<SendIcon size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
.portal-doctype {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.portal-doctype__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.3125rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
background var(--motion-fast),
|
||||||
|
color var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__tab:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__tab.is-active {
|
||||||
|
color: var(--portal-tab-accent, var(--color-blue));
|
||||||
|
border-color: var(--portal-tab-accent, var(--color-blue));
|
||||||
|
background: var(--color-surface);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__tab-dot {
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__tab-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical groups */
|
||||||
|
.portal-doctype__groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__group-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__group-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__scroller {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__scroller::-webkit-scrollbar {
|
||||||
|
height: 0.375rem;
|
||||||
|
}
|
||||||
|
.portal-doctype__scroller::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-hover);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.portal-doctype__card {
|
||||||
|
flex: 0 0 17rem;
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
box-shadow var(--motion-fast),
|
||||||
|
transform var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-0.0625rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-accent {
|
||||||
|
width: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-eyebrow {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-title {
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-meta {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__regions {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__regions-more {
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-cta {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-cta:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__skel,
|
||||||
|
.portal-doctype__card-skel {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-bg-muted) 0%,
|
||||||
|
var(--color-bg-hover) 50%,
|
||||||
|
var(--color-bg-muted) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__skel {
|
||||||
|
display: inline-block;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-doctype__card-skel {
|
||||||
|
flex: 0 0 17rem;
|
||||||
|
height: 8rem;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
|
||||||
|
|
||||||
|
const meta: Meta<typeof DocumentTypeGrid> = {
|
||||||
|
title: "Portal/Home/DocumentTypeGrid",
|
||||||
|
component: DocumentTypeGrid,
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ maxWidth: "72rem" }}>
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof DocumentTypeGrid>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get("/v1/endpoints", async () => {
|
||||||
|
await delay("infinite");
|
||||||
|
return HttpResponse.json([]);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [http.get("/v1/endpoints", () => HttpResponse.json([]))],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
Skeleton,
|
||||||
|
StatusBadge,
|
||||||
|
Tabs,
|
||||||
|
type TabItem,
|
||||||
|
} from "@shared/components";
|
||||||
|
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||||
|
import {
|
||||||
|
fetchVerticals,
|
||||||
|
type Endpoint,
|
||||||
|
type Vertical,
|
||||||
|
type VerticalKey,
|
||||||
|
} from "@portal/api/endpoints";
|
||||||
|
import "@portal/components/DocumentTypeGrid.css";
|
||||||
|
|
||||||
|
type ActiveTab = VerticalKey | "all";
|
||||||
|
|
||||||
|
const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const;
|
||||||
|
const TIER_TONE = ["success", "info", "purple"] as const;
|
||||||
|
|
||||||
|
function tierBadge(tier: 0 | 1 | 2) {
|
||||||
|
return (
|
||||||
|
<StatusBadge tone={TIER_TONE[tier]} size="sm">
|
||||||
|
{TIER_LABEL[tier]}
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndpointCard({
|
||||||
|
endpoint,
|
||||||
|
vertical,
|
||||||
|
}: {
|
||||||
|
endpoint: Endpoint;
|
||||||
|
vertical: Vertical;
|
||||||
|
}) {
|
||||||
|
const visibleRegions = endpoint.regions.slice(0, 2);
|
||||||
|
const extraRegions = endpoint.regions.length - visibleRegions.length;
|
||||||
|
return (
|
||||||
|
<article className="portal-doctype__card">
|
||||||
|
<div
|
||||||
|
className="portal-doctype__card-accent"
|
||||||
|
style={{ background: vertical.color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="portal-doctype__card-body">
|
||||||
|
<div className="portal-doctype__card-eyebrow">
|
||||||
|
{vertical.label.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<h3 className="portal-doctype__card-title">{endpoint.name}</h3>
|
||||||
|
<p className="portal-doctype__card-desc">{endpoint.desc}</p>
|
||||||
|
<div className="portal-doctype__card-meta">
|
||||||
|
<span className="portal-doctype__regions">
|
||||||
|
{visibleRegions.join(" · ")}
|
||||||
|
{extraRegions > 0 && (
|
||||||
|
<span className="portal-doctype__regions-more">
|
||||||
|
{" "}
|
||||||
|
+{extraRegions} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{tierBadge(endpoint.tier)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="portal-doctype__card-cta"
|
||||||
|
style={{ color: vertical.color }}
|
||||||
|
href={`#${endpoint.endpoint}`}
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Explore <span aria-hidden>→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="portal-doctype__groups" aria-hidden>
|
||||||
|
{Array.from({ length: 2 }).map((_, gi) => (
|
||||||
|
<div key={gi} className="portal-doctype__group">
|
||||||
|
<div className="portal-doctype__group-head">
|
||||||
|
<Skeleton shape="circle" width="0.4375rem" height="0.4375rem" />
|
||||||
|
<Skeleton width="6rem" />
|
||||||
|
</div>
|
||||||
|
<div className="portal-doctype__scroller">
|
||||||
|
{Array.from({ length: 4 }).map((_, ci) => (
|
||||||
|
<Skeleton key={ci} shape="rect" width="17rem" height="8rem" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentTypeGrid() {
|
||||||
|
const [tab, setTab] = useState<ActiveTab>("all");
|
||||||
|
const state = useAsync<Vertical[]>(() => fetchVerticals(), []);
|
||||||
|
const { data: verticals } = state;
|
||||||
|
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||||
|
const hasVerticals = verticals !== null && verticals.length > 0;
|
||||||
|
|
||||||
|
const tabItems: TabItem<ActiveTab>[] = hasVerticals
|
||||||
|
? [
|
||||||
|
{ key: "all", label: "All" },
|
||||||
|
...verticals.map<TabItem<ActiveTab>>((v) => ({
|
||||||
|
key: v.key,
|
||||||
|
label: v.label,
|
||||||
|
count: v.endpoints.length,
|
||||||
|
accentColor: v.color,
|
||||||
|
dotColor: v.color,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="portal-doctype" aria-label="Document types">
|
||||||
|
<header className="portal-doctype__head">
|
||||||
|
<h2 className="portal-doctype__title">Document types</h2>
|
||||||
|
<p className="portal-doctype__sub">
|
||||||
|
Typed endpoints across every supported vertical — each carries a
|
||||||
|
schema, region availability and tier gate.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{hasVerticals && (
|
||||||
|
<Tabs<ActiveTab>
|
||||||
|
items={tabItems}
|
||||||
|
activeKey={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
ariaLabel="Document type verticals"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <GridSkeleton />}
|
||||||
|
|
||||||
|
{isEmpty && (
|
||||||
|
<EmptyState
|
||||||
|
title="No document types yet"
|
||||||
|
description="When endpoints are registered they'll appear in this catalogue."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasVerticals && (
|
||||||
|
<div className="portal-doctype__groups">
|
||||||
|
{(tab === "all"
|
||||||
|
? verticals
|
||||||
|
: verticals.filter((v) => v.key === tab)
|
||||||
|
).map((v) => (
|
||||||
|
<div key={v.key} className="portal-doctype__group">
|
||||||
|
{tab === "all" && (
|
||||||
|
<div className="portal-doctype__group-head">
|
||||||
|
<span
|
||||||
|
className="portal-doctype__tab-dot"
|
||||||
|
style={{ background: v.color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<h3 className="portal-doctype__group-title">{v.label}</h3>
|
||||||
|
<span className="portal-doctype__group-count">
|
||||||
|
{v.endpoints.length} endpoints
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="portal-doctype__scroller">
|
||||||
|
{v.endpoints.map((endpoint) => (
|
||||||
|
<EndpointCard
|
||||||
|
key={endpoint.endpoint}
|
||||||
|
endpoint={endpoint}
|
||||||
|
vertical={v}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
.portal-header {
|
||||||
|
height: 3.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background: var(--color-header-bg);
|
||||||
|
border-bottom: 1px solid var(--color-header-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__left {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 12.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__breadcrumb {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-header-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search trigger */
|
||||||
|
.portal-header__search {
|
||||||
|
flex: 0 1 20rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
background: var(--color-search-bg);
|
||||||
|
border: 1px solid var(--color-search-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-search-text);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
background var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-header__search:hover {
|
||||||
|
border-color: var(--color-search-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__search-placeholder {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__search-kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
padding: 0.0625rem 0.25rem;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right cluster */
|
||||||
|
.portal-header__right {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-bell-stroke, var(--color-text-3));
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast),
|
||||||
|
color var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-header__icon-btn:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__bell-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.375rem;
|
||||||
|
right: 0.4375rem;
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-red);
|
||||||
|
border: 2px solid var(--color-header-bg);
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier switcher */
|
||||||
|
.portal-header__tier {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__tier-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.3125rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
background var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-header__tier-btn:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__tier-dot {
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__tier-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__tier-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
right: 0;
|
||||||
|
min-width: 12rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--color-dropdown-bg);
|
||||||
|
border: 1px solid var(--color-dropdown-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 20;
|
||||||
|
animation: fadeInUp var(--motion-enter) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-header__tier-option {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4375rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.portal-header__tier-option:hover {
|
||||||
|
background: var(--color-dropdown-hover);
|
||||||
|
}
|
||||||
|
.portal-header__tier-option.is-active {
|
||||||
|
background: var(--color-nav-active);
|
||||||
|
color: var(--color-nav-active-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.portal-header__avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--grad-blue-btn);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Header } from "@portal/components/Header";
|
||||||
|
|
||||||
|
const meta: Meta<typeof Header> = {
|
||||||
|
title: "Portal/Shell/Header",
|
||||||
|
component: Header,
|
||||||
|
parameters: { layout: "fullscreen" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ background: "var(--color-bg)" }}>
|
||||||
|
<S />
|
||||||
|
<div style={{ padding: "1.5rem", color: "var(--color-text-3)" }}>
|
||||||
|
Page body would render below the header.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Header>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const FreeTier: Story = { globals: { tier: "free" } };
|
||||||
|
export const ProTier: Story = { globals: { tier: "pro" } };
|
||||||
|
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Avatar, Dropdown } from "@shared/components";
|
||||||
|
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||||
|
import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
|
||||||
|
import { useView, VIEW_LABELS } from "@portal/contexts/ViewContext";
|
||||||
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
import {
|
||||||
|
SearchIcon,
|
||||||
|
SunIcon,
|
||||||
|
MoonIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from "@portal/components/icons";
|
||||||
|
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
|
||||||
|
import { MocksToggle } from "@portal/components/MocksToggle";
|
||||||
|
import "@portal/components/Header.css";
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-header__icon-btn"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={
|
||||||
|
theme === "light" ? "Switch to dark theme" : "Switch to light theme"
|
||||||
|
}
|
||||||
|
title={theme === "light" ? "Dark mode" : "Light mode"}
|
||||||
|
>
|
||||||
|
{theme === "light" ? <MoonIcon size={16} /> : <SunIcon size={16} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TierSwitcher() {
|
||||||
|
const { tier, setTier } = useTier();
|
||||||
|
const info = TIER_INFO[tier];
|
||||||
|
return (
|
||||||
|
<Dropdown.Root align="end">
|
||||||
|
<Dropdown.Trigger>
|
||||||
|
<button type="button" className="portal-header__tier-btn">
|
||||||
|
<span
|
||||||
|
className="portal-header__tier-dot"
|
||||||
|
style={{ background: info.dotColor }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="portal-header__tier-label">{info.label}</span>
|
||||||
|
<ChevronDownIcon size={14} />
|
||||||
|
</button>
|
||||||
|
</Dropdown.Trigger>
|
||||||
|
<Dropdown.Menu width="12rem">
|
||||||
|
{(Object.keys(TIER_INFO) as Tier[]).map((id) => (
|
||||||
|
<Dropdown.Item
|
||||||
|
key={id}
|
||||||
|
active={tier === id}
|
||||||
|
onSelect={() => setTier(id)}
|
||||||
|
leading={
|
||||||
|
<span
|
||||||
|
className="portal-header__tier-dot"
|
||||||
|
style={{ background: TIER_INFO[id].dotColor }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TIER_INFO[id].label}
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { activeView } = useView();
|
||||||
|
const { openSearch } = useUI();
|
||||||
|
return (
|
||||||
|
<header className="portal-header">
|
||||||
|
<div className="portal-header__left">
|
||||||
|
<span className="portal-header__breadcrumb">
|
||||||
|
{VIEW_LABELS[activeView]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-header__search"
|
||||||
|
aria-label="Search"
|
||||||
|
onClick={openSearch}
|
||||||
|
>
|
||||||
|
<SearchIcon size={14} />
|
||||||
|
<span className="portal-header__search-placeholder">Search…</span>
|
||||||
|
<span className="portal-header__search-kbd" aria-hidden>
|
||||||
|
⌘K
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="portal-header__right">
|
||||||
|
<MocksToggle />
|
||||||
|
<ThemeToggle />
|
||||||
|
<NotificationsDropdown />
|
||||||
|
<TierSwitcher />
|
||||||
|
<Avatar name="Reece" size="md" tone="blue" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
.portal-mocks-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1_5);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast),
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
color var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mocks-toggle.is-on {
|
||||||
|
color: var(--color-amber-dark);
|
||||||
|
background: var(--color-amber-light);
|
||||||
|
border-color: var(--color-amber-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mocks-toggle.is-off {
|
||||||
|
color: var(--color-text-4);
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mocks-toggle:hover {
|
||||||
|
filter: brightness(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mocks-toggle.is-pending {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mocks-toggle__dot {
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-mocks-toggle.is-off .portal-mocks-toggle__dot {
|
||||||
|
background: var(--color-text-5);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
readMocksPreference,
|
||||||
|
writeMocksPreference,
|
||||||
|
} from "@portal/mocks/preference";
|
||||||
|
import "@portal/components/MocksToggle.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only header chip that flips MSW interception on and off. Persists the
|
||||||
|
* preference to localStorage so it survives reloads. Hidden entirely in
|
||||||
|
* production builds — there's no MSW worker to toggle there.
|
||||||
|
*
|
||||||
|
* Toggling reloads the page. Without a reload, components that already
|
||||||
|
* fetched data via useAsync keep showing the cached result, which makes the
|
||||||
|
* toggle feel like it does nothing. A reload gives a clean view of what the
|
||||||
|
* app looks like with/without mocks.
|
||||||
|
*/
|
||||||
|
export function MocksToggle() {
|
||||||
|
const [enabled] = useState(() => readMocksPreference());
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
if (!import.meta.env.DEV) return null;
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (pending) return;
|
||||||
|
setPending(true);
|
||||||
|
writeMocksPreference(!enabled);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
"portal-mocks-toggle" +
|
||||||
|
(enabled ? " is-on" : " is-off") +
|
||||||
|
(pending ? " is-pending" : "")
|
||||||
|
}
|
||||||
|
onClick={toggle}
|
||||||
|
aria-pressed={enabled}
|
||||||
|
title={
|
||||||
|
enabled
|
||||||
|
? "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
|
||||||
|
: "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="portal-mocks-toggle__dot" aria-hidden />
|
||||||
|
<span className="portal-mocks-toggle__label">
|
||||||
|
Mocks {enabled ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
.portal-notif {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
right: 0;
|
||||||
|
width: 22.5rem;
|
||||||
|
background: var(--color-notif-bg, var(--color-surface));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 25;
|
||||||
|
animation: fadeInUp var(--motion-enter) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-blue);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: var(--color-blue-light);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 22rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__item:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 0.4375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__item-title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__time {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__action {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-blue);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__action:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__action:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__count--quiet {
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-notif__empty {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
|
||||||
|
import { NOTIFICATIONS } from "@portal/mocks/notifications";
|
||||||
|
|
||||||
|
const meta: Meta<typeof NotificationsDropdown> = {
|
||||||
|
title: "Portal/Header/NotificationsDropdown",
|
||||||
|
component: NotificationsDropdown,
|
||||||
|
parameters: { layout: "centered" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4rem 8rem",
|
||||||
|
minHeight: "30rem",
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof NotificationsDropdown>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get("/v1/notifications", async () => {
|
||||||
|
await delay("infinite");
|
||||||
|
return HttpResponse.json([]);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HighVolume: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get("/v1/notifications", () => {
|
||||||
|
const items = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
...NOTIFICATIONS[i % NOTIFICATIONS.length],
|
||||||
|
id: `n${i + 1}`,
|
||||||
|
}));
|
||||||
|
return HttpResponse.json(items);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NetworkError: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get("/v1/notifications", () =>
|
||||||
|
HttpResponse.json({ error: "Service unavailable" }, { status: 503 }),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dropdown, EmptyState, Skeleton } from "@shared/components";
|
||||||
|
import { BellIcon } from "@portal/components/icons";
|
||||||
|
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
type Notification,
|
||||||
|
type NotificationCategory,
|
||||||
|
} from "@portal/api/notifications";
|
||||||
|
import "@portal/components/NotificationsDropdown.css";
|
||||||
|
|
||||||
|
const CATEGORY_COLOUR: Record<NotificationCategory, string> = {
|
||||||
|
pipeline: "var(--color-blue)",
|
||||||
|
deploy: "var(--color-green)",
|
||||||
|
billing: "var(--color-amber)",
|
||||||
|
audit: "var(--color-purple)",
|
||||||
|
agent: "var(--color-cat-insurance)",
|
||||||
|
doc: "var(--color-cat-healthcare)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationsDropdown() {
|
||||||
|
const state = useAsync<Notification[]>(() => fetchNotifications(), []);
|
||||||
|
const { data: items } = state;
|
||||||
|
const { isLoading } = useSectionFlags(state);
|
||||||
|
|
||||||
|
// Optimistically clear on "mark all read"; revert if the request fails.
|
||||||
|
const [cleared, setCleared] = useState(false);
|
||||||
|
const visible = cleared ? [] : (items ?? []);
|
||||||
|
|
||||||
|
async function onMarkAllRead() {
|
||||||
|
setCleared(true);
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead();
|
||||||
|
} catch {
|
||||||
|
setCleared(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = !isLoading && visible.length === 0;
|
||||||
|
const hasUnread = visible.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root align="end">
|
||||||
|
<Dropdown.Trigger>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-header__icon-btn portal-header__icon-btn--badge"
|
||||||
|
aria-label={
|
||||||
|
hasUnread
|
||||||
|
? `Notifications, ${visible.length} unread`
|
||||||
|
: "Notifications, no unread"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BellIcon size={16} />
|
||||||
|
{hasUnread && (
|
||||||
|
<span className="portal-header__bell-dot" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Dropdown.Trigger>
|
||||||
|
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
|
||||||
|
<div className="portal-notif__header">
|
||||||
|
<span className="portal-notif__title">Notifications</span>
|
||||||
|
{hasUnread ? (
|
||||||
|
<span className="portal-notif__count">{visible.length} new</span>
|
||||||
|
) : isLoading ? (
|
||||||
|
<span className="portal-notif__count portal-notif__count--quiet">
|
||||||
|
loading
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="portal-notif__count portal-notif__count--quiet">
|
||||||
|
all read
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="portal-notif__loading">
|
||||||
|
<Skeleton height="0.875rem" />
|
||||||
|
<Skeleton height="0.6875rem" width="60%" />
|
||||||
|
<Skeleton height="0.875rem" />
|
||||||
|
<Skeleton height="0.6875rem" width="60%" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEmpty && (
|
||||||
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
title="You're all caught up"
|
||||||
|
description="No new notifications."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !isEmpty && (
|
||||||
|
<ul className="portal-notif__list">
|
||||||
|
{visible.map((item) => (
|
||||||
|
<li key={item.id} className="portal-notif__item">
|
||||||
|
<span
|
||||||
|
className="portal-notif__dot"
|
||||||
|
style={{ background: CATEGORY_COLOUR[item.category] }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="portal-notif__body">
|
||||||
|
<div className="portal-notif__item-title">{item.title}</div>
|
||||||
|
<div className="portal-notif__desc">{item.description}</div>
|
||||||
|
<div className="portal-notif__time">{item.time}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="portal-notif__footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-notif__action"
|
||||||
|
onClick={onMarkAllRead}
|
||||||
|
disabled={!hasUnread}
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
<button type="button" className="portal-notif__action">
|
||||||
|
View all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.portal-usecases {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__viewall,
|
||||||
|
.portal-usecases__cta {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
transition: opacity var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__viewall {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__viewall:hover,
|
||||||
|
.portal-usecases__cta:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
.portal-usecases__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__eyebrow {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__card-title {
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__blurb {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-usecases__cta {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Card, type CardProps } from "@shared/components";
|
||||||
|
import { useView } from "@portal/contexts/ViewContext";
|
||||||
|
import "@portal/components/PopularUseCases.css";
|
||||||
|
|
||||||
|
type Accent = NonNullable<CardProps["accent"]>;
|
||||||
|
|
||||||
|
interface UseCase {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
blurb: string;
|
||||||
|
cta: string;
|
||||||
|
accent: Accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCENT_COLOR: Record<Accent, string> = {
|
||||||
|
blue: "var(--color-blue)",
|
||||||
|
purple: "var(--color-purple)",
|
||||||
|
green: "var(--color-green)",
|
||||||
|
amber: "var(--color-amber)",
|
||||||
|
red: "var(--color-red)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated landing-page use cases — a teaser, not the full catalogue. The
|
||||||
|
* exhaustive per-vertical endpoint list lives on the Documents view; here we
|
||||||
|
* surface the four cross-cutting pipelines people reach for first. Copy mirrors
|
||||||
|
* the prototype's "Popular use cases" block.
|
||||||
|
*/
|
||||||
|
const USE_CASES: UseCase[] = [
|
||||||
|
{
|
||||||
|
eyebrow: "AUTO-ROUTING",
|
||||||
|
title: "Auto-classify and route incoming documents",
|
||||||
|
blurb:
|
||||||
|
"One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow.",
|
||||||
|
cta: "Build a classifier pipeline",
|
||||||
|
accent: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "PII REDACTION",
|
||||||
|
title: "Redact PII before it leaves your stack",
|
||||||
|
blurb:
|
||||||
|
"Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline.",
|
||||||
|
cta: "See redaction pipelines",
|
||||||
|
accent: "red",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "TRAINING DATA",
|
||||||
|
title: "Turn PDFs into training data",
|
||||||
|
blurb:
|
||||||
|
"Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable.",
|
||||||
|
cta: "Build a training-data pipeline",
|
||||||
|
accent: "purple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "AUTHENTICITY",
|
||||||
|
title: "Verify signatures and detect tampering",
|
||||||
|
blurb:
|
||||||
|
"Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code.",
|
||||||
|
cta: "Try authenticity check",
|
||||||
|
accent: "green",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PopularUseCases() {
|
||||||
|
const { setActiveView } = useView();
|
||||||
|
return (
|
||||||
|
<section className="portal-usecases" aria-label="Popular use cases">
|
||||||
|
<header className="portal-usecases__head">
|
||||||
|
<h2 className="portal-usecases__title">Popular use cases</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-usecases__viewall"
|
||||||
|
onClick={() => setActiveView("pipelines")}
|
||||||
|
>
|
||||||
|
View all pipelines <span aria-hidden>→</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="portal-usecases__grid">
|
||||||
|
{USE_CASES.map((uc) => (
|
||||||
|
<Card
|
||||||
|
key={uc.eyebrow}
|
||||||
|
accent={uc.accent}
|
||||||
|
padding="loose"
|
||||||
|
className="portal-usecases__card"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="portal-usecases__eyebrow"
|
||||||
|
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||||
|
>
|
||||||
|
{uc.eyebrow}
|
||||||
|
</span>
|
||||||
|
<h3 className="portal-usecases__card-title">{uc.title}</h3>
|
||||||
|
<p className="portal-usecases__blurb">{uc.blurb}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-usecases__cta"
|
||||||
|
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||||
|
onClick={() => setActiveView("pipelines")}
|
||||||
|
>
|
||||||
|
{uc.cta} <span aria-hidden>→</span>
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
.portal-activity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__more {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-blue);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__more:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
transition: background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__item:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__rail {
|
||||||
|
width: 0.1875rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__action {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__time {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__subject {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
margin-top: 0.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__detail-row {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__detail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__item--skeleton {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__item--skeleton .portal-activity__rail {
|
||||||
|
background: var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__skel {
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-bg-muted) 0%,
|
||||||
|
var(--color-bg-hover) 50%,
|
||||||
|
var(--color-bg-muted) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s linear infinite;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__skel--md {
|
||||||
|
width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-activity__skel--sm {
|
||||||
|
width: 50%;
|
||||||
|
height: 0.625rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { RecentActivity } from "@portal/components/RecentActivity";
|
||||||
|
|
||||||
|
const meta: Meta<typeof RecentActivity> = {
|
||||||
|
title: "Portal/Home/RecentActivity",
|
||||||
|
component: RecentActivity,
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ maxWidth: "40rem" }}>
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof RecentActivity>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get("/v1/activity", async () => {
|
||||||
|
await delay("infinite");
|
||||||
|
return HttpResponse.json([]);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [http.get("/v1/activity", () => HttpResponse.json([]))],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components";
|
||||||
|
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||||
|
import {
|
||||||
|
fetchRecentActivity,
|
||||||
|
type ActivityEvent,
|
||||||
|
type ActivityKind,
|
||||||
|
} from "@portal/api/home";
|
||||||
|
import "@portal/components/RecentActivity.css";
|
||||||
|
|
||||||
|
const KIND_COLOUR: Record<ActivityKind, string> = {
|
||||||
|
"pipeline-run": "var(--color-blue)",
|
||||||
|
deploy: "var(--color-green)",
|
||||||
|
drift: "var(--color-amber)",
|
||||||
|
eval: "var(--color-purple)",
|
||||||
|
agent: "var(--color-cat-insurance)",
|
||||||
|
billing: "var(--color-red)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecentActivity() {
|
||||||
|
const state = useAsync<ActivityEvent[]>(() => fetchRecentActivity(), []);
|
||||||
|
const { data: events } = state;
|
||||||
|
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
padding="none"
|
||||||
|
className="portal-activity"
|
||||||
|
aria-label="Recent activity"
|
||||||
|
>
|
||||||
|
<header className="portal-activity__head">
|
||||||
|
<h2 className="portal-activity__title">Recent activity</h2>
|
||||||
|
<button type="button" className="portal-activity__more">
|
||||||
|
View all →
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<ul className="portal-activity__list" aria-hidden>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="portal-activity__item portal-activity__item--skeleton"
|
||||||
|
>
|
||||||
|
<span className="portal-activity__rail" />
|
||||||
|
<div className="portal-activity__body">
|
||||||
|
<Skeleton width="70%" />
|
||||||
|
<Skeleton width="50%" height="0.625rem" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmpty && (
|
||||||
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
title="Nothing here yet"
|
||||||
|
description="Pipeline runs, deploys and agent events will appear here."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events && events.length > 0 && (
|
||||||
|
<ol className="portal-activity__list">
|
||||||
|
{events.map((event) => (
|
||||||
|
<li key={event.id} className="portal-activity__item">
|
||||||
|
<span
|
||||||
|
className="portal-activity__rail"
|
||||||
|
style={{ background: KIND_COLOUR[event.kind] }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="portal-activity__body">
|
||||||
|
<div className="portal-activity__row">
|
||||||
|
<span className="portal-activity__action">
|
||||||
|
{event.action}
|
||||||
|
</span>
|
||||||
|
<span className="portal-activity__time">{event.time}</span>
|
||||||
|
</div>
|
||||||
|
<div className="portal-activity__subject">{event.subject}</div>
|
||||||
|
<div className="portal-activity__detail-row">
|
||||||
|
<span className="portal-activity__detail">
|
||||||
|
{event.detail}
|
||||||
|
</span>
|
||||||
|
<StatusBadge tone={event.status} size="sm">
|
||||||
|
{event.status}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.portal-search__input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.25rem 0.25rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__input::placeholder {
|
||||||
|
color: var(--color-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__esc {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__results {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__group-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-section-label);
|
||||||
|
padding: 0 0.5rem 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__item:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search__item-hint {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { SearchModal } from "@portal/components/SearchModal";
|
||||||
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
|
||||||
|
function ForceOpen() {
|
||||||
|
const { openSearch } = useUI();
|
||||||
|
useEffect(() => {
|
||||||
|
openSearch();
|
||||||
|
}, [openSearch]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof SearchModal> = {
|
||||||
|
title: "Portal/Header/SearchModal",
|
||||||
|
component: SearchModal,
|
||||||
|
parameters: { layout: "fullscreen" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||||
|
<ForceOpen />
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof SearchModal>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const EmptyCatalogue: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { EmptyState, Modal, Skeleton } from "@shared/components";
|
||||||
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
|
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||||
|
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
|
||||||
|
import { SearchIcon } from "@portal/components/icons";
|
||||||
|
import "@portal/components/SearchModal.css";
|
||||||
|
|
||||||
|
export function SearchModal() {
|
||||||
|
const { searchOpen, closeSearch } = useUI();
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
|
||||||
|
const { data: actions } = state;
|
||||||
|
const { isLoading } = useSectionFlags(state);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchOpen) {
|
||||||
|
setQuery("");
|
||||||
|
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [searchOpen]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!actions) return [] as QuickAction[];
|
||||||
|
if (!query.trim()) return actions;
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
return actions.filter((item) => item.label.toLowerCase().includes(needle));
|
||||||
|
}, [actions, query]);
|
||||||
|
|
||||||
|
const groups = useMemo(
|
||||||
|
() =>
|
||||||
|
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
|
||||||
|
if (!acc[item.group]) acc[item.group] = [];
|
||||||
|
acc[item.group].push(item);
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
[filtered],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupKeys = Object.keys(groups);
|
||||||
|
const isEmpty = !isLoading && groupKeys.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={closeSearch}
|
||||||
|
width="lg"
|
||||||
|
ariaLabel="Search"
|
||||||
|
>
|
||||||
|
<div className="portal-search">
|
||||||
|
<div className="portal-search__input-row">
|
||||||
|
<SearchIcon size={16} />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search Stirling — endpoints, pipelines, docs…"
|
||||||
|
aria-label="Search"
|
||||||
|
className="portal-search__input"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="portal-search__esc" aria-hidden>
|
||||||
|
ESC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="portal-search__results">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="portal-search__loading">
|
||||||
|
<Skeleton height="1rem" width="40%" />
|
||||||
|
<Skeleton height="0.875rem" />
|
||||||
|
<Skeleton height="0.875rem" width="80%" />
|
||||||
|
<Skeleton height="0.875rem" width="65%" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEmpty && (
|
||||||
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
title={
|
||||||
|
query.trim()
|
||||||
|
? `No matches for "${query.trim()}"`
|
||||||
|
: "No quick actions"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
query.trim()
|
||||||
|
? "Try a different keyword or browse the catalogue."
|
||||||
|
: "Quick actions will appear here once they're available."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading &&
|
||||||
|
!isEmpty &&
|
||||||
|
Object.entries(groups).map(([group, items]) => (
|
||||||
|
<div key={group} className="portal-search__group">
|
||||||
|
<div className="portal-search__group-label">{group}</div>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
className="portal-search__item"
|
||||||
|
onClick={closeSearch}
|
||||||
|
>
|
||||||
|
<span className="portal-search__item-label">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="portal-search__item-hint">
|
||||||
|
{item.hint}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
.portal-sidebar {
|
||||||
|
width: 15rem;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--color-sidebar-bg);
|
||||||
|
border-right: 1px solid var(--color-sidebar-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo block */
|
||||||
|
.portal-sidebar__logo {
|
||||||
|
height: 3.1875rem; /* 51px */
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-sidebar-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__wordmark {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-logo-text);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__workspace {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-4);
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast),
|
||||||
|
color var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-sidebar__workspace:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav body */
|
||||||
|
.portal-sidebar__nav {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem 0.625rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-sidebar-divider);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.portal-sidebar__footer {
|
||||||
|
border-top: 1px solid var(--color-sidebar-divider);
|
||||||
|
padding: 0.5rem 0.625rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__usage {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-usage-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__usage-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__usage-label {
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__usage-value {
|
||||||
|
color: var(--color-usage-value);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__usage-track {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
background: var(--color-usage-track);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__usage-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--grad-blue-btn);
|
||||||
|
transition: width var(--motion-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__plan {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-sidebar__plan-dot {
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-green);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Sidebar } from "@portal/components/Sidebar";
|
||||||
|
|
||||||
|
const meta: Meta<typeof Sidebar> = {
|
||||||
|
title: "Portal/Shell/Sidebar",
|
||||||
|
component: Sidebar,
|
||||||
|
parameters: { layout: "fullscreen" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: "100vh",
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Sidebar>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const FreeTier: Story = { globals: { tier: "free" } };
|
||||||
|
|
||||||
|
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { NavItem } from "@shared/components";
|
||||||
|
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||||
|
import { useTier } from "@portal/contexts/TierContext";
|
||||||
|
import { useAsync } from "@portal/hooks/useAsync";
|
||||||
|
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
EditorIcon,
|
||||||
|
SourcesIcon,
|
||||||
|
PipelinesIcon,
|
||||||
|
DocumentsIcon,
|
||||||
|
InfrastructureIcon,
|
||||||
|
UsageIcon,
|
||||||
|
DocsIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from "@portal/components/icons";
|
||||||
|
import "@portal/components/Sidebar.css";
|
||||||
|
|
||||||
|
interface NavEntry {
|
||||||
|
id: ViewId;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_PRIMARY: NavEntry[] = [
|
||||||
|
{ id: "home", label: "Home", icon: <HomeIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_OPERATIONAL: NavEntry[] = [
|
||||||
|
{ id: "editor", label: "Editor", icon: <EditorIcon /> },
|
||||||
|
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
|
||||||
|
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
|
||||||
|
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_PLATFORM: NavEntry[] = [
|
||||||
|
{
|
||||||
|
id: "infrastructure",
|
||||||
|
label: "Infrastructure",
|
||||||
|
icon: <InfrastructureIcon />,
|
||||||
|
},
|
||||||
|
{ id: "usage", label: "Usage & Billing", icon: <UsageIcon /> },
|
||||||
|
{ id: "docs", label: "Developer Docs", icon: <DocsIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StirlingMark() {
|
||||||
|
// The Stirling brand mark — two stacked parallelograms in the brand blues.
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
aria-hidden
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<path d="M5 4 L20 4 L17 11 L2 11 Z" fill="var(--color-blue)" />
|
||||||
|
<path d="M5 11 L17 11 L14 18 L2 18 Z" fill="var(--color-blue-border)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageFooter() {
|
||||||
|
const { tier } = useTier();
|
||||||
|
// Read the same endpoint Home's KPI strip uses so the doc count here can't
|
||||||
|
// drift from the headline figure. The first KPI is always the doc total.
|
||||||
|
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||||
|
() => fetchHomeKpis(tier),
|
||||||
|
[tier],
|
||||||
|
);
|
||||||
|
const docs = loading ? undefined : kpis?.[0]?.value;
|
||||||
|
|
||||||
|
if (tier === "free") {
|
||||||
|
// The free doc KPI is formatted "used / cap"; parse it for the meter.
|
||||||
|
const [used, cap] =
|
||||||
|
typeof docs === "string"
|
||||||
|
? docs.split("/").map((s) => Number(s.replace(/[^\d]/g, "")))
|
||||||
|
: [];
|
||||||
|
const pct = used && cap ? (used / cap) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="portal-sidebar__usage portal-sidebar__usage--free">
|
||||||
|
<div className="portal-sidebar__usage-line">
|
||||||
|
<span className="portal-sidebar__usage-label">Docs processed</span>
|
||||||
|
<span className="portal-sidebar__usage-value">{docs ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="portal-sidebar__usage-track"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={used ?? 0}
|
||||||
|
aria-valuemax={cap ?? 100}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="portal-sidebar__usage-fill"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="portal-sidebar__usage">
|
||||||
|
<div className="portal-sidebar__usage-line">
|
||||||
|
<span className="portal-sidebar__plan">
|
||||||
|
<span className="portal-sidebar__plan-dot" aria-hidden />
|
||||||
|
{planLabel}
|
||||||
|
</span>
|
||||||
|
<span className="portal-sidebar__usage-value">
|
||||||
|
{docs != null ? `${docs} docs` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { activeView, setActiveView } = useView();
|
||||||
|
|
||||||
|
function renderGroup(entries: NavEntry[]) {
|
||||||
|
return entries.map((entry) => (
|
||||||
|
<NavItem
|
||||||
|
key={entry.id}
|
||||||
|
id={entry.id}
|
||||||
|
label={entry.label}
|
||||||
|
icon={entry.icon}
|
||||||
|
isActive={activeView === entry.id}
|
||||||
|
onClick={(id) => setActiveView(id as ViewId)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="portal-sidebar" aria-label="Primary navigation">
|
||||||
|
<div className="portal-sidebar__logo">
|
||||||
|
<StirlingMark />
|
||||||
|
<span className="portal-sidebar__wordmark">Stirling</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-sidebar__workspace"
|
||||||
|
aria-label="Switch workspace"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="portal-sidebar__nav">
|
||||||
|
<div className="portal-sidebar__group">
|
||||||
|
{renderGroup(GROUP_PRIMARY)}
|
||||||
|
</div>
|
||||||
|
<div className="portal-sidebar__divider" aria-hidden />
|
||||||
|
<div className="portal-sidebar__group">
|
||||||
|
{renderGroup(GROUP_OPERATIONAL)}
|
||||||
|
</div>
|
||||||
|
<div className="portal-sidebar__divider" aria-hidden />
|
||||||
|
<div className="portal-sidebar__group">
|
||||||
|
{renderGroup(GROUP_PLATFORM)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="portal-sidebar__footer">
|
||||||
|
<NavItem
|
||||||
|
id="settings"
|
||||||
|
label="Settings"
|
||||||
|
icon={<SettingsIcon />}
|
||||||
|
isActive={activeView === "settings"}
|
||||||
|
onClick={(id) => setActiveView(id as ViewId)}
|
||||||
|
/>
|
||||||
|
<UsageFooter />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
.portal-runner__layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
.portal-runner__layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__left,
|
||||||
|
.portal-runner__right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop zone */
|
||||||
|
.portal-runner__drop {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1.5px dashed var(--color-border-input);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
color: var(--color-text-3);
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__drop.is-armed,
|
||||||
|
.portal-runner__drop:hover {
|
||||||
|
border-color: var(--color-blue);
|
||||||
|
background: var(--color-blue-light);
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__drop.has-file {
|
||||||
|
border-color: var(--color-green);
|
||||||
|
background: var(--color-green-light);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__drop-icon {
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__drop.is-armed .portal-runner__drop-icon,
|
||||||
|
.portal-runner__drop.has-file .portal-runner__drop-icon {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__drop-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__drop-text strong {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__drop-text span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__sample-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-blue);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__sample-btn:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Op picker */
|
||||||
|
.portal-runner__section-title {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-section-label);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__ops {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op.is-selected {
|
||||||
|
border-color: var(--color-blue);
|
||||||
|
background: var(--color-blue-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op[data-accent="purple"].is-selected {
|
||||||
|
border-color: var(--color-purple);
|
||||||
|
background: var(--color-purple-light);
|
||||||
|
}
|
||||||
|
.portal-runner__op[data-accent="green"].is-selected {
|
||||||
|
border-color: var(--color-green);
|
||||||
|
background: var(--color-green-light);
|
||||||
|
}
|
||||||
|
.portal-runner__op[data-accent="amber"].is-selected {
|
||||||
|
border-color: var(--color-amber);
|
||||||
|
background: var(--color-amber-light);
|
||||||
|
}
|
||||||
|
.portal-runner__op[data-accent="red"].is-selected {
|
||||||
|
border-color: var(--color-red);
|
||||||
|
background: var(--color-red-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op-endpoint {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op-blurb {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__op--skeleton {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.portal-runner__op--skeleton:hover {
|
||||||
|
border-color: var(--color-border-light);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right column states */
|
||||||
|
.portal-runner__right {
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
padding: 1.125rem;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__hint {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__hint-eyebrow {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-blue);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__hint h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__hint h3 code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-blue);
|
||||||
|
background: var(--color-blue-light);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__hint p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__hint p code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__hint kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
padding: 0.0625rem 0.3125rem;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Running state */
|
||||||
|
.portal-runner__running {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-blue-light);
|
||||||
|
border: 1px solid var(--color-blue-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__running-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__running code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__spinner,
|
||||||
|
.portal-runner__spinner-lg {
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__spinner {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__spinner-lg {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done state */
|
||||||
|
.portal-runner__result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__result-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--code-bg-header);
|
||||||
|
border-bottom: 1px solid var(--code-border);
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
color: var(--code-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__result-head code {
|
||||||
|
flex: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--code-keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__result-meta {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--code-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__result-code {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre;
|
||||||
|
max-height: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.portal-runner__footer-status {
|
||||||
|
margin-right: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-runner__footer-status code {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
|
||||||
|
|
||||||
|
const meta: Meta<typeof SingleOpRunner> = {
|
||||||
|
title: "Portal/Home/SingleOpRunner",
|
||||||
|
component: SingleOpRunner,
|
||||||
|
parameters: { layout: "fullscreen" },
|
||||||
|
args: { open: true, onClose: () => console.log("close") },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof SingleOpRunner>;
|
||||||
|
|
||||||
|
export const Idle: Story = {};
|
||||||
|
|
||||||
|
export const PreSelectedOp: Story = {
|
||||||
|
args: { initialOpId: "redact" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RunFailsWithUnknownOp: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.post("/v1/ops/:opId/run", () =>
|
||||||
|
HttpResponse.json({ error: "Unknown op" }, { status: 404 }),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlowOp: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.post("/v1/ops/:opId/run", async () => {
|
||||||
|
await delay(4000);
|
||||||
|
return HttpResponse.json({
|
||||||
|
result: { schema: "demo", note: "took its time" },
|
||||||
|
durationMs: 4000,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
EmptyState,
|
||||||
|
Modal,
|
||||||
|
Skeleton,
|
||||||
|
StatusBadge,
|
||||||
|
} from "@shared/components";
|
||||||
|
import { useView } from "@portal/contexts/ViewContext";
|
||||||
|
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||||
|
import {
|
||||||
|
fetchFeaturedOps,
|
||||||
|
runSingleOp,
|
||||||
|
type FeaturedOp,
|
||||||
|
type OpResultMap,
|
||||||
|
} from "@portal/api/ops";
|
||||||
|
import "@portal/components/SingleOpRunner.css";
|
||||||
|
|
||||||
|
type Phase = "idle" | "running" | "done" | "error";
|
||||||
|
|
||||||
|
interface SingleOpRunnerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Optional pre-selected op id (e.g. carousel deep-link). */
|
||||||
|
initialOpId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_DOCS = [
|
||||||
|
"certificate-of-insurance.pdf",
|
||||||
|
"loss-run-2025.pdf",
|
||||||
|
"invoice-acme-corp-q1.pdf",
|
||||||
|
"prior-auth-cigna-12471.pdf",
|
||||||
|
"contract-acme-msa-v3.pdf",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface RunResult {
|
||||||
|
result: OpResultMap;
|
||||||
|
durationMs: number;
|
||||||
|
opId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SingleOpRunner({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
initialOpId,
|
||||||
|
}: SingleOpRunnerProps) {
|
||||||
|
const { setActiveView } = useView();
|
||||||
|
const opsState = useAsync<FeaturedOp[]>(() => fetchFeaturedOps(), []);
|
||||||
|
const { data: ops } = opsState;
|
||||||
|
const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } =
|
||||||
|
useSectionFlags(opsState);
|
||||||
|
|
||||||
|
const [selectedOpId, setSelectedOpId] = useState<string | null>(
|
||||||
|
initialOpId ?? null,
|
||||||
|
);
|
||||||
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
|
const [sample, setSample] = useState<string | null>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [runResult, setRunResult] = useState<RunResult | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Default selection once the catalogue loads.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedOpId && ops && ops.length > 0) {
|
||||||
|
setSelectedOpId(ops[0].id);
|
||||||
|
}
|
||||||
|
}, [ops, selectedOpId]);
|
||||||
|
|
||||||
|
const selectedOp = useMemo<FeaturedOp | null>(
|
||||||
|
() => ops?.find((o) => o.id === selectedOpId) ?? null,
|
||||||
|
[ops, selectedOpId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedOpId(initialOpId ?? ops?.[0]?.id ?? null);
|
||||||
|
setPhase("idle");
|
||||||
|
setSample(null);
|
||||||
|
setRunResult(null);
|
||||||
|
setErrorMsg(null);
|
||||||
|
}
|
||||||
|
// ops is intentionally not in deps — we don't want to reset state when
|
||||||
|
// the catalogue arrives while the modal is open.
|
||||||
|
}, [open, initialOpId]);
|
||||||
|
|
||||||
|
const pickSample = useCallback(() => {
|
||||||
|
if (!ops) return;
|
||||||
|
const idx = ops.findIndex((o) => o.id === selectedOpId);
|
||||||
|
const safeIdx = idx < 0 ? 0 : idx;
|
||||||
|
setSample(SAMPLE_DOCS[safeIdx % SAMPLE_DOCS.length]);
|
||||||
|
}, [ops, selectedOpId]);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!selectedOp) return;
|
||||||
|
let activeSample = sample;
|
||||||
|
if (!activeSample) {
|
||||||
|
pickSample();
|
||||||
|
// pickSample updates state; use the synchronously-derived value for the
|
||||||
|
// call so we don't race.
|
||||||
|
const idx = ops?.findIndex((o) => o.id === selectedOpId) ?? 0;
|
||||||
|
activeSample = SAMPLE_DOCS[idx % SAMPLE_DOCS.length];
|
||||||
|
}
|
||||||
|
setPhase("running");
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await runSingleOp(selectedOp.id, activeSample);
|
||||||
|
setRunResult({ ...res, opId: selectedOp.id });
|
||||||
|
setPhase("done");
|
||||||
|
} catch (err) {
|
||||||
|
setPhase("error");
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setPhase("idle");
|
||||||
|
setRunResult(null);
|
||||||
|
setErrorMsg(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPipelineWithOp() {
|
||||||
|
onClose();
|
||||||
|
setActiveView("pipelines");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}
|
||||||
|
function onDragLeave() {
|
||||||
|
setDragOver(false);
|
||||||
|
}
|
||||||
|
function onDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) setSample(file.name);
|
||||||
|
else pickSample();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width="xl"
|
||||||
|
title="Try a PDF operation"
|
||||||
|
subtitle="Drop a sample, pick an op, see what Stirling returns."
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<div className="portal-runner__footer-status">
|
||||||
|
{phase === "running" && selectedOp && (
|
||||||
|
<>
|
||||||
|
<span className="portal-runner__spinner" aria-hidden />
|
||||||
|
<code>POST {selectedOp.endpoint}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "done" && (
|
||||||
|
<StatusBadge tone="success" size="sm">
|
||||||
|
200 OK
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
|
{phase === "error" && (
|
||||||
|
<StatusBadge tone="danger" size="sm">
|
||||||
|
{errorMsg ?? "Failed"}
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{phase === "done" ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={reset}>
|
||||||
|
Run again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
onClick={buildPipelineWithOp}
|
||||||
|
trailingIcon={<span aria-hidden>→</span>}
|
||||||
|
>
|
||||||
|
Open the pipeline builder
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
onClick={run}
|
||||||
|
disabled={phase === "running" || !selectedOp}
|
||||||
|
trailingIcon={<span aria-hidden>→</span>}
|
||||||
|
>
|
||||||
|
{phase === "running" ? "Running…" : "Run operation"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="portal-runner__layout">
|
||||||
|
{/* Left column: file + op picker */}
|
||||||
|
<section className="portal-runner__left">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"portal-runner__drop" +
|
||||||
|
(dragOver ? " is-armed" : "") +
|
||||||
|
(sample ? " has-file" : "")
|
||||||
|
}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
<div className="portal-runner__drop-icon" aria-hidden>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="portal-runner__drop-text">
|
||||||
|
{sample ? (
|
||||||
|
<>
|
||||||
|
<strong>{sample}</strong>
|
||||||
|
<span>Drop again or pick another sample to replace.</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>Drop a PDF here</strong>
|
||||||
|
<span>or use a sample document.</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-runner__sample-btn"
|
||||||
|
onClick={pickSample}
|
||||||
|
>
|
||||||
|
{sample ? "Pick another sample" : "Use a sample"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="portal-runner__section-title">Featured ops</div>
|
||||||
|
<div className="portal-runner__ops">
|
||||||
|
{opsIsLoading &&
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`op-skel-${i}`}
|
||||||
|
className="portal-runner__op portal-runner__op--skeleton"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Skeleton width="60%" />
|
||||||
|
<Skeleton width="40%" height="0.625rem" />
|
||||||
|
<Skeleton width="80%" height="0.625rem" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{opsIsEmpty && (
|
||||||
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
title="No featured ops yet"
|
||||||
|
description="Once operations are published, they'll show up here."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ops?.map((op) => (
|
||||||
|
<button
|
||||||
|
key={op.id}
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
"portal-runner__op" +
|
||||||
|
(selectedOpId === op.id ? " is-selected" : "")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOpId(op.id);
|
||||||
|
if (phase === "done") {
|
||||||
|
setPhase("idle");
|
||||||
|
setRunResult(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-accent={op.accent}
|
||||||
|
>
|
||||||
|
<span className="portal-runner__op-label">{op.label}</span>
|
||||||
|
<span className="portal-runner__op-endpoint">
|
||||||
|
{op.endpoint}
|
||||||
|
</span>
|
||||||
|
<span className="portal-runner__op-blurb">{op.blurb}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Right column: state-driven result panel */}
|
||||||
|
<section className="portal-runner__right">
|
||||||
|
{phase === "idle" && selectedOp && (
|
||||||
|
<div className="portal-runner__hint">
|
||||||
|
<div className="portal-runner__hint-eyebrow">Ready</div>
|
||||||
|
<h3>
|
||||||
|
Run <code>{selectedOp.label}</code> on{" "}
|
||||||
|
<code>{sample ?? "a sample"}</code>
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{selectedOp.blurb}. Press <kbd>Run operation</kbd> to invoke{" "}
|
||||||
|
<code>POST {selectedOp.endpoint}</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{phase === "running" && selectedOp && (
|
||||||
|
<div className="portal-runner__running">
|
||||||
|
<div className="portal-runner__spinner-lg" aria-hidden />
|
||||||
|
<div className="portal-runner__running-text">
|
||||||
|
<div className="portal-runner__running-title">
|
||||||
|
Running {selectedOp.label}…
|
||||||
|
</div>
|
||||||
|
<code>POST {selectedOp.endpoint}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{phase === "done" && selectedOp && runResult && (
|
||||||
|
<div className="portal-runner__result">
|
||||||
|
<header className="portal-runner__result-head">
|
||||||
|
<StatusBadge tone="success" size="sm">
|
||||||
|
Completed
|
||||||
|
</StatusBadge>
|
||||||
|
<code>POST {selectedOp.endpoint}</code>
|
||||||
|
<span className="portal-runner__result-meta">
|
||||||
|
{runResult.durationMs} ms
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<pre className="portal-runner__result-code">
|
||||||
|
{JSON.stringify(runResult.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{phase === "error" && (
|
||||||
|
<div className="portal-runner__hint">
|
||||||
|
<div
|
||||||
|
className="portal-runner__hint-eyebrow"
|
||||||
|
style={{ color: "var(--color-red)" }}
|
||||||
|
>
|
||||||
|
Failed
|
||||||
|
</div>
|
||||||
|
<h3>The operation didn’t complete</h3>
|
||||||
|
<p>{errorMsg ?? "Unknown error"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
.portal-chart {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.125rem 1.25rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-section-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__value {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__delta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.1875rem 0.5rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__delta.is-up {
|
||||||
|
color: var(--color-green-dark);
|
||||||
|
background: var(--color-green-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__delta.is-down {
|
||||||
|
color: var(--color-red);
|
||||||
|
background: var(--color-red-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 15rem;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__gridline {
|
||||||
|
stroke: var(--color-border-light);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__axis-label {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 10.5px;
|
||||||
|
fill: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-blue);
|
||||||
|
stroke-width: 1.75;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__scrub {
|
||||||
|
stroke: var(--color-text-5);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 2 3;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__dot {
|
||||||
|
fill: var(--color-blue);
|
||||||
|
stroke: var(--color-surface);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__tooltip {
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
top: 4rem;
|
||||||
|
padding: 0.4375rem 0.625rem;
|
||||||
|
background: var(--color-tooltip-bg);
|
||||||
|
color: var(--color-tooltip-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__tooltip-date {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-chart__tooltip-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { UsageAreaChart } from "@portal/components/UsageAreaChart";
|
||||||
|
import { buildUsageSeries } from "@portal/mocks/home";
|
||||||
|
|
||||||
|
const meta: Meta<typeof UsageAreaChart> = {
|
||||||
|
title: "Portal/Home/UsageAreaChart",
|
||||||
|
component: UsageAreaChart,
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ maxWidth: "60rem" }}>
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof UsageAreaChart>;
|
||||||
|
|
||||||
|
const series = buildUsageSeries();
|
||||||
|
const total = series.reduce((sum, p) => sum + p.value, 0);
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { data: series, totalValue: total.toLocaleString(), deltaPct: 0.12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NegativeDelta: Story = {
|
||||||
|
args: { data: series, totalValue: total.toLocaleString(), deltaPct: -0.08 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoDelta: Story = {
|
||||||
|
args: { data: series },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Flat: Story = {
|
||||||
|
args: {
|
||||||
|
data: series.map((p) => ({ ...p, value: 1200 })),
|
||||||
|
totalValue: "36,000",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HighVolatility: Story = {
|
||||||
|
args: {
|
||||||
|
data: series.map((p, i) => ({
|
||||||
|
...p,
|
||||||
|
value: Math.round(p.value * (1 + Math.sin(i) * 0.6)),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { data: [], totalValue: "—" },
|
||||||
|
};
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
import type { UsagePoint } from "@portal/api/home";
|
||||||
|
import "@portal/components/UsageAreaChart.css";
|
||||||
|
|
||||||
|
interface UsageAreaChartProps {
|
||||||
|
data: UsagePoint[];
|
||||||
|
/** Headline metric shown above the chart (e.g. total 30d, current value). */
|
||||||
|
totalLabel?: string;
|
||||||
|
totalValue?: string;
|
||||||
|
deltaPct?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const;
|
||||||
|
const VIEW = { width: 800, height: 240 } as const;
|
||||||
|
|
||||||
|
function formatTick(date: string): string {
|
||||||
|
return new Date(date).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
||||||
|
}
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageAreaChart({
|
||||||
|
data,
|
||||||
|
totalLabel = "Docs processed · last 30 days",
|
||||||
|
totalValue,
|
||||||
|
deltaPct,
|
||||||
|
}: UsageAreaChartProps) {
|
||||||
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
const { points, areaPath, linePath, yMax, yTicks, xTickIndices } =
|
||||||
|
useMemo(() => {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return {
|
||||||
|
points: [] as Array<{ x: number; y: number; raw: UsagePoint }>,
|
||||||
|
areaPath: "",
|
||||||
|
linePath: "",
|
||||||
|
yMax: 0,
|
||||||
|
yTicks: [] as number[],
|
||||||
|
xTickIndices: [] as number[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const max = Math.max(...data.map((d) => d.value));
|
||||||
|
// Round yMax up to a "nice" number for ticks. Floor at 500 so an
|
||||||
|
// all-zero series doesn't divide by zero (which would NaN every point).
|
||||||
|
const niceMax = Math.max(Math.ceil(max / 500) * 500, 500);
|
||||||
|
const yTicksCalc = [0, niceMax * 0.5, niceMax];
|
||||||
|
|
||||||
|
const innerW = VIEW.width - PADDING.left - PADDING.right;
|
||||||
|
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
||||||
|
const xStep = innerW / Math.max(data.length - 1, 1);
|
||||||
|
|
||||||
|
const pts = data.map((raw, i) => ({
|
||||||
|
x: PADDING.left + i * xStep,
|
||||||
|
y: PADDING.top + innerH - (raw.value / niceMax) * innerH,
|
||||||
|
raw,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const linePathStr = pts
|
||||||
|
.map(
|
||||||
|
(p, i) =>
|
||||||
|
`${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`,
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
const baseY = PADDING.top + innerH;
|
||||||
|
const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`;
|
||||||
|
|
||||||
|
// X ticks: 5 evenly spread.
|
||||||
|
const xIdx: number[] = [];
|
||||||
|
const tickCount = 5;
|
||||||
|
for (let t = 0; t < tickCount; t++) {
|
||||||
|
xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: pts,
|
||||||
|
areaPath: areaPathStr,
|
||||||
|
linePath: linePathStr,
|
||||||
|
yMax: niceMax,
|
||||||
|
yTicks: yTicksCalc,
|
||||||
|
xTickIndices: xIdx,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
function onMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
|
if (!svgRef.current || points.length === 0) return;
|
||||||
|
const rect = svgRef.current.getBoundingClientRect();
|
||||||
|
const xRatio = (e.clientX - rect.left) / rect.width;
|
||||||
|
const svgX = xRatio * VIEW.width;
|
||||||
|
// Find nearest point
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let nearestDist = Infinity;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const d = Math.abs(points[i].x - svgX);
|
||||||
|
if (d < nearestDist) {
|
||||||
|
nearestDist = d;
|
||||||
|
nearestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHoverIndex(nearestIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hovered = hoverIndex !== null ? points[hoverIndex] : null;
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent<SVGSVGElement>) {
|
||||||
|
if (points.length === 0) return;
|
||||||
|
if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHoverIndex((idx) =>
|
||||||
|
idx === null ? 0 : Math.min(points.length - 1, idx + 1),
|
||||||
|
);
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHoverIndex((idx) =>
|
||||||
|
idx === null ? points.length - 1 : Math.max(0, idx - 1),
|
||||||
|
);
|
||||||
|
} else if (e.key === "Home") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHoverIndex(0);
|
||||||
|
} else if (e.key === "End") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHoverIndex(points.length - 1);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setHoverIndex(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayTotal =
|
||||||
|
totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="portal-chart">
|
||||||
|
<header className="portal-chart__head">
|
||||||
|
<div>
|
||||||
|
<div className="portal-chart__label">{totalLabel}</div>
|
||||||
|
<div className="portal-chart__value">{displayTotal}</div>
|
||||||
|
</div>
|
||||||
|
{deltaPct !== undefined && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"portal-chart__delta " + (deltaPct >= 0 ? "is-up" : "is-down")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
|
||||||
|
{Math.abs(Math.round(deltaPct * 100))}% vs prior 30d
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
aria-label={totalLabel}
|
||||||
|
className="portal-chart__svg"
|
||||||
|
tabIndex={points.length > 0 ? 0 : -1}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseLeave={() => setHoverIndex(null)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={() => setHoverIndex(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor="var(--color-blue)"
|
||||||
|
stopOpacity="0.32"
|
||||||
|
/>
|
||||||
|
<stop offset="100%" stopColor="var(--color-blue)" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Y gridlines + labels */}
|
||||||
|
{yTicks.map((tick) => {
|
||||||
|
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
||||||
|
const y = PADDING.top + innerH - (tick / yMax) * innerH;
|
||||||
|
return (
|
||||||
|
<g key={tick}>
|
||||||
|
<line
|
||||||
|
x1={PADDING.left}
|
||||||
|
x2={VIEW.width - PADDING.right}
|
||||||
|
y1={y}
|
||||||
|
y2={y}
|
||||||
|
className="portal-chart__gridline"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={PADDING.left - 6}
|
||||||
|
y={y + 4}
|
||||||
|
className="portal-chart__axis-label"
|
||||||
|
textAnchor="end"
|
||||||
|
>
|
||||||
|
{formatNumber(tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Area + line */}
|
||||||
|
<path d={areaPath} fill="url(#chart-fill)" />
|
||||||
|
<path d={linePath} className="portal-chart__line" />
|
||||||
|
|
||||||
|
{/* X ticks */}
|
||||||
|
{xTickIndices.map((idx) => {
|
||||||
|
const p = points[idx];
|
||||||
|
if (!p) return null;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={idx}
|
||||||
|
x={p.x}
|
||||||
|
y={VIEW.height - PADDING.bottom + 18}
|
||||||
|
className="portal-chart__axis-label"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{formatTick(p.raw.date)}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Hover scrub + dot + tooltip */}
|
||||||
|
{hovered && (
|
||||||
|
<>
|
||||||
|
<line
|
||||||
|
x1={hovered.x}
|
||||||
|
x2={hovered.x}
|
||||||
|
y1={PADDING.top}
|
||||||
|
y2={VIEW.height - PADDING.bottom}
|
||||||
|
className="portal-chart__scrub"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={hovered.x}
|
||||||
|
cy={hovered.y}
|
||||||
|
r={4}
|
||||||
|
className="portal-chart__dot"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{hovered && (
|
||||||
|
<div
|
||||||
|
className="portal-chart__tooltip"
|
||||||
|
style={{
|
||||||
|
left: `${(hovered.x / VIEW.width) * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="portal-chart__tooltip-date">
|
||||||
|
{new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="portal-chart__tooltip-value">
|
||||||
|
{hovered.raw.value.toLocaleString()} docs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
||||||
|
{hovered
|
||||||
|
? `${new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}: ${hovered.raw.value.toLocaleString()} docs`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
.portal-carousel {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--grad-banner);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-carousel__inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
animation: fadeInUp var(--motion-enter) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
.portal-carousel__inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-carousel__text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-carousel__eyebrow {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-blue);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-carousel__title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-carousel__sub {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-carousel__cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ornament container */
|
||||||
|
.portal-carousel__ornament {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Doc card ornament (slide 0) */
|
||||||
|
.portal-carousel__doc {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.portal-carousel__doc-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
.portal-carousel__doc-sub {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
.portal-carousel__doc-meta {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code ornament (slide 1) */
|
||||||
|
.portal-carousel__code {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--code-text);
|
||||||
|
border: 1px solid var(--code-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.portal-carousel__code-method {
|
||||||
|
color: var(--code-fn);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.portal-carousel__code-path {
|
||||||
|
color: var(--code-keyword);
|
||||||
|
}
|
||||||
|
.portal-carousel__code-key {
|
||||||
|
color: var(--code-property);
|
||||||
|
}
|
||||||
|
.portal-carousel__code-string {
|
||||||
|
color: var(--code-string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eval ornament (slide 2) */
|
||||||
|
.portal-carousel__eval {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.portal-carousel__eval-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.portal-carousel__eval-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
.portal-carousel__eval-score {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
.portal-carousel__eval-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
.portal-carousel__eval-bar {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.portal-carousel__eval-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--grad-green-btn);
|
||||||
|
}
|
||||||
|
.portal-carousel__eval-meta {
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decoration blobs */
|
||||||
|
.portal-carousel__decor {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.portal-carousel__blob {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(2.5rem);
|
||||||
|
}
|
||||||
|
.portal-carousel__blob--1 {
|
||||||
|
top: -3rem;
|
||||||
|
right: -2rem;
|
||||||
|
width: 16rem;
|
||||||
|
height: 16rem;
|
||||||
|
background: radial-gradient(circle, var(--color-purple), transparent 70%);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.portal-carousel__blob--2 {
|
||||||
|
bottom: -4rem;
|
||||||
|
right: 8rem;
|
||||||
|
width: 12rem;
|
||||||
|
height: 12rem;
|
||||||
|
background: radial-gradient(circle, var(--color-blue), transparent 70%);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination dots */
|
||||||
|
.portal-carousel__dots {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.875rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.portal-carousel__dot {
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-5);
|
||||||
|
opacity: 0.4;
|
||||||
|
transition:
|
||||||
|
opacity var(--motion-fast),
|
||||||
|
background var(--motion-fast),
|
||||||
|
width var(--motion-fast);
|
||||||
|
}
|
||||||
|
.portal-carousel__dot:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.portal-carousel__dot.is-active {
|
||||||
|
width: 1rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-blue);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
|
||||||
|
|
||||||
|
const meta: Meta<typeof WelcomeCarousel> = {
|
||||||
|
title: "Portal/Home/WelcomeCarousel",
|
||||||
|
component: WelcomeCarousel,
|
||||||
|
args: { onTryOp: () => console.log("try op") },
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
decorators: [
|
||||||
|
(S) => (
|
||||||
|
<div style={{ maxWidth: "64rem" }}>
|
||||||
|
<S />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof WelcomeCarousel>;
|
||||||
|
|
||||||
|
export const AutoRotating: Story = {};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { Button, StatusBadge } from "@shared/components";
|
||||||
|
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||||
|
import "@portal/components/WelcomeCarousel.css";
|
||||||
|
|
||||||
|
type SlideAction =
|
||||||
|
| { label: string; target: ViewId }
|
||||||
|
| { label: string; action: "try-op" };
|
||||||
|
|
||||||
|
interface Slide {
|
||||||
|
id: string;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
sub: string;
|
||||||
|
durationMs: number;
|
||||||
|
primary: SlideAction;
|
||||||
|
secondary: SlideAction;
|
||||||
|
ornament: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorOrnament() {
|
||||||
|
return (
|
||||||
|
<div className="portal-carousel__doc">
|
||||||
|
<StatusBadge tone="danger" size="sm" pulse>
|
||||||
|
Critical
|
||||||
|
</StatusBadge>
|
||||||
|
<div className="portal-carousel__doc-title">
|
||||||
|
Vulnerability Assessment Report
|
||||||
|
</div>
|
||||||
|
<div className="portal-carousel__doc-sub">CVE-2026-1847 · 12 pages</div>
|
||||||
|
<div className="portal-carousel__doc-meta">
|
||||||
|
<span>signed</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>OCR-clean</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>schema match 0.97</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformOrnament() {
|
||||||
|
return (
|
||||||
|
<pre className="portal-carousel__code">
|
||||||
|
<span className="portal-carousel__code-method">POST</span>{" "}
|
||||||
|
<span className="portal-carousel__code-path">/v1/secure</span>
|
||||||
|
{"\n"}
|
||||||
|
<span className="portal-carousel__code-key">Authorization</span>:{" "}
|
||||||
|
<span className="portal-carousel__code-string">Bearer sk_live_a3f8…</span>
|
||||||
|
{"\n"}
|
||||||
|
<span className="portal-carousel__code-key">file</span>:{" "}
|
||||||
|
<span className="portal-carousel__code-string">
|
||||||
|
federal_CUI_contract.pdf
|
||||||
|
</span>
|
||||||
|
{"\n"}
|
||||||
|
<span className="portal-carousel__code-key">pipeline</span>:{" "}
|
||||||
|
<span className="portal-carousel__code-string">cui-redact-v2</span>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentOrnament() {
|
||||||
|
return (
|
||||||
|
<div className="portal-carousel__eval">
|
||||||
|
<div className="portal-carousel__eval-head">
|
||||||
|
<span className="portal-carousel__eval-title">KYC Processor</span>
|
||||||
|
<span className="portal-carousel__eval-score">94%</span>
|
||||||
|
</div>
|
||||||
|
<div className="portal-carousel__eval-sub">
|
||||||
|
Eval pass rate · 28 test cases
|
||||||
|
</div>
|
||||||
|
<div className="portal-carousel__eval-bar">
|
||||||
|
<div
|
||||||
|
className="portal-carousel__eval-fill"
|
||||||
|
style={{ width: "94%" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="portal-carousel__eval-meta">
|
||||||
|
<span>26 passed</span>
|
||||||
|
<span>2 review</span>
|
||||||
|
<span>MCP · Claude</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLIDES: Slide[] = [
|
||||||
|
{
|
||||||
|
id: "editor",
|
||||||
|
eyebrow: "PDF Editor",
|
||||||
|
title: "The #1 PDF Editor on GitHub",
|
||||||
|
sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.",
|
||||||
|
durationMs: 12000,
|
||||||
|
primary: { label: "Install PDF Editor", target: "editor" },
|
||||||
|
secondary: { label: "Connect an instance", target: "editor" },
|
||||||
|
ornament: <EditorOrnament />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "platform",
|
||||||
|
eyebrow: "Platform",
|
||||||
|
title: "PDF Infrastructure for Developers",
|
||||||
|
sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.",
|
||||||
|
durationMs: 8000,
|
||||||
|
primary: { label: "Try a PDF operation", action: "try-op" },
|
||||||
|
secondary: { label: "Get an API key", target: "infrastructure" },
|
||||||
|
ornament: <PlatformOrnament />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agents",
|
||||||
|
eyebrow: "AI Agents",
|
||||||
|
title: "PDF Processor for AI Agents",
|
||||||
|
sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.",
|
||||||
|
durationMs: 8000,
|
||||||
|
primary: { label: "Try PDF Processor", target: "sources" },
|
||||||
|
secondary: { label: "View MCP docs", target: "docs" },
|
||||||
|
ornament: <AgentOrnament />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface WelcomeCarouselProps {
|
||||||
|
/** Called when a slide CTA wants to invoke the single-op runner. */
|
||||||
|
onTryOp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const { setActiveView } = useView();
|
||||||
|
|
||||||
|
function runAction(act: SlideAction) {
|
||||||
|
if ("action" in act && act.action === "try-op") {
|
||||||
|
onTryOp();
|
||||||
|
} else if ("target" in act) {
|
||||||
|
setActiveView(act.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused) return;
|
||||||
|
const duration = SLIDES[index].durationMs;
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
setIndex((i) => (i + 1) % SLIDES.length);
|
||||||
|
}, duration);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [index, paused]);
|
||||||
|
|
||||||
|
const slide = SLIDES[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="portal-carousel"
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
onFocus={() => setPaused(true)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// onBlur bubbles from children; only unpause when focus actually
|
||||||
|
// leaves the carousel, not when tabbing between the dot buttons.
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||||
|
setPaused(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Stirling product highlights"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
>
|
||||||
|
<div className="portal-carousel__inner" key={slide.id}>
|
||||||
|
<div className="portal-carousel__text">
|
||||||
|
<div className="portal-carousel__eyebrow">{slide.eyebrow}</div>
|
||||||
|
<h1 className="portal-carousel__title">{slide.title}</h1>
|
||||||
|
<p className="portal-carousel__sub">{slide.sub}</p>
|
||||||
|
<div className="portal-carousel__cta">
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
onClick={() => runAction(slide.primary)}
|
||||||
|
trailingIcon={<span aria-hidden>→</span>}
|
||||||
|
>
|
||||||
|
{slide.primary.label}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => runAction(slide.secondary)}
|
||||||
|
>
|
||||||
|
{slide.secondary.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="portal-carousel__ornament">{slide.ornament}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="portal-carousel__decor" aria-hidden>
|
||||||
|
<div className="portal-carousel__blob portal-carousel__blob--1" />
|
||||||
|
<div className="portal-carousel__blob portal-carousel__blob--2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="portal-carousel__dots"
|
||||||
|
role="group"
|
||||||
|
aria-label="Carousel pagination"
|
||||||
|
>
|
||||||
|
{SLIDES.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
aria-current={i === index ? "true" : undefined}
|
||||||
|
aria-label={`Slide ${i + 1}: ${s.title}`}
|
||||||
|
className={
|
||||||
|
"portal-carousel__dot" + (i === index ? " is-active" : "")
|
||||||
|
}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Svg({
|
||||||
|
size = 18,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: IconProps & { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<path d="m10 13-2 2 2 2" />
|
||||||
|
<path d="m14 17 2-2-2-2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M9 2v6" />
|
||||||
|
<path d="M15 2v6" />
|
||||||
|
<path d="M6 8h12v3a6 6 0 0 1-12 0z" />
|
||||||
|
<path d="M12 14v8" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelinesIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<rect x="3" y="3" width="6" height="6" rx="1" />
|
||||||
|
<rect x="15" y="3" width="6" height="6" rx="1" />
|
||||||
|
<rect x="9" y="15" width="6" height="6" rx="1" />
|
||||||
|
<path d="M6 9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9" />
|
||||||
|
<path d="M12 14v1" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="8" y1="13" x2="16" y2="13" />
|
||||||
|
<line x1="8" y1="17" x2="16" y2="17" />
|
||||||
|
<line x1="8" y1="9" x2="10" y2="9" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfrastructureIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<rect x="3" y="4" width="18" height="6" rx="1" />
|
||||||
|
<rect x="3" y="14" width="18" height="6" rx="1" />
|
||||||
|
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||||
|
<line x1="7" y1="17" x2="7.01" y2="17" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<line x1="6" y1="20" x2="6" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="18" y1="20" x2="18" y2="14" />
|
||||||
|
<line x1="3" y1="20" x2="21" y2="20" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocsIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M2 4.5A2.5 2.5 0 0 1 4.5 2H20v17H4.5A2.5 2.5 0 0 0 2 21.5z" />
|
||||||
|
<path d="M2 19.5A2.5 2.5 0 0 1 4.5 17H20" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SunIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<line x1="12" y1="2" x2="12" y2="4" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="22" />
|
||||||
|
<line x1="4.93" y1="4.93" x2="6.34" y2="6.34" />
|
||||||
|
<line x1="17.66" y1="17.66" x2="19.07" y2="19.07" />
|
||||||
|
<line x1="2" y1="12" x2="4" y2="12" />
|
||||||
|
<line x1="20" y1="12" x2="22" y2="12" />
|
||||||
|
<line x1="4.93" y1="19.07" x2="6.34" y2="17.66" />
|
||||||
|
<line x1="17.66" y1="6.34" x2="19.07" y2="4.93" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoonIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BellIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronDownIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SparklesIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" />
|
||||||
|
<path d="M19 4v3" />
|
||||||
|
<path d="M17.5 5.5h3" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloseIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...props}>
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13" />
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "stirling.portal.theme";
|
||||||
|
|
||||||
|
function readInitialTheme(): Theme {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(readInitialTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const value = useMemo<ThemeContextValue>(
|
||||||
|
() => ({
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
|
||||||
|
}),
|
||||||
|
[theme],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const v = useContext(ThemeContext);
|
||||||
|
if (!v) throw new Error("useTheme must be used inside <ThemeProvider>");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type Tier = "free" | "pro" | "enterprise";
|
||||||
|
|
||||||
|
export interface TierInfo {
|
||||||
|
label: string;
|
||||||
|
dotColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIER_INFO: Record<Tier, TierInfo> = {
|
||||||
|
free: { label: "Free Plan", dotColor: "var(--color-text-4)" },
|
||||||
|
pro: { label: "Pay-as-you-go", dotColor: "var(--color-blue)" },
|
||||||
|
enterprise: { label: "Enterprise Plan", dotColor: "var(--color-purple)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TierContextValue {
|
||||||
|
tier: Tier;
|
||||||
|
setTier: (tier: Tier) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TierContext = createContext<TierContextValue | null>(null);
|
||||||
|
|
||||||
|
export function TierProvider({
|
||||||
|
children,
|
||||||
|
initialTier = "pro",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
initialTier?: Tier;
|
||||||
|
}) {
|
||||||
|
const [tier, setTier] = useState<Tier>(initialTier);
|
||||||
|
const value = useMemo<TierContextValue>(() => ({ tier, setTier }), [tier]);
|
||||||
|
return <TierContext.Provider value={value}>{children}</TierContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTier(): TierContextValue {
|
||||||
|
const v = useContext(TierContext);
|
||||||
|
if (!v) throw new Error("useTier must be used inside <TierProvider>");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface UIContextValue {
|
||||||
|
searchOpen: boolean;
|
||||||
|
openSearch: () => void;
|
||||||
|
closeSearch: () => void;
|
||||||
|
toggleSearch: () => void;
|
||||||
|
|
||||||
|
assistantOpen: boolean;
|
||||||
|
openAssistant: () => void;
|
||||||
|
closeAssistant: () => void;
|
||||||
|
toggleAssistant: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIContext = createContext<UIContextValue | null>(null);
|
||||||
|
|
||||||
|
export function UIProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||||
|
|
||||||
|
const value = useMemo<UIContextValue>(
|
||||||
|
() => ({
|
||||||
|
searchOpen,
|
||||||
|
openSearch: () => setSearchOpen(true),
|
||||||
|
closeSearch: () => setSearchOpen(false),
|
||||||
|
toggleSearch: () => setSearchOpen((o) => !o),
|
||||||
|
|
||||||
|
assistantOpen,
|
||||||
|
openAssistant: () => setAssistantOpen(true),
|
||||||
|
closeAssistant: () => setAssistantOpen(false),
|
||||||
|
toggleAssistant: () => setAssistantOpen((o) => !o),
|
||||||
|
}),
|
||||||
|
[searchOpen, assistantOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <UIContext.Provider value={value}>{children}</UIContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUI(): UIContextValue {
|
||||||
|
const v = useContext(UIContext);
|
||||||
|
if (!v) throw new Error("useUI must be used inside <UIProvider>");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export type ViewId =
|
||||||
|
| "home"
|
||||||
|
| "editor"
|
||||||
|
| "sources"
|
||||||
|
| "pipelines"
|
||||||
|
| "documents"
|
||||||
|
| "infrastructure"
|
||||||
|
| "usage"
|
||||||
|
| "docs"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
|
export const VIEW_LABELS: Record<ViewId, string> = {
|
||||||
|
home: "Home",
|
||||||
|
editor: "Editor",
|
||||||
|
sources: "Sources",
|
||||||
|
pipelines: "Pipelines",
|
||||||
|
documents: "Documents",
|
||||||
|
infrastructure: "Infrastructure",
|
||||||
|
usage: "Usage & Billing",
|
||||||
|
docs: "Developer Docs",
|
||||||
|
settings: "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIEW_PATHS: Record<ViewId, string> = {
|
||||||
|
home: "/",
|
||||||
|
editor: "/editor",
|
||||||
|
sources: "/sources",
|
||||||
|
pipelines: "/pipelines",
|
||||||
|
documents: "/documents",
|
||||||
|
infrastructure: "/infrastructure",
|
||||||
|
usage: "/usage",
|
||||||
|
docs: "/docs",
|
||||||
|
settings: "/settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PATH_TO_VIEW: Record<string, ViewId> = Object.fromEntries(
|
||||||
|
(Object.entries(VIEW_PATHS) as Array<[ViewId, string]>).map(
|
||||||
|
([view, path]) => [path, view],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function deriveActiveView(pathname: string): ViewId {
|
||||||
|
// Exact match first; otherwise treat the first segment as the view.
|
||||||
|
if (PATH_TO_VIEW[pathname]) return PATH_TO_VIEW[pathname];
|
||||||
|
const firstSegment = "/" + pathname.split("/").filter(Boolean)[0];
|
||||||
|
return PATH_TO_VIEW[firstSegment] ?? "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewContextValue {
|
||||||
|
activeView: ViewId;
|
||||||
|
setActiveView: (view: ViewId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backwards-compatible facade over react-router. Components keep using
|
||||||
|
* useView()/setActiveView the way they did before; URLs are now real.
|
||||||
|
*
|
||||||
|
* No <ViewProvider> wrapper exists any more — the router is the provider.
|
||||||
|
* App.tsx wraps its children in <BrowserRouter>.
|
||||||
|
*/
|
||||||
|
export function useView(): ViewContextValue {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const activeView = useMemo(() => deriveActiveView(pathname), [pathname]);
|
||||||
|
const setActiveView = useCallback(
|
||||||
|
(view: ViewId) => navigate(VIEW_PATHS[view]),
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
return { activeView, setActiveView };
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export interface AsyncState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived render flags for a panel backed by {@link useAsync}.
|
||||||
|
*
|
||||||
|
* Every async-driven section in the portal renders the same three-state shape:
|
||||||
|
*
|
||||||
|
* - `isLoading` — first load, no data yet → render skeletons
|
||||||
|
* - `isEmpty` — fetch failed OR data is genuinely empty → render <EmptyState>
|
||||||
|
* - ready (neither flag set) — data is present → render the real UI
|
||||||
|
*
|
||||||
|
* Error and empty collapse into one branch on purpose: when there's no
|
||||||
|
* backend yet, fetch failures should surface as an empty page rather than
|
||||||
|
* an alarming error banner. The section header always renders regardless of
|
||||||
|
* which branch is active.
|
||||||
|
*
|
||||||
|
* The `ready` state is intentionally NOT exposed as a flag — callers should
|
||||||
|
* gate the ready branch on the actual data (`{events && events.length > 0
|
||||||
|
* && …}`) so TypeScript can narrow `events` from `T[] | null` to `T[]`.
|
||||||
|
*/
|
||||||
|
export interface SectionFlags {
|
||||||
|
isLoading: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveSectionFlags<T>(state: AsyncState<T>): SectionFlags {
|
||||||
|
const { data, loading, error } = state;
|
||||||
|
const dataIsEmpty =
|
||||||
|
data === null || (Array.isArray(data) && data.length === 0);
|
||||||
|
return {
|
||||||
|
isLoading: loading && data === null,
|
||||||
|
isEmpty: !loading && (error !== null || dataIsEmpty),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Memoised variant for use inside components. */
|
||||||
|
export function useSectionFlags<T>(state: AsyncState<T>): SectionFlags {
|
||||||
|
return useMemo(() => deriveSectionFlags(state), [state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight loading-state hook for async functions. Cancels the in-flight
|
||||||
|
* effect on unmount and on dependency change, so race conditions don't
|
||||||
|
* resolve into stale state.
|
||||||
|
*
|
||||||
|
* Intentionally minimal — when we want caching, retries, and revalidation
|
||||||
|
* we'll move this to TanStack Query or similar. For mocked data with sub-
|
||||||
|
* second latency, this is enough.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading } = useAsync(() => fetchDeployedPipelines(), []);
|
||||||
|
* if (loading) return <Spinner />;
|
||||||
|
* if (!data) return null;
|
||||||
|
*/
|
||||||
|
export function useAsync<T>(
|
||||||
|
fn: (signal: AbortSignal) => Promise<T>,
|
||||||
|
deps: ReadonlyArray<unknown>,
|
||||||
|
): AsyncState<T> {
|
||||||
|
const [state, setState] = useState<AsyncState<T>>({
|
||||||
|
data: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||||
|
|
||||||
|
fn(controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({ data, loading: false, error: null });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (cancelled || controller.signal.aborted) return;
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
setState({ data: null, loading: false, error: err });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
// Callers own the dependency array — `fn` is intentionally not listed
|
||||||
|
// here so the effect only re-runs when the caller asks it to.
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/** Mock assistant: routing rules + canned replies + suggested prompts. */
|
||||||
|
|
||||||
|
export interface AssistantRoute {
|
||||||
|
patterns: readonly RegExp[];
|
||||||
|
reply: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ASSISTANT_ROUTES: AssistantRoute[] = [
|
||||||
|
{
|
||||||
|
patterns: [/extract/i, /schema/i],
|
||||||
|
reply:
|
||||||
|
"For extraction, point the doc at /v1/<endpoint> and the schema-aware op infers fields from your typed catalogue. Confidence-check gates anything under 0.85 by default — adjust the threshold in the composer's per-op config.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: [/mcp/i, /\bagent\b/i, /connect.*agent/i],
|
||||||
|
reply:
|
||||||
|
"MCP wiring lives under Sources → Add → Agent. You'll get an MCP URL of the shape mcp://stirling.com/agents/{id} plus scoped credentials. Scenarios drive the eval set the agent must pass before it can take real traffic.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: [/redact/i, /pii/i],
|
||||||
|
reply:
|
||||||
|
"The redact op runs PII enforcement across SSN / DOB / accounts / contacts / names / addresses by default. Toggle categories in the composer; auto-detected PII supports blackout, replace, and mask styles.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: [/deploy/i, /docker/i, /self.host/i],
|
||||||
|
reply:
|
||||||
|
"Self-host via the docker variant from /editor → Self-Hosted. Air-gapped tarballs ship on Enterprise. Helm charts cover us-east-1, eu-west-1, ap-southeast-1; multi-region requires the Enterprise plan.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: [/rate.*limit/i, /quota/i, /throttl/i],
|
||||||
|
reply:
|
||||||
|
"Free is 4.2 req/min. Pro scales to 342 req/min with burst tolerance. Enterprise lifts to 2.4k req/min with per-region pools. The 429 response carries Retry-After in seconds.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patterns: [/webhook/i, /callback/i],
|
||||||
|
reply:
|
||||||
|
"Outbound webhooks deliver via 3× exponential backoff. We sign every payload with HMAC-SHA256 — verify via the X-Stirling-Signature header. Inbound webhooks support Bearer, HMAC, or mTLS auth.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ASSISTANT_DEFAULT_REPLY =
|
||||||
|
"I can help with extraction, MCP / agents, redaction, deployment, rate limits, and webhooks. Try one of the suggestions, or ask something specific.";
|
||||||
|
|
||||||
|
export const ASSISTANT_SUGGESTIONS: readonly string[] = [
|
||||||
|
"How do I wire an MCP agent?",
|
||||||
|
"Extract fields from a COI",
|
||||||
|
"Redact PII before storage",
|
||||||
|
"Self-host with Docker",
|
||||||
|
"What are the rate limits?",
|
||||||
|
"Build a pipeline from a sample",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Resolve an input to a canned reply by walking the route table. */
|
||||||
|
export function routeAssistantReply(input: string): string {
|
||||||
|
for (const route of ASSISTANT_ROUTES) {
|
||||||
|
for (const pattern of route.patterns) {
|
||||||
|
if (pattern.test(input)) return route.reply;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ASSISTANT_DEFAULT_REPLY;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { setupWorker } from "msw/browser";
|
||||||
|
import { handlers } from "@portal/mocks/handlers";
|
||||||
|
|
||||||
|
export const worker = setupWorker(...handlers);
|
||||||
|
|
||||||
|
let workerStarted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the MSW worker. Idempotent — calling repeatedly is safe.
|
||||||
|
*
|
||||||
|
* The toggle flips MSW by writing the preference to localStorage and
|
||||||
|
* reloading the page, so there's no need for a `stopMockWorker` counterpart:
|
||||||
|
* the next boot just decides whether to call this or not.
|
||||||
|
*/
|
||||||
|
export async function startMockWorker(): Promise<void> {
|
||||||
|
if (workerStarted) return;
|
||||||
|
await worker.start({
|
||||||
|
onUnhandledRequest: "bypass",
|
||||||
|
serviceWorker: { url: "/mockServiceWorker.js" },
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
workerStarted = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import {
|
||||||
|
ASSISTANT_SUGGESTIONS,
|
||||||
|
routeAssistantReply,
|
||||||
|
} from "@portal/mocks/assistant";
|
||||||
|
|
||||||
|
interface AssistantMessageBody {
|
||||||
|
input: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assistantHandlers = [
|
||||||
|
http.get("/v1/assistant/suggestions", async () => {
|
||||||
|
return HttpResponse.json(ASSISTANT_SUGGESTIONS);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post("/v1/assistant/messages", async ({ request }) => {
|
||||||
|
const body = (await request.json()) as AssistantMessageBody;
|
||||||
|
await delay(600 + Math.random() * 300);
|
||||||
|
return HttpResponse.json({ reply: routeAssistantReply(body.input) });
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { VERTICALS } from "@shared/data/endpoints";
|
||||||
|
|
||||||
|
export const endpointsHandlers = [
|
||||||
|
http.get("/v1/endpoints", async ({ request }) => {
|
||||||
|
await delay(120);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const verticalKey = url.searchParams.get("vertical");
|
||||||
|
if (verticalKey) {
|
||||||
|
const v = VERTICALS.find((x) => x.key === verticalKey);
|
||||||
|
return HttpResponse.json(v ? v.endpoints : []);
|
||||||
|
}
|
||||||
|
return HttpResponse.json(VERTICALS);
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import type { Tier } from "@portal/contexts/TierContext";
|
||||||
|
import {
|
||||||
|
buildUsageSeries,
|
||||||
|
buildUsageSeriesResponse,
|
||||||
|
enterpriseKpisFor,
|
||||||
|
FREE_KPIS,
|
||||||
|
FREE_ONBOARDING,
|
||||||
|
proKpisFor,
|
||||||
|
RECENT_ACTIVITY,
|
||||||
|
REGION_HEALTH,
|
||||||
|
type KpiEntry,
|
||||||
|
} from "@portal/mocks/home";
|
||||||
|
|
||||||
|
function kpisFor(tier: Tier): KpiEntry[] {
|
||||||
|
if (tier === "free") return FREE_KPIS;
|
||||||
|
const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0);
|
||||||
|
if (tier === "enterprise") return enterpriseKpisFor(docs30d);
|
||||||
|
return proKpisFor(docs30d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const homeHandlers = [
|
||||||
|
http.get("/v1/analytics/usage", async () => {
|
||||||
|
await delay(120);
|
||||||
|
return HttpResponse.json(buildUsageSeriesResponse());
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get("/v1/activity", async () => {
|
||||||
|
await delay(120);
|
||||||
|
return HttpResponse.json(RECENT_ACTIVITY);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get("/v1/home/kpis", async ({ request }) => {
|
||||||
|
await delay(120);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
|
||||||
|
return HttpResponse.json(kpisFor(tier));
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get("/v1/regions/health", async () => {
|
||||||
|
await delay(120);
|
||||||
|
return HttpResponse.json(REGION_HEALTH);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get("/v1/onboarding", async () => {
|
||||||
|
await delay(120);
|
||||||
|
return HttpResponse.json(FREE_ONBOARDING);
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
|
||||||
|
import { endpointsHandlers } from "@portal/mocks/handlers/endpoints";
|
||||||
|
import { homeHandlers } from "@portal/mocks/handlers/home";
|
||||||
|
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
|
||||||
|
import { opsHandlers } from "@portal/mocks/handlers/ops";
|
||||||
|
import { searchHandlers } from "@portal/mocks/handlers/search";
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
...homeHandlers,
|
||||||
|
...opsHandlers,
|
||||||
|
...notificationsHandlers,
|
||||||
|
...assistantHandlers,
|
||||||
|
...searchHandlers,
|
||||||
|
...endpointsHandlers,
|
||||||
|
];
|
||||||
|
|
||||||
|
export { resetNotificationsStore } from "@portal/mocks/handlers/notifications";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { NOTIFICATIONS, type Notification } from "@portal/mocks/notifications";
|
||||||
|
|
||||||
|
let store: Notification[] = [...NOTIFICATIONS];
|
||||||
|
|
||||||
|
export function resetNotificationsStore(seed?: Notification[]): void {
|
||||||
|
store = seed ? [...seed] : [...NOTIFICATIONS];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsHandlers = [
|
||||||
|
http.get("/v1/notifications", async () => {
|
||||||
|
await delay(120);
|
||||||
|
return HttpResponse.json(store);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post("/v1/notifications/mark-all-read", async () => {
|
||||||
|
await delay(120);
|
||||||
|
store = [];
|
||||||
|
return HttpResponse.json({ ok: true });
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
import { FEATURED_OPS, OP_RESULTS } from "@portal/mocks/ops";
|
||||||
|
|
||||||
|
export const opsHandlers = [
|
||||||
|
http.get("/v1/ops/featured", async () => {
|
||||||
|
await delay(120);
|
||||||
|
return HttpResponse.json(FEATURED_OPS);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post("/v1/ops/:opId/run", async ({ params }) => {
|
||||||
|
const start = performance.now();
|
||||||
|
await delay(800 + Math.random() * 200);
|
||||||
|
const opId = String(params.opId);
|
||||||
|
const result = OP_RESULTS[opId];
|
||||||
|
if (!result) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ error: `Unknown op: ${opId}` },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const enriched =
|
||||||
|
opId === "sign-output"
|
||||||
|
? { ...result, signed_at: new Date().toISOString() }
|
||||||
|
: result;
|
||||||
|
return HttpResponse.json({
|
||||||
|
result: enriched,
|
||||||
|
durationMs: Math.round(performance.now() - start),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { QUICK_ACTIONS } from "@portal/mocks/search";
|
||||||
|
|
||||||
|
export const searchHandlers = [
|
||||||
|
http.get("/v1/search/quick-actions", () => {
|
||||||
|
return HttpResponse.json(QUICK_ACTIONS);
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Home dashboard fixtures and the types api/home.ts shares with them.
|
||||||
|
* api/home.ts imports the types; the MSW handlers in mocks/handlers/ serve the
|
||||||
|
* fixture data over the intercepted httpJson() calls. Components never reach
|
||||||
|
* into this module directly.
|
||||||
|
*
|
||||||
|
* Once a real backend exists, the MSW handlers stop being registered and these
|
||||||
|
* fixtures can be deleted (or kept as test seeds).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UsagePoint {
|
||||||
|
/** ISO date (YYYY-MM-DD). */
|
||||||
|
date: string;
|
||||||
|
/** Docs processed on that day. */
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server response for the usage-series endpoint. Returning the prior window's
|
||||||
|
* total alongside the points lets the client derive the headline delta
|
||||||
|
* deterministically from real data rather than carrying a hardcoded figure.
|
||||||
|
*/
|
||||||
|
export interface UsageSeriesResponse {
|
||||||
|
points: UsagePoint[];
|
||||||
|
/** Equivalent docs total from the immediately prior 30-day window. */
|
||||||
|
priorTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds 30 daily points ending today. Deterministic per day. */
|
||||||
|
export function buildUsageSeries(): UsagePoint[] {
|
||||||
|
const points: UsagePoint[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(now.getDate() - i);
|
||||||
|
const day = d.getDay();
|
||||||
|
// Weekend dip + slow uptrend + bounded noise.
|
||||||
|
const weekend = day === 0 || day === 6 ? 0.55 : 1;
|
||||||
|
const trend = 1 + (30 - i) * 0.012;
|
||||||
|
const wobble = 1 + Math.sin(i * 1.3) * 0.18 + Math.cos(i * 0.6) * 0.09;
|
||||||
|
const base = 1450 * weekend * trend * wobble;
|
||||||
|
points.push({
|
||||||
|
date: d.toISOString().slice(0, 10),
|
||||||
|
value: Math.round(base),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the full usage payload with a plausible prior-window total. */
|
||||||
|
export function buildUsageSeriesResponse(): UsageSeriesResponse {
|
||||||
|
const points = buildUsageSeries();
|
||||||
|
const currentTotal = points.reduce((sum, p) => sum + p.value, 0);
|
||||||
|
// The current window's series simulates ~12% growth over the prior one.
|
||||||
|
const priorTotal = Math.round(currentTotal / 1.12);
|
||||||
|
return { points, priorTotal };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityKind =
|
||||||
|
| "pipeline-run"
|
||||||
|
| "deploy"
|
||||||
|
| "drift"
|
||||||
|
| "eval"
|
||||||
|
| "agent"
|
||||||
|
| "billing";
|
||||||
|
|
||||||
|
export interface ActivityEvent {
|
||||||
|
id: string;
|
||||||
|
kind: ActivityKind;
|
||||||
|
/** Short action verb shown at the top of the row. */
|
||||||
|
action: string;
|
||||||
|
/** Subject of the action (pipeline / endpoint / agent name). */
|
||||||
|
subject: string;
|
||||||
|
/** One-line detail line under the action. */
|
||||||
|
detail: string;
|
||||||
|
/** Relative-time string. */
|
||||||
|
time: string;
|
||||||
|
status: "success" | "warning" | "danger" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECENT_ACTIVITY: ActivityEvent[] = [
|
||||||
|
{
|
||||||
|
id: "act-1",
|
||||||
|
kind: "pipeline-run",
|
||||||
|
action: "Pipeline run completed",
|
||||||
|
subject: "COI Compliance",
|
||||||
|
detail: "1,287 docs · 0.4% errors · P95 412 ms",
|
||||||
|
time: "2m ago",
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-2",
|
||||||
|
kind: "deploy",
|
||||||
|
action: "Deployed",
|
||||||
|
subject: "Prior Auth v3.1.0",
|
||||||
|
detail: "Promoted to us-east-1, eu-west-1 · golden set 36/36",
|
||||||
|
time: "14m ago",
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-3",
|
||||||
|
kind: "drift",
|
||||||
|
action: "Schema drift detected",
|
||||||
|
subject: "Invoice v3",
|
||||||
|
detail: "12 docs in 1h didn't match — confidence ↓ 0.07",
|
||||||
|
time: "1h ago",
|
||||||
|
status: "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-4",
|
||||||
|
kind: "eval",
|
||||||
|
action: "Eval set passed",
|
||||||
|
subject: "KYC Processor",
|
||||||
|
detail: "94% (26/28) — 2 cases sent to review",
|
||||||
|
time: "3h ago",
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-5",
|
||||||
|
kind: "agent",
|
||||||
|
action: "Agent escalated",
|
||||||
|
subject: "Contract Router",
|
||||||
|
detail: "Low-confidence DPA routed to L2 reviewer pool",
|
||||||
|
time: "5h ago",
|
||||||
|
status: "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-6",
|
||||||
|
kind: "pipeline-run",
|
||||||
|
action: "Pipeline run failed",
|
||||||
|
subject: "Contract Review",
|
||||||
|
detail: "8% error rate · 14 docs sent to review queue",
|
||||||
|
time: "8h ago",
|
||||||
|
status: "danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-7",
|
||||||
|
kind: "billing",
|
||||||
|
action: "Approaching cap",
|
||||||
|
subject: "Monthly usage",
|
||||||
|
detail: "389k of 500k docs · auto-upgrade disabled",
|
||||||
|
time: "yesterday",
|
||||||
|
status: "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "act-8",
|
||||||
|
kind: "deploy",
|
||||||
|
action: "Rolled back",
|
||||||
|
subject: "COI Compliance v2.3.7",
|
||||||
|
detail: "Confidence regressions on Carrier supplement",
|
||||||
|
time: "2d ago",
|
||||||
|
status: "warning",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI labels are owned by the client (see `KPI_LABELS_BY_TIER` in Home.tsx)
|
||||||
|
* because they're product copy that should stay stable across loading / empty
|
||||||
|
* / ready states. The API only ships values + deltas.
|
||||||
|
*/
|
||||||
|
export interface KpiEntry {
|
||||||
|
value: string | number;
|
||||||
|
delta?: number;
|
||||||
|
description?: string;
|
||||||
|
deltaDirection?: "up" | "down" | "flat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FREE_KPIS: KpiEntry[] = [
|
||||||
|
{ value: "247 / 500" },
|
||||||
|
{ value: 189 },
|
||||||
|
{ value: 3 },
|
||||||
|
{ value: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function proKpisFor(docs30d: number): KpiEntry[] {
|
||||||
|
return [
|
||||||
|
{ value: docs30d.toLocaleString(), delta: 0.12 },
|
||||||
|
{ value: 12, delta: 0.16 },
|
||||||
|
{ value: 7, delta: 0.4 },
|
||||||
|
{ value: "94.6%", delta: 0.02 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enterpriseKpisFor(docs30d: number): KpiEntry[] {
|
||||||
|
return [
|
||||||
|
{ value: docs30d.toLocaleString(), delta: 0.18 },
|
||||||
|
{ value: "412 ms", delta: -0.05 },
|
||||||
|
{ value: "96.2%", delta: 0.01 },
|
||||||
|
{ value: "99.987%" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionHealth {
|
||||||
|
name: string;
|
||||||
|
status: "healthy" | "degraded" | "down";
|
||||||
|
meta: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGION_HEALTH: RegionHealth[] = [
|
||||||
|
{
|
||||||
|
name: "us-east-1",
|
||||||
|
status: "healthy",
|
||||||
|
meta: "2.1k/min · P95 287ms · 99.99% uptime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eu-west-1",
|
||||||
|
status: "healthy",
|
||||||
|
meta: "1.4k/min · P95 312ms · 99.98% uptime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ap-southeast-1",
|
||||||
|
status: "degraded",
|
||||||
|
meta: "412/min · P95 521ms · 99.92% uptime · degraded",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface OnboardingStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
blurb: string;
|
||||||
|
done: boolean;
|
||||||
|
/** What to render in the per-step CTA slot. */
|
||||||
|
cta?: { kind: "try-op" } | { kind: "navigate"; target: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FREE_ONBOARDING: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: "first-op",
|
||||||
|
title: "Run your first operation",
|
||||||
|
blurb: "Try extract, redact, or OCR on a sample document.",
|
||||||
|
done: true,
|
||||||
|
cta: { kind: "try-op" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "connect-source",
|
||||||
|
title: "Connect a source",
|
||||||
|
blurb: "Attach an S3 bucket, webhook, or email inbox.",
|
||||||
|
done: false,
|
||||||
|
cta: { kind: "navigate", target: "sources" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "build-pipeline",
|
||||||
|
title: "Build a pipeline",
|
||||||
|
blurb: "Compose ops into a repeatable workflow.",
|
||||||
|
done: false,
|
||||||
|
cta: { kind: "navigate", target: "pipelines" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wire-agent",
|
||||||
|
title: "Wire an agent",
|
||||||
|
blurb: "Expose Stirling via MCP or REST tool definitions.",
|
||||||
|
done: false,
|
||||||
|
cta: { kind: "navigate", target: "sources" },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/** Mock notifications for the header dropdown. */
|
||||||
|
|
||||||
|
export type NotificationCategory =
|
||||||
|
| "pipeline"
|
||||||
|
| "deploy"
|
||||||
|
| "billing"
|
||||||
|
| "audit"
|
||||||
|
| "agent"
|
||||||
|
| "doc";
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
category: NotificationCategory;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Relative-time string. */
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOTIFICATIONS: Notification[] = [
|
||||||
|
{
|
||||||
|
id: "n1",
|
||||||
|
category: "pipeline",
|
||||||
|
title: "COI Compliance — golden set passed",
|
||||||
|
description: "48/48 docs reproduced expected outputs after schema change",
|
||||||
|
time: "2m ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n2",
|
||||||
|
category: "deploy",
|
||||||
|
title: "Deploy succeeded — Prior Auth v2.4",
|
||||||
|
description: "Rolled to us-east-1 + eu-west-1. P99 142ms.",
|
||||||
|
time: "14m ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n3",
|
||||||
|
category: "billing",
|
||||||
|
title: "Approaching 80% of monthly cap",
|
||||||
|
description: "389k of 500k docs processed. Auto-upgrade not enabled.",
|
||||||
|
time: "1h ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n4",
|
||||||
|
category: "audit",
|
||||||
|
title: "Four-eyes elevation requested",
|
||||||
|
description: "Compliance Lead approval pending for redacted doc #28471",
|
||||||
|
time: "3h ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n5",
|
||||||
|
category: "agent",
|
||||||
|
title: "KYC Processor — eval drop",
|
||||||
|
description: "Pass rate fell to 87% on the latest 72h window",
|
||||||
|
time: "5h ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n6",
|
||||||
|
category: "doc",
|
||||||
|
title: "Schema drift detected — Invoice v3",
|
||||||
|
description: "12 docs in last 24h didn't match expected schema",
|
||||||
|
time: "yesterday",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Mock op catalogue + canned operation results for the single-op runner.
|
||||||
|
* Only api/ops.ts imports from this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type OpResultMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface FeaturedOp {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
endpoint: string;
|
||||||
|
accent: "blue" | "purple" | "green" | "amber" | "red";
|
||||||
|
/** Shown in the picker — short single line. */
|
||||||
|
blurb: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEATURED_OPS: FeaturedOp[] = [
|
||||||
|
{
|
||||||
|
id: "extract",
|
||||||
|
label: "Extract",
|
||||||
|
endpoint: "/v1/extract",
|
||||||
|
accent: "blue",
|
||||||
|
blurb: "Pull structured fields into a typed schema",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redact",
|
||||||
|
label: "Redact PII",
|
||||||
|
endpoint: "/v1/redact",
|
||||||
|
accent: "red",
|
||||||
|
blurb: "Mask SSN, DOB, addresses, accounts before storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "classify",
|
||||||
|
label: "Classify",
|
||||||
|
endpoint: "/v1/classify",
|
||||||
|
accent: "purple",
|
||||||
|
blurb: "Identify document type with a confidence score",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ocr",
|
||||||
|
label: "OCR",
|
||||||
|
endpoint: "/v1/ocr",
|
||||||
|
accent: "green",
|
||||||
|
blurb: "Text-recognize scanned or image pages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "validate",
|
||||||
|
label: "Schema validate",
|
||||||
|
endpoint: "/v1/validate",
|
||||||
|
accent: "blue",
|
||||||
|
blurb: "Check fields, rules, and coverage against the schema",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sign-output",
|
||||||
|
label: "Sign output",
|
||||||
|
endpoint: "/v1/sign",
|
||||||
|
accent: "green",
|
||||||
|
blurb: "Tamper-evident signature over artifact + run metadata",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "authenticity",
|
||||||
|
label: "Authenticity",
|
||||||
|
endpoint: "/v1/authenticity",
|
||||||
|
accent: "blue",
|
||||||
|
blurb: "Verify issuer signature, watermark, and metadata",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tamper-check",
|
||||||
|
label: "Tamper check",
|
||||||
|
endpoint: "/v1/tamper-check",
|
||||||
|
accent: "amber",
|
||||||
|
blurb: "Detect modifications since signing or last-known-good state",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "encrypt-rest",
|
||||||
|
label: "Encrypt at rest",
|
||||||
|
endpoint: "/v1/encrypt",
|
||||||
|
accent: "purple",
|
||||||
|
blurb: "AES-256 with Stirling-managed, BYOK, or HYOK keys",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "smart-redact",
|
||||||
|
label: "Smart redact",
|
||||||
|
endpoint: "/v1/smart-redact",
|
||||||
|
accent: "red",
|
||||||
|
blurb: "Schema-aware redaction with confidence gating",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Canned JSON for each featured op's runner "done" state. */
|
||||||
|
export const OP_RESULTS: Record<string, OpResultMap> = {
|
||||||
|
extract: {
|
||||||
|
schema: "coi.v2",
|
||||||
|
fields: {
|
||||||
|
carrier: "Travelers Casualty",
|
||||||
|
policy_number: "PHB-1108-2025",
|
||||||
|
gl_limit: 1_000_000,
|
||||||
|
umbrella_limit: 5_000_000,
|
||||||
|
effective: "2026-01-15",
|
||||||
|
expiry: "2027-01-15",
|
||||||
|
},
|
||||||
|
confidence_avg: 0.96,
|
||||||
|
},
|
||||||
|
redact: {
|
||||||
|
redacted_pages: 4,
|
||||||
|
pii_types: ["SSN", "DOB", "ADDRESS", "EMAIL"],
|
||||||
|
occurrences: 19,
|
||||||
|
redaction_style: "blackout",
|
||||||
|
audit_id: "rdct_01HVQ7K3ZA9YJ8C",
|
||||||
|
},
|
||||||
|
classify: {
|
||||||
|
schema: "invoice.v3",
|
||||||
|
confidence: 0.94,
|
||||||
|
alternatives: [
|
||||||
|
{ schema: "credit_memo.v1", confidence: 0.04 },
|
||||||
|
{ schema: "purchase_order.v2", confidence: 0.02 },
|
||||||
|
],
|
||||||
|
processing_ms: 287,
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
pages: 12,
|
||||||
|
characters_recognized: 28471,
|
||||||
|
confidence_avg: 0.987,
|
||||||
|
languages_detected: ["en"],
|
||||||
|
processing_ms: 1840,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
schema: "coi.v2",
|
||||||
|
passed: true,
|
||||||
|
checks_run: 14,
|
||||||
|
warnings: [
|
||||||
|
{ field: "additional_insured", message: "Optional field empty" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sign-output": {
|
||||||
|
algorithm: "Ed25519",
|
||||||
|
key_id: "kx-prod-2026",
|
||||||
|
manifest_hash:
|
||||||
|
"0xb3f0c1a9d54fa1c0b8fd4eebd7fa11b1b16c9a3e2d2cc6f1f5a2f0a87e1b7a04",
|
||||||
|
},
|
||||||
|
authenticity: {
|
||||||
|
verified: true,
|
||||||
|
issuer: "State of California DMV",
|
||||||
|
signed_at: "2025-11-04T17:22:00Z",
|
||||||
|
watermark_match: true,
|
||||||
|
},
|
||||||
|
"tamper-check": {
|
||||||
|
tampered: false,
|
||||||
|
hash_match: true,
|
||||||
|
modifications_detected: 0,
|
||||||
|
last_known_good: "2026-04-22T09:14:00Z",
|
||||||
|
},
|
||||||
|
"encrypt-rest": {
|
||||||
|
algorithm: "AES-256-GCM",
|
||||||
|
key_mode: "BYOK",
|
||||||
|
key_id: "arn:aws:kms:us-east-1:123:key/abc-…",
|
||||||
|
object_id: "obj_01HVQ7M9B2",
|
||||||
|
},
|
||||||
|
"smart-redact": {
|
||||||
|
schema: "coi.v2",
|
||||||
|
redacted_fields: ["named_insured", "dob"],
|
||||||
|
occurrences: 7,
|
||||||
|
confidence_gate: 0.85,
|
||||||
|
gated_by_confidence: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight preference helpers — pulled out of mocks/browser.ts so they
|
||||||
|
* don't drag MSW + every handler + every fixture into any chunk that just
|
||||||
|
* needs to *read* the user's choice. Loading the actual worker stays a
|
||||||
|
* dynamic import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "stirling.portal.mocks-enabled";
|
||||||
|
|
||||||
|
export function readMocksPreference(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === "true") return true;
|
||||||
|
if (stored === "false") return false;
|
||||||
|
return import.meta.env.DEV;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeMocksPreference(enabled: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, String(enabled));
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Mock quick-action catalogue for the ⌘K search palette. */
|
||||||
|
|
||||||
|
export interface QuickAction {
|
||||||
|
group: "Jump to" | "Create" | "Theme";
|
||||||
|
label: string;
|
||||||
|
/** Keyboard hint shown to the right. */
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QUICK_ACTIONS: QuickAction[] = [
|
||||||
|
{ group: "Jump to", label: "Home", hint: "G H" },
|
||||||
|
{ group: "Jump to", label: "Pipelines", hint: "G P" },
|
||||||
|
{ group: "Jump to", label: "Sources", hint: "G S" },
|
||||||
|
{ group: "Jump to", label: "Documents", hint: "G D" },
|
||||||
|
{ group: "Create", label: "New pipeline", hint: "N P" },
|
||||||
|
{ group: "Create", label: "New API key", hint: "N K" },
|
||||||
|
{ group: "Theme", label: "Toggle dark / light", hint: "T" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import {
|
||||||
|
Button as MantineButton,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Button as SuiButton } from "@shared/components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proof that Mantine components render with the SUI-bound theme — a Mantine
|
||||||
|
* filled button uses --color-blue, so it sits next to a SUI button as one
|
||||||
|
* system. This is the "escape hatch" pattern: reach for Mantine when a
|
||||||
|
* component isn't worth rebuilding in SUI, and it still looks on-brand.
|
||||||
|
*/
|
||||||
|
const meta: Meta = {
|
||||||
|
title: "Portal/Theme/Mantine Integration",
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj;
|
||||||
|
|
||||||
|
export const SideBySide: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Stack gap="md" style={{ maxWidth: 420 }}>
|
||||||
|
<Group>
|
||||||
|
<SuiButton>SUI Button</SuiButton>
|
||||||
|
<MantineButton color="blue">Mantine Button</MantineButton>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<MantineButton color="green">Mantine green</MantineButton>
|
||||||
|
<MantineButton color="red">Mantine red</MantineButton>
|
||||||
|
<MantineButton color="amber">Mantine amber</MantineButton>
|
||||||
|
<MantineButton color="purple">Mantine purple</MantineButton>
|
||||||
|
</Group>
|
||||||
|
<MantineButton variant="light" color="blue">
|
||||||
|
Light variant (uses --color-blue-light)
|
||||||
|
</MantineButton>
|
||||||
|
<TextInput
|
||||||
|
label="Mantine TextInput"
|
||||||
|
placeholder="Reach for Mantine form controls when SUI doesn't have one"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createTheme, type MantineColorsTuple } from "@mantine/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mantine theme for the portal, bound to the SUI design tokens in
|
||||||
|
* shared/tokens/tokens.css. This is what lets a Mantine component (e.g. a
|
||||||
|
* Combobox we don't want to rebuild in SUI) sit next to a SUI <Card> and look
|
||||||
|
* like one system.
|
||||||
|
*
|
||||||
|
* SUI exposes ~4 named shades per colour (`-light`, `-border`, base, `-dark`),
|
||||||
|
* but Mantine requires a 10-slot tuple. `tuple()` spreads the SUI shades across
|
||||||
|
* the 10 slots, with index 6 — Mantine's default `filled` shade — landing on
|
||||||
|
* the base brand colour. Because every slot is a `var(--color-*)` reference and
|
||||||
|
* those variables flip under `[data-theme="dark"]`, the Mantine theme follows
|
||||||
|
* SUI's light/dark switch automatically with no extra wiring.
|
||||||
|
*
|
||||||
|
* Caveat: every shade is a `var(--color-*)` reference, not a literal colour, so
|
||||||
|
* Mantine's JS colour maths can't read it. Don't enable `autoContrast` or rely
|
||||||
|
* on `theme.fn.lighten/darken/alpha` for these palettes — they need real hex
|
||||||
|
* values. CSS `color-mix()` variants work fine.
|
||||||
|
*/
|
||||||
|
function tuple(
|
||||||
|
light: string,
|
||||||
|
border: string,
|
||||||
|
base: string,
|
||||||
|
dark: string,
|
||||||
|
): MantineColorsTuple {
|
||||||
|
return [
|
||||||
|
`var(${light})`, // 0 subtle background
|
||||||
|
`var(${light})`, // 1
|
||||||
|
`var(${border})`, // 2
|
||||||
|
`var(${border})`, // 3
|
||||||
|
`var(${base})`, // 4
|
||||||
|
`var(${base})`, // 5
|
||||||
|
`var(${base})`, // 6 default filled shade
|
||||||
|
`var(${dark})`, // 7 hover
|
||||||
|
`var(${dark})`, // 8
|
||||||
|
`var(${dark})`, // 9
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const blue = tuple(
|
||||||
|
"--color-blue-light",
|
||||||
|
"--color-blue-border",
|
||||||
|
"--color-blue",
|
||||||
|
"--color-blue-dark",
|
||||||
|
);
|
||||||
|
const green = tuple(
|
||||||
|
"--color-green-light",
|
||||||
|
"--color-green-border",
|
||||||
|
"--color-green",
|
||||||
|
"--color-green-dark",
|
||||||
|
);
|
||||||
|
const red = tuple(
|
||||||
|
"--color-red-light",
|
||||||
|
"--color-red-border",
|
||||||
|
"--color-red",
|
||||||
|
"--color-red-dark",
|
||||||
|
);
|
||||||
|
const amber = tuple(
|
||||||
|
"--color-amber-light",
|
||||||
|
"--color-amber-border",
|
||||||
|
"--color-amber",
|
||||||
|
"--color-amber-dark",
|
||||||
|
);
|
||||||
|
const purple = tuple(
|
||||||
|
"--color-purple-light",
|
||||||
|
"--color-purple-border",
|
||||||
|
"--color-purple",
|
||||||
|
"--color-purple-dark",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const mantineTheme = createTheme({
|
||||||
|
primaryColor: "blue",
|
||||||
|
// Mantine uses index 6 of the tuple for filled components by default, which
|
||||||
|
// is where tuple() places the base brand shade.
|
||||||
|
primaryShade: 6,
|
||||||
|
colors: {
|
||||||
|
blue,
|
||||||
|
green,
|
||||||
|
red,
|
||||||
|
amber,
|
||||||
|
purple,
|
||||||
|
},
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontFamilyMonospace: "var(--font-mono)",
|
||||||
|
defaultRadius: "var(--radius-md)",
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.portal-documents {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 78rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
|
||||||
|
import "@portal/views/Documents.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full document-type catalogue — the exhaustive per-vertical endpoint list.
|
||||||
|
* Home only teases four use cases (see PopularUseCases); the complete,
|
||||||
|
* tab-filterable grid lives here on its own surface.
|
||||||
|
*/
|
||||||
|
export function Documents() {
|
||||||
|
return (
|
||||||
|
<div className="portal-documents">
|
||||||
|
<DocumentTypeGrid />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
.portal-home {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 78rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product card grid */
|
||||||
|
.portal-home__product-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 60rem) {
|
||||||
|
.portal-home__product-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__product-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__product-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__product-blurb {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metric strip */
|
||||||
|
.portal-home__metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
.portal-home__metrics {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-column dashboard row (activity + quick actions) */
|
||||||
|
.portal-home__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 60rem) {
|
||||||
|
.portal-home__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick actions card */
|
||||||
|
.portal-home__quick-head {
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast),
|
||||||
|
border-color var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-row:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-text strong {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-text span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-arrow {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__quick-row:hover .portal-home__quick-arrow {
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Free-tier onboarding card */
|
||||||
|
.portal-home__onboard-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-step.is-done {
|
||||||
|
background: var(--color-green-light);
|
||||||
|
border-color: color-mix(in srgb, var(--color-green) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-step.is-done .portal-home__onboard-mark {
|
||||||
|
background: var(--color-green);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
border-color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-text strong {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__onboard-text span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enterprise region strip */
|
||||||
|
.portal-home__regions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
.portal-home__regions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__region {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__region-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__region-dot {
|
||||||
|
width: 0.4375rem;
|
||||||
|
height: 0.4375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-home__region-meta {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user