Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd288caf8 | ||
|
|
a84b375f5d | ||
|
|
52358c5bf9 | ||
|
|
c1e68c27c5 | ||
|
|
d4edff9059 | ||
|
|
915c76fc10 | ||
|
|
35e0fdc18f | ||
|
|
f37a46fbb6 | ||
|
|
3fe6e664f1 | ||
|
|
3967c28c50 | ||
|
|
f39ff2d360 | ||
|
|
33bb7f18f0 | ||
|
|
cffbcb5d79 | ||
|
|
a2aa43d71c |
@@ -1,92 +0,0 @@
|
||||
name: Sync Portal Docs
|
||||
|
||||
# Regenerates the portal Developer Docs manifest from the Stirling docs repo and
|
||||
# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs
|
||||
# repo fires a `docs-updated` repository_dispatch.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Docs repo ref (branch or tag) to sync from"
|
||||
required: false
|
||||
default: "main"
|
||||
repository_dispatch:
|
||||
types: [docs-updated]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync docs manifest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Regenerate docs manifest
|
||||
working-directory: frontend
|
||||
env:
|
||||
DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }}
|
||||
GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
run: npm run docs:sync
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Sync portal docs from docs repo"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync-portal-docs
|
||||
base: main
|
||||
title: "Sync portal docs from docs repo"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
|
||||
|
||||
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
|
||||
from the Stirling docs repo via `npm run docs:sync`.
|
||||
labels: documentation,github-actions,frontend
|
||||
add-paths: frontend/editor/src/portal/generated/docsManifest.json
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
+18
-1
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)) {
|
||||
if (Files.exists(directory)
|
||||
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
@@ -275,6 +277,21 @@ public class TempFileCleanupService {
|
||||
return totalDeletedCount.get();
|
||||
}
|
||||
|
||||
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
|
||||
if (maxAgeMillis <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long lastModified = Files.getLastModifiedTime(directory).toMillis();
|
||||
return (currentTime - lastModified) > maxAgeMillis;
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the system temp directory path based on configuration or system property. */
|
||||
private Path getSystemTempPath() {
|
||||
String systemTempDir =
|
||||
|
||||
@@ -155,6 +155,7 @@ public class TempFileManager {
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
try {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
registry.unregisterDirectory(directory);
|
||||
log.debug("Deleted temp directory: {}", directory.toString());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
|
||||
|
||||
@@ -85,6 +85,18 @@ public class TempFileRegistry {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a temporary directory from the registry.
|
||||
*
|
||||
* @param directory The directory to unregister
|
||||
*/
|
||||
public void unregisterDirectory(Path directory) {
|
||||
if (directory != null) {
|
||||
tempDirectories.remove(directory);
|
||||
log.debug("Unregistered temp directory: {}", directory.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a third-party temporary file that requires special handling.
|
||||
*
|
||||
|
||||
+19
-1
@@ -176,11 +176,13 @@ class TempFileCleanupServiceMoreTest {
|
||||
class ScheduledCleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("deletes registered temp directories and reports counts")
|
||||
@DisplayName("deletes stale registered temp directories and reports counts")
|
||||
void deletesRegisteredDirectories() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Files.setLastModifiedTime(
|
||||
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
@@ -193,6 +195,22 @@ class TempFileCleanupServiceMoreTest {
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeps a fresh registered temp directory")
|
||||
void keepsFreshRegisteredDirectory() throws IOException {
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
|
||||
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
|
||||
Files.createFile(regDir.resolve("inside.txt"));
|
||||
Set<Path> dirs = new HashSet<>();
|
||||
dirs.add(regDir);
|
||||
when(registry.getTempDirectories()).thenReturn(dirs);
|
||||
lenient().when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
withIsolatedUserHome(cleanupService::scheduledCleanup);
|
||||
|
||||
assertThat(Files.exists(regDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips a registered directory that no longer exists")
|
||||
void skipsMissingRegisteredDirectory() {
|
||||
|
||||
@@ -12,8 +12,6 @@ editor/public/mockServiceWorker.js
|
||||
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
|
||||
editor/public/og-metadata.json
|
||||
editor/src/core/data/ogImageMap.json
|
||||
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
|
||||
editor/src/portal/generated/docsManifest.json
|
||||
editor/public/pdfjs*/
|
||||
editor/public/js/thirdParty/
|
||||
editor/public/css/cookieconsent.css
|
||||
|
||||
@@ -6603,10 +6603,6 @@ title = "No components available"
|
||||
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
|
||||
title = "Some components need a paid plan"
|
||||
|
||||
[portal.docs]
|
||||
browse = "Browse docs"
|
||||
viewSource = "View source on GitHub"
|
||||
|
||||
[portal.docs.authentication]
|
||||
codeCaption = "every request"
|
||||
eyebrow = "GETTING STARTED"
|
||||
@@ -6692,19 +6688,11 @@ title = "Official SDKs"
|
||||
beta = "Beta"
|
||||
deprecated = "Deprecated"
|
||||
|
||||
[portal.docs.search]
|
||||
empty = "No matching docs"
|
||||
placeholder = "Search docs"
|
||||
results = "{{count}} results"
|
||||
|
||||
[portal.docs.skills]
|
||||
eyebrow = "SKILLS"
|
||||
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
|
||||
title = "Agent skills"
|
||||
|
||||
[portal.docs.toc]
|
||||
title = "On this page"
|
||||
|
||||
[portal.docs.webhooks]
|
||||
codeCaption = "document.processed"
|
||||
eyebrow = "API REFERENCE"
|
||||
@@ -7258,7 +7246,7 @@ storage = "Storage"
|
||||
[portal.nav]
|
||||
agent-builder = "Agent Builder"
|
||||
components = "Components"
|
||||
docs = "Documentation"
|
||||
docs = "Developer Docs"
|
||||
documents = "Documents"
|
||||
editor = "Editor"
|
||||
home = "Home"
|
||||
|
||||
@@ -6530,10 +6530,6 @@ title = "No components available"
|
||||
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
|
||||
title = "Some components need a paid plan"
|
||||
|
||||
[portal.docs]
|
||||
browse = "Browse docs"
|
||||
viewSource = "View source on GitHub"
|
||||
|
||||
[portal.docs.authentication]
|
||||
codeCaption = "every request"
|
||||
eyebrow = "GETTING STARTED"
|
||||
@@ -6619,19 +6615,11 @@ title = "Official SDKs"
|
||||
beta = "Beta"
|
||||
deprecated = "Deprecated"
|
||||
|
||||
[portal.docs.search]
|
||||
empty = "No matching docs"
|
||||
placeholder = "Search docs"
|
||||
results = "{{count}} results"
|
||||
|
||||
[portal.docs.skills]
|
||||
eyebrow = "SKILLS"
|
||||
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
|
||||
title = "Agent skills"
|
||||
|
||||
[portal.docs.toc]
|
||||
title = "On this page"
|
||||
|
||||
[portal.docs.webhooks]
|
||||
codeCaption = "document.processed"
|
||||
eyebrow = "API REFERENCE"
|
||||
@@ -7296,7 +7284,7 @@ storage = "Storage"
|
||||
[portal.nav]
|
||||
agent-builder = "Agent Builder"
|
||||
components = "Components"
|
||||
docs = "Documentation"
|
||||
docs = "Developer Docs"
|
||||
documents = "Documents"
|
||||
editor = "Editor"
|
||||
home = "Home"
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Sync the portal Developer Docs from the Stirling docs repo.
|
||||
*
|
||||
* Fetches the docs repo tarball, extracts `docs/**` in-process (no external tar
|
||||
* binary, no per-file GitHub rate limits), shapes it with the pure transforms in
|
||||
* src/portal/docs/manifest/transform.ts, and writes the committed manifest that
|
||||
* the portal docs view renders. Re-run with `npm run docs:sync`.
|
||||
*
|
||||
* Env: DOCS_REPO, DOCS_REF, DOCS_ROOT override the defaults below.
|
||||
*/
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// tsx/node16 can't resolve the @portal alias here, so import by relative .ts path.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
buildManifest,
|
||||
type CategoryMap,
|
||||
type RawDoc,
|
||||
} from "../src/portal/docs/manifest/transform.ts";
|
||||
|
||||
const REPO = process.env.DOCS_REPO ?? "Stirling-Tools/Stirling-Tools.github.io";
|
||||
const REF = process.env.DOCS_REF ?? "main";
|
||||
const ROOT = process.env.DOCS_ROOT ?? "docs";
|
||||
const SITE = "https://docs.stirlingpdf.com";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = resolve(HERE, "../src/portal/generated/docsManifest.json");
|
||||
|
||||
/* ── Minimal tar reader (ustar + pax/GNU long names) ─────────────────────── */
|
||||
|
||||
interface TarEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
function readTar(buf: Buffer): TarEntry[] {
|
||||
const entries: TarEntry[] = [];
|
||||
let offset = 0;
|
||||
let longName: string | null = null;
|
||||
let paxPath: string | null = null;
|
||||
|
||||
const str = (start: number, len: number) => {
|
||||
const slice = buf.subarray(start, start + len);
|
||||
const end = slice.indexOf(0);
|
||||
return slice.toString("utf8", 0, end === -1 ? len : end);
|
||||
};
|
||||
|
||||
while (offset + 512 <= buf.length) {
|
||||
const header = buf.subarray(offset, offset + 512);
|
||||
// Two consecutive zero blocks mark the end of the archive.
|
||||
if (header.every((b) => b === 0)) break;
|
||||
|
||||
const name = str(offset, 100);
|
||||
const prefix = str(offset + 345, 155);
|
||||
const sizeStr = str(offset + 124, 12).trim();
|
||||
const size = parseInt(sizeStr, 8) || 0;
|
||||
const type = String.fromCharCode(header[156]);
|
||||
const dataStart = offset + 512;
|
||||
const data = buf.subarray(dataStart, dataStart + size);
|
||||
|
||||
let fullName = prefix ? `${prefix}/${name}` : name;
|
||||
if (longName) {
|
||||
fullName = longName;
|
||||
longName = null;
|
||||
}
|
||||
if (paxPath) {
|
||||
fullName = paxPath;
|
||||
paxPath = null;
|
||||
}
|
||||
|
||||
if (type === "L") {
|
||||
// GNU long name: the payload is the real name of the next entry.
|
||||
longName = data.toString("utf8").replace(/\0+$/, "");
|
||||
} else if (type === "x") {
|
||||
// pax extended header: pull a `path=` record for the next entry.
|
||||
const record = /(?:^|\n)\d+ path=([^\n]+)\n/.exec(data.toString("utf8"));
|
||||
if (record) paxPath = record[1];
|
||||
} else if (type === "0" || type === "\0" || type === "") {
|
||||
entries.push({ name: fullName, type, data: Buffer.from(data) });
|
||||
}
|
||||
|
||||
offset = dataStart + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/* ── Fetch + shape ───────────────────────────────────────────────────────── */
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const url = `https://api.github.com/repos/${REPO}/tarball/${REF}`;
|
||||
console.log(`Fetching ${REPO}@${REF} …`);
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "stirling-portal-docs-sync",
|
||||
...(process.env.GITHUB_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`GitHub tarball fetch failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const gz = Buffer.from(await res.arrayBuffer());
|
||||
const entries = readTar(gunzipSync(gz));
|
||||
|
||||
// Strip the "<repo>-<sha>/" wrapper dir and keep only files under docs root.
|
||||
const prefix = `${ROOT}/`;
|
||||
const rawDocs: RawDoc[] = [];
|
||||
const categories: CategoryMap = {};
|
||||
for (const entry of entries) {
|
||||
const rel = entry.name.replace(/^[^/]+\//, "");
|
||||
if (!rel.startsWith(prefix)) continue;
|
||||
const inner = rel.slice(prefix.length);
|
||||
if (!inner) continue;
|
||||
if (inner.endsWith("/_category_.json")) {
|
||||
const dir = inner.slice(0, -"/_category_.json".length);
|
||||
try {
|
||||
categories[dir] = JSON.parse(entry.data.toString("utf8"));
|
||||
} catch {
|
||||
console.warn(` skipping unparseable _category_.json in ${dir}`);
|
||||
}
|
||||
} else if (/\.mdx?$/i.test(inner)) {
|
||||
rawDocs.push({ relPath: inner, content: entry.data.toString("utf8") });
|
||||
}
|
||||
}
|
||||
|
||||
if (rawDocs.length === 0) {
|
||||
throw new Error(`No markdown found under ${ROOT}/ — wrong repo/ref/root?`);
|
||||
}
|
||||
|
||||
const manifest = buildManifest(rawDocs, categories, {
|
||||
repo: REPO,
|
||||
ref: REF,
|
||||
root: ROOT,
|
||||
siteBaseUrl: SITE,
|
||||
});
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||
|
||||
const items = manifest.nav.reduce((n, s) => n + s.items.length, 0);
|
||||
console.log(
|
||||
`Wrote ${manifest.nav.length} sections, ${items} docs → ${OUT.replace(/.*[/\\]frontend[/\\]/, "frontend/")}`,
|
||||
);
|
||||
for (const s of manifest.nav) {
|
||||
console.log(` ${s.icon} ${s.label} (${s.items.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -4,9 +4,7 @@
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
// sync-portal-docs.mts imports the shared transform by its .ts path (run via tsx).
|
||||
"allowImportingTsExtensions": true
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.mts"]
|
||||
}
|
||||
|
||||
@@ -270,14 +270,12 @@ export function FileSelectorPicker({
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const ct =
|
||||
res.headers?.["content-type"] ||
|
||||
const ct = (res.headers?.["content-type"] ||
|
||||
res.headers?.["Content-Type"] ||
|
||||
"";
|
||||
const disp =
|
||||
res.headers?.["content-disposition"] ||
|
||||
"") as string;
|
||||
const disp = (res.headers?.["content-disposition"] ||
|
||||
res.headers?.["Content-Disposition"] ||
|
||||
"";
|
||||
"") as string;
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || "shared-file",
|
||||
@@ -294,14 +292,12 @@ export function FileSelectorPicker({
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const ct =
|
||||
res.headers?.["content-type"] ||
|
||||
const ct = (res.headers?.["content-type"] ||
|
||||
res.headers?.["Content-Type"] ||
|
||||
"";
|
||||
const disp =
|
||||
res.headers?.["content-disposition"] ||
|
||||
"") as string;
|
||||
const disp = (res.headers?.["content-disposition"] ||
|
||||
res.headers?.["Content-Disposition"] ||
|
||||
"";
|
||||
"") as string;
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || stub.name,
|
||||
|
||||
@@ -1003,16 +1003,14 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const contentType =
|
||||
(response.headers &&
|
||||
(response.headers["content-type"] ||
|
||||
response.headers["Content-Type"])) ||
|
||||
"";
|
||||
const disposition =
|
||||
(response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"";
|
||||
const contentType = ((response.headers &&
|
||||
(response.headers["content-type"] ||
|
||||
response.headers["Content-Type"])) ||
|
||||
"") as string;
|
||||
const disposition = ((response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"") as string;
|
||||
const filename =
|
||||
parseContentDispositionFilename(disposition) ||
|
||||
file.name ||
|
||||
|
||||
@@ -184,16 +184,13 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const contentType =
|
||||
(response.headers &&
|
||||
(response.headers["content-type"] ||
|
||||
response.headers["Content-Type"])) ||
|
||||
"";
|
||||
const disposition =
|
||||
(response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"";
|
||||
const contentType = ((response.headers &&
|
||||
(response.headers["content-type"] || response.headers["Content-Type"])) ||
|
||||
"") as string;
|
||||
const disposition = ((response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"") as string;
|
||||
const filename =
|
||||
parseContentDispositionFilename(disposition) || "server-file";
|
||||
const blob = response.data as Blob;
|
||||
@@ -210,16 +207,13 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
skipAuthRedirect: true,
|
||||
} as any,
|
||||
);
|
||||
const contentType =
|
||||
(response.headers &&
|
||||
(response.headers["content-type"] ||
|
||||
response.headers["Content-Type"])) ||
|
||||
"";
|
||||
const disposition =
|
||||
(response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"";
|
||||
const contentType = ((response.headers &&
|
||||
(response.headers["content-type"] || response.headers["Content-Type"])) ||
|
||||
"") as string;
|
||||
const disposition = ((response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"") as string;
|
||||
const filename =
|
||||
parseContentDispositionFilename(disposition) || "shared-file";
|
||||
const blob = response.data as Blob;
|
||||
|
||||
@@ -166,7 +166,9 @@ class SignatureStorageService {
|
||||
|
||||
// Convert to data URL (base64) for both display and use
|
||||
const blob = new Blob([imageResponse.data], {
|
||||
type: imageResponse.headers["content-type"] || "image/png",
|
||||
type:
|
||||
(imageResponse.headers["content-type"] as string) ||
|
||||
"image/png",
|
||||
});
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
|
||||
@@ -270,29 +270,8 @@
|
||||
--grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
|
||||
}
|
||||
|
||||
/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark
|
||||
terminal palette in dark mode. (CodeBlock renders plain text, so only the
|
||||
base colours are load-bearing; the syntax slots are kept for future Shiki. */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--code-bg: #f6f8fa;
|
||||
--code-bg-alt: #eef1f4;
|
||||
--code-bg-header: #eaeef2;
|
||||
--code-text: #1f2328;
|
||||
--code-dim: #656d76;
|
||||
--code-muted: #8c959f;
|
||||
--code-keyword: #cf222e;
|
||||
--code-string: #0a3069;
|
||||
--code-number: #0550ae;
|
||||
--code-fn: #8250df;
|
||||
--code-type: #953800;
|
||||
--code-property: #0550ae;
|
||||
--code-comment: #6e7781;
|
||||
--code-border: #d0d7de;
|
||||
/* Window-chrome traffic-light dots in the code-block header. */
|
||||
--code-dot: #d0d7de;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
/* Always-dark code palette. Not theme-switched. */
|
||||
:root {
|
||||
--code-bg: #0f172a;
|
||||
--code-bg-alt: #1e293b;
|
||||
--code-bg-header: #1a2332;
|
||||
@@ -307,16 +286,12 @@
|
||||
--code-property: #93c5fd;
|
||||
--code-comment: #475569;
|
||||
--code-border: #1e293b;
|
||||
/* Window-chrome traffic-light dots in the code-block header. */
|
||||
--code-dot: #475569;
|
||||
}
|
||||
|
||||
/* Radii / typography / motion / spacing / z-index — theme-stable */
|
||||
:root {
|
||||
/* Home hero strip navy. Theme-stable by design — the hero keeps this deep
|
||||
navy in both light and dark (it's a branded surface, like the assistant
|
||||
header), so it's defined once here rather than in the light/dark blocks. */
|
||||
--color-hero-navy: #16213e;
|
||||
|
||||
--radius-xs: 0.1875rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
|
||||
@@ -61,14 +61,11 @@
|
||||
background: var(--button-bg, transparent) !important;
|
||||
}
|
||||
|
||||
/* Disabled buttons in dark read as a muted surface (not a dimmed accent that
|
||||
still looks clickable, and not an invisible transparent pill). Covers every
|
||||
variant so a disabled primary and a disabled secondary look alike. */
|
||||
[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]),
|
||||
[data-theme="dark"]
|
||||
.sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-5);
|
||||
border-color: transparent;
|
||||
opacity: 1;
|
||||
.sui-btn--primary.mantine-Button-root:disabled:not([data-loading]),
|
||||
[data-theme="dark"]
|
||||
.sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) {
|
||||
background: var(--button-bg);
|
||||
color: var(--button-color);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-code__copy:hover {
|
||||
/* Subtle tint that reads on both the light and dark code surfaces. */
|
||||
background: color-mix(in srgb, var(--code-text) 8%, transparent);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.sui-code__pre {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Home } from "@portal/views/Home";
|
||||
import { Users } from "@portal/views/Users";
|
||||
@@ -8,19 +7,14 @@ import { PipelineBuilder } from "@portal/views/PipelineBuilder";
|
||||
import { Sources } from "@portal/views/Sources";
|
||||
import { AgentBuilder } from "@portal/views/AgentBuilder";
|
||||
import { Policies } from "@portal/views/Policies";
|
||||
import { Components } from "@portal/views/Components";
|
||||
import { EditorAdmin } from "@portal/views/EditorAdmin";
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
import { Procurement } from "@portal/views/Procurement";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
|
||||
// Lazy so the generated docs manifest (bundled JSON) lands in its own chunk.
|
||||
const DeveloperDocs = lazy(() =>
|
||||
import("@portal/views/DeveloperDocs").then((m) => ({
|
||||
default: m.DeveloperDocs,
|
||||
})),
|
||||
);
|
||||
|
||||
// The portal mounts as a route-set under /processor/* in the editor app, so these
|
||||
// child routes are relative to that base: strip the leading slash from the
|
||||
// logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath
|
||||
@@ -48,6 +42,7 @@ export function ViewRouter() {
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.policies)} element={<Policies />} />
|
||||
<Route path={rel(VIEW_PATHS.documents)} element={<Documents />} />
|
||||
<Route path={rel(VIEW_PATHS.components)} element={<Components />} />
|
||||
<Route path={rel(VIEW_PATHS.editor)} element={<EditorAdmin />} />
|
||||
<Route
|
||||
path={rel(VIEW_PATHS.infrastructure)}
|
||||
@@ -55,14 +50,7 @@ export function ViewRouter() {
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
|
||||
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
|
||||
<Route
|
||||
path={rel(VIEW_PATHS.docs)}
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<DeveloperDocs />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
|
||||
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
|
||||
<Route
|
||||
path="account-link"
|
||||
|
||||
@@ -119,13 +119,14 @@ export interface EditorDeploymentResponse {
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TargetMeta {
|
||||
icon: string;
|
||||
tone: "neutral" | "blue" | "purple";
|
||||
}
|
||||
|
||||
export const TARGET_META: Record<TargetKind, TargetMeta> = {
|
||||
cloud: { tone: "blue" },
|
||||
docker: { tone: "neutral" },
|
||||
kubernetes: { tone: "purple" },
|
||||
cloud: { icon: "☁", tone: "blue" },
|
||||
docker: { icon: "▣", tone: "neutral" },
|
||||
kubernetes: { icon: "⎈", tone: "purple" },
|
||||
};
|
||||
|
||||
export const INSTANCE_STATUS_TONE: Record<
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/*
|
||||
* "Components" are embeddable React/Vue/Vanilla SDK widgets a developer drops
|
||||
* into their own product — a PDF Viewer, an E-Sign flow, an AI Review panel —
|
||||
* each metered per action (per render, per review, per signature). Every
|
||||
* component carries its npm package, maturity, supported frameworks, per-action
|
||||
* price, an install/usage snippet, and its key props.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Types */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ComponentMaturity = "ga" | "beta";
|
||||
|
||||
export type Framework = "React" | "Vue" | "Vanilla";
|
||||
|
||||
/** The action a component bills against — surfaces in the price unit label. */
|
||||
export type BillingUnit =
|
||||
| "render"
|
||||
| "review"
|
||||
| "approval"
|
||||
| "signature"
|
||||
| "check"
|
||||
| "event"
|
||||
| "session";
|
||||
|
||||
export interface ComponentProp {
|
||||
name: string;
|
||||
/** TypeScript-ish type expression, shown verbatim in the API table. */
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ComponentPricing {
|
||||
/** Price per billed action in USD. */
|
||||
pricePerAction: number;
|
||||
unit: BillingUnit;
|
||||
/** Free-tier monthly allowance before metering kicks in; 0 = none. */
|
||||
freeQuota: number;
|
||||
}
|
||||
|
||||
export interface SdkComponent {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Package suffix — full name is `@stirling/<package>`. */
|
||||
package: string;
|
||||
description: string;
|
||||
maturity: ComponentMaturity;
|
||||
frameworks: Framework[];
|
||||
pricing: ComponentPricing;
|
||||
/** Install command (npm). */
|
||||
install: string;
|
||||
/** Minimal usage snippet shown under the Code tab. */
|
||||
usage: string;
|
||||
props: ComponentProp[];
|
||||
/**
|
||||
* Embeds attributed to this component over the trailing 30 days — drives the
|
||||
* per-card usage line. Zero for never-embedded components.
|
||||
*/
|
||||
embeds30d: number;
|
||||
/**
|
||||
* Tier at which the component becomes available. Components above the active
|
||||
* tier render locked with an upgrade nudge. `pro` is the default floor.
|
||||
*/
|
||||
minTier: Tier;
|
||||
}
|
||||
|
||||
export interface ComponentsSummary {
|
||||
/** Count of GA (production-ready) components available to the tier. */
|
||||
gaCount: number;
|
||||
/** Count of Beta components available to the tier. */
|
||||
betaCount: number;
|
||||
/** Total embeds across all components this month. */
|
||||
embedsThisMonth: number;
|
||||
/** Month-to-date spend attributed to component actions, in USD. */
|
||||
spendThisMonth: number;
|
||||
}
|
||||
|
||||
export interface ComponentsResponse {
|
||||
summary: ComponentsSummary;
|
||||
components: SdkComponent[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (client-side product copy, not data) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface MaturityMeta {
|
||||
label: string;
|
||||
tone: "success" | "info";
|
||||
}
|
||||
|
||||
/** `label` values are i18n keys — render with t(). */
|
||||
export const MATURITY_META: Record<ComponentMaturity, MaturityMeta> = {
|
||||
ga: { label: "portal.components.maturity.ga", tone: "success" },
|
||||
beta: { label: "portal.components.maturity.beta", tone: "info" },
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const BILLING_UNIT_LABEL: Record<BillingUnit, string> = {
|
||||
render: "portal.components.billingUnit.render",
|
||||
review: "portal.components.billingUnit.review",
|
||||
approval: "portal.components.billingUnit.approval",
|
||||
signature: "portal.components.billingUnit.signature",
|
||||
check: "portal.components.billingUnit.check",
|
||||
event: "portal.components.billingUnit.event",
|
||||
session: "portal.components.billingUnit.session",
|
||||
};
|
||||
|
||||
/** Format a price as the per-action string shown on cards, e.g. "$0.04 / review". */
|
||||
export function formatPrice(
|
||||
pricing: ComponentPricing,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return `$${pricing.pricePerAction.toFixed(2)} / ${t(BILLING_UNIT_LABEL[pricing.unit])}`;
|
||||
}
|
||||
|
||||
const TIER_RANK: Record<Tier, number> = { free: 0, pro: 1, enterprise: 2 };
|
||||
|
||||
/** Whether a component is usable at the given tier (vs locked/upgrade). */
|
||||
export function isUnlocked(component: SdkComponent, tier: Tier): boolean {
|
||||
return TIER_RANK[tier] >= TIER_RANK[component.minTier];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
|
||||
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
|
||||
return apiClient.local.json<ComponentsResponse>(
|
||||
`/v1/components?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-hero-navy);
|
||||
background: #16213e;
|
||||
}
|
||||
|
||||
.portal-editor-hero__logo {
|
||||
@@ -123,7 +123,7 @@
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: var(--color-hero-navy);
|
||||
color: #16213e;
|
||||
}
|
||||
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type CatalogueEntry,
|
||||
type PoliciesResponse,
|
||||
} from "@portal/api/policies";
|
||||
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/components/PolicySummary.css";
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,9 @@ export function PolicySummary() {
|
||||
header: t("portal.policySummary.column.policy"),
|
||||
render: ({ entry }) => (
|
||||
<div className="portal-policysum__cat">
|
||||
<PolicyCategoryBadge category={entry.category} />
|
||||
<span className="portal-policysum__icon" aria-hidden>
|
||||
{policyIcon(entry.category.icon)}
|
||||
</span>
|
||||
<div className="portal-policysum__cat-text">
|
||||
<strong>{t(entry.category.label)}</strong>
|
||||
<span>{t(entry.category.desc)}</span>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: var(--color-hero-navy);
|
||||
background: #16213e;
|
||||
}
|
||||
|
||||
.portal-welcome__brand {
|
||||
@@ -90,7 +90,7 @@
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn {
|
||||
background: #ffffff;
|
||||
border-color: #ffffff;
|
||||
color: var(--color-hero-navy);
|
||||
color: #16213e;
|
||||
}
|
||||
.portal-welcome__header .portal-welcome__cta.sui-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
|
||||
@@ -35,11 +35,7 @@ export function EnterpriseUpsell({ bare = false }: Props) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setActiveView("procurement")}
|
||||
>
|
||||
<Button size="sm" onClick={() => setActiveView("procurement")}>
|
||||
{t("portal.billing.enterpriseUpsell.cta", "Explore Enterprise")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
/* Free grant still offsetting spend — reads as a positive credit note, pulled up
|
||||
under the "PDFs processed" line. */
|
||||
.portal-billing__free-remaining {
|
||||
color: var(--color-green);
|
||||
color: var(--color-green-600, var(--color-green));
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
}
|
||||
|
||||
.portal-billing__error {
|
||||
color: var(--color-red, var(--color-red-dark));
|
||||
color: var(--color-red, #b91c1c);
|
||||
font-size: 0.875rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
@@ -228,7 +228,11 @@
|
||||
|
||||
.portal-billing__meter-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-blue), var(--color-purple));
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-blue, #0a8bff),
|
||||
var(--color-purple, #8b5cf6)
|
||||
);
|
||||
border-radius: 999px;
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
@@ -304,28 +308,28 @@
|
||||
border-radius: 999px;
|
||||
}
|
||||
.payg-status[data-state="FULL"] {
|
||||
background: var(--color-green-light);
|
||||
color: var(--color-green-dark);
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.payg-status[data-state="FULL"] .payg-status__dot {
|
||||
background: var(--color-green);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-green) 18%, transparent);
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
.payg-status[data-state="WARNED"] {
|
||||
background: var(--color-amber-light);
|
||||
color: var(--color-amber-dark);
|
||||
background: #fef3c7;
|
||||
color: #a16207;
|
||||
}
|
||||
.payg-status[data-state="WARNED"] .payg-status__dot {
|
||||
background: var(--color-amber);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-amber) 20%, transparent);
|
||||
background: #eab308;
|
||||
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] {
|
||||
background: var(--color-red-light);
|
||||
color: var(--color-red-dark);
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] .payg-status__dot {
|
||||
background: var(--color-red);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-red) 20%, transparent);
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Segmented usage bar */
|
||||
@@ -343,13 +347,13 @@
|
||||
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.payg-bar__fill[data-state="FULL"] {
|
||||
background: var(--color-blue);
|
||||
background: linear-gradient(90deg, #0a8bff, #38bdf8);
|
||||
}
|
||||
.payg-bar__fill[data-state="WARNED"] {
|
||||
background: var(--color-amber);
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
}
|
||||
.payg-bar__fill[data-state="DEGRADED"] {
|
||||
background: var(--color-red);
|
||||
background: linear-gradient(90deg, #dc2626, #f87171);
|
||||
}
|
||||
|
||||
/* ── Plan card (free state) ─────────────────────────────────────────────── */
|
||||
@@ -390,7 +394,7 @@
|
||||
|
||||
.portal-billing__plan-features li::before {
|
||||
content: "✓";
|
||||
color: var(--color-green);
|
||||
color: var(--color-green, #10b981);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-weight: 600;
|
||||
@@ -453,15 +457,15 @@
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-fill--blue {
|
||||
background: var(--color-blue);
|
||||
background: #0a8bff;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-fill--purple {
|
||||
background: var(--color-purple);
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.portal-billing__breakdown-fill--teal {
|
||||
background: var(--color-cat-extraction);
|
||||
background: #06b6d4;
|
||||
}
|
||||
|
||||
/* ── Cap control ────────────────────────────────────────────────────────── */
|
||||
@@ -566,10 +570,10 @@
|
||||
Same class names + structure so the visual is identical to the SaaS Plan
|
||||
page; when the shared-component move lands, these dedupe to one stylesheet. */
|
||||
.scc {
|
||||
--scc-accent: var(--color-blue);
|
||||
--scc-accent-text: var(--color-blue);
|
||||
--scc-accent-soft: color-mix(in srgb, var(--color-blue) 12%, transparent);
|
||||
--scc-accent-border: color-mix(in srgb, var(--color-blue) 25%, transparent);
|
||||
--scc-accent: #0a8bff;
|
||||
--scc-accent-text: #0a8bff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.12);
|
||||
--scc-accent-border: rgba(10, 139, 255, 0.25);
|
||||
--scc-chip-bg: var(--color-bg-muted, #f8fafc);
|
||||
--scc-chip-border: var(--color-border, #e2e8f0);
|
||||
--scc-text-primary: var(--color-text-1, #0f172a);
|
||||
@@ -581,11 +585,11 @@
|
||||
gap: 14px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
[data-theme="dark"] .scc {
|
||||
--scc-accent-text: var(--color-blue);
|
||||
--scc-accent-soft: color-mix(in srgb, var(--color-blue) 16%, transparent);
|
||||
--scc-chip-bg: var(--color-bg-muted);
|
||||
--scc-chip-border: var(--color-border);
|
||||
[data-mantine-color-scheme="dark"] .scc {
|
||||
--scc-accent-text: #66b8ff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--scc-chip-bg: #272d35;
|
||||
--scc-chip-border: #3d444e;
|
||||
}
|
||||
|
||||
.scc-row {
|
||||
@@ -732,9 +736,9 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.portal-billing__role-pill[data-leader="true"] {
|
||||
background: color-mix(in srgb, var(--color-blue) 12%, transparent);
|
||||
color: var(--color-blue);
|
||||
border: 1px solid color-mix(in srgb, var(--color-blue) 25%, transparent);
|
||||
background: rgba(10, 139, 255, 0.12);
|
||||
color: #0a8bff;
|
||||
border: 1px solid rgba(10, 139, 255, 0.25);
|
||||
}
|
||||
.portal-billing__role-pill[data-leader="false"] {
|
||||
background: var(--color-bg-muted);
|
||||
@@ -764,10 +768,10 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.portal-billing__planhead-lbl--free {
|
||||
color: var(--color-green);
|
||||
color: #10b981;
|
||||
}
|
||||
.portal-billing__planhead-lbl--meter {
|
||||
color: var(--color-blue);
|
||||
color: #0a8bff;
|
||||
}
|
||||
.portal-billing__planhead-title {
|
||||
margin: 0;
|
||||
@@ -835,7 +839,7 @@
|
||||
|
||||
.portal-billing__invoice-link:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-blue);
|
||||
border-color: var(--color-blue, #0a8bff);
|
||||
background: var(--color-bg-muted, #f1f5f9);
|
||||
}
|
||||
|
||||
@@ -949,12 +953,6 @@
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* Keep the CTA on one line at its natural width — in the narrow subscribed
|
||||
column it was shrinking and clipping its label. */
|
||||
.portal-billing__enterprise-head > .sui-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Bare variant — embeds in another card's column without its own surface. */
|
||||
.portal-billing__enterprise-bare {
|
||||
display: block;
|
||||
@@ -990,13 +988,13 @@
|
||||
height: 100%;
|
||||
}
|
||||
.portal-billing__segbar-seg--blue {
|
||||
background: var(--color-blue);
|
||||
background: #0a8bff;
|
||||
}
|
||||
.portal-billing__segbar-seg--purple {
|
||||
background: var(--color-purple);
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.portal-billing__segbar-seg--teal {
|
||||
background: var(--color-cat-extraction);
|
||||
background: #06b6d4;
|
||||
}
|
||||
.portal-billing__seglegend {
|
||||
display: flex;
|
||||
@@ -1018,13 +1016,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.portal-billing__dot--blue {
|
||||
background: var(--color-blue);
|
||||
background: #0a8bff;
|
||||
}
|
||||
.portal-billing__dot--purple {
|
||||
background: var(--color-purple);
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.portal-billing__dot--teal {
|
||||
background: var(--color-cat-extraction);
|
||||
background: #06b6d4;
|
||||
}
|
||||
.portal-billing__seglegend-label {
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentCard } from "@portal/components/catalogue/ComponentCard";
|
||||
|
||||
const PRO = componentsFor("pro");
|
||||
const GA = PRO.find((c) => c.maturity === "ga")!;
|
||||
const BETA = PRO.find((c) => c.maturity === "beta")!;
|
||||
|
||||
const meta: Meta<typeof ComponentCard> = {
|
||||
title: "Portal/Components/ComponentCard",
|
||||
component: ComponentCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: { component: GA, unlocked: true, onOpen: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentCard>;
|
||||
|
||||
export const GeneralAvailability: Story = {};
|
||||
|
||||
export const Beta: Story = {
|
||||
args: { component: BETA },
|
||||
};
|
||||
|
||||
/** Sits above the tier — dimmed with a lock affordance. */
|
||||
export const Locked: Story = {
|
||||
args: { component: BETA, unlocked: false },
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge } from "@app/ui";
|
||||
import {
|
||||
type SdkComponent,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
interface ComponentCardProps {
|
||||
component: SdkComponent;
|
||||
/** False when the component sits above the active tier — renders locked. */
|
||||
unlocked: boolean;
|
||||
onOpen: (component: SdkComponent) => void;
|
||||
}
|
||||
|
||||
/** A single catalogue tile: name, maturity, description, price and frameworks. */
|
||||
export function ComponentCard({
|
||||
component,
|
||||
unlocked,
|
||||
onOpen,
|
||||
}: ComponentCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const maturity = MATURITY_META[component.maturity];
|
||||
|
||||
return (
|
||||
<Card
|
||||
interactive
|
||||
padding="default"
|
||||
className={"portal-components__card" + (unlocked ? "" : " is-locked")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("portal.catalogue.card.openAriaLabel", {
|
||||
name: component.name,
|
||||
})}
|
||||
onClick={() => onOpen(component)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen(component);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="portal-components__card-head">
|
||||
<h3 className="portal-components__card-name">{component.name}</h3>
|
||||
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
|
||||
{t(maturity.label)}
|
||||
</StatusBadge>
|
||||
{!unlocked && (
|
||||
<span
|
||||
className="portal-components__lock"
|
||||
aria-label={t("portal.catalogue.card.lockedAriaLabel")}
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="portal-components__card-desc">{component.description}</p>
|
||||
|
||||
<div className="portal-components__card-meta">
|
||||
<span className="portal-components__price">
|
||||
{formatPrice(component.pricing, t)}
|
||||
</span>
|
||||
<span className="portal-components__pkg">
|
||||
@stirling/{component.package}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="portal-components__frameworks">
|
||||
{component.frameworks.map((fw) => (
|
||||
<Chip key={fw} size="sm">
|
||||
{fw}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentDetailModal } from "@portal/components/catalogue/ComponentDetailModal";
|
||||
|
||||
const PRO = componentsFor("pro");
|
||||
const VIEWER = PRO.find((c) => c.id === "viewer")!;
|
||||
const TOOLKIT = PRO.find((c) => c.id === "toolkit")!;
|
||||
|
||||
const meta: Meta<typeof ComponentDetailModal> = {
|
||||
title: "Portal/Components/ComponentDetailModal",
|
||||
component: ComponentDetailModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { component: VIEWER, unlocked: true, onClose: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentDetailModal>;
|
||||
|
||||
export const Unlocked: Story = {};
|
||||
|
||||
/** Enterprise-only component opened on a lower tier — shows the upgrade nudge. */
|
||||
export const Locked: Story = {
|
||||
args: { component: TOOLKIT, unlocked: false },
|
||||
};
|
||||
|
||||
export const Closed: Story = {
|
||||
args: { component: null },
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Chip,
|
||||
CodeBlock,
|
||||
Modal,
|
||||
StatTile,
|
||||
StatusBadge,
|
||||
Tabs,
|
||||
} from "@app/ui";
|
||||
import {
|
||||
type SdkComponent,
|
||||
MATURITY_META,
|
||||
formatPrice,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
type DetailTab = "overview" | "code" | "props" | "pricing";
|
||||
|
||||
const TAB_KEYS: DetailTab[] = ["overview", "code", "props", "pricing"];
|
||||
|
||||
interface ComponentDetailModalProps {
|
||||
component: SdkComponent | null;
|
||||
/** False when the open component sits above the active tier. */
|
||||
unlocked: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail overlay for a catalogue component: a live-preview placeholder plus
|
||||
* Overview / Code / Props / Pricing tabs. Locked components swap the install
|
||||
* CTA for an upgrade nudge.
|
||||
*/
|
||||
export function ComponentDetailModal({
|
||||
component,
|
||||
unlocked,
|
||||
onClose,
|
||||
}: ComponentDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<DetailTab>("overview");
|
||||
|
||||
// Reset to the first tab whenever a new component is opened.
|
||||
const open = component !== null;
|
||||
if (!component) {
|
||||
return (
|
||||
<Modal
|
||||
open={false}
|
||||
onClose={onClose}
|
||||
ariaLabel={t("portal.catalogue.detail.ariaLabel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = TAB_KEYS.map((key) => ({
|
||||
key,
|
||||
label: t(`portal.catalogue.detail.tabs.${key}`),
|
||||
}));
|
||||
|
||||
const maturity = MATURITY_META[component.maturity];
|
||||
const npm = `@stirling/${component.package}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
key={component.id}
|
||||
open={open}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setTab("overview");
|
||||
}}
|
||||
width="xl"
|
||||
title={
|
||||
<span className="portal-components__modal-title">
|
||||
{component.name}
|
||||
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
|
||||
{t(maturity.label)}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
}
|
||||
subtitle={npm}
|
||||
footer={
|
||||
unlocked ? (
|
||||
<div className="portal-components__modal-footer">
|
||||
<span className="portal-components__price">
|
||||
{formatPrice(component.pricing, t)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
// TODO(backend): open the package quickstart / provision a
|
||||
// publishable key scoped to this component.
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t("portal.catalogue.detail.addToProject")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
accent="premium"
|
||||
// TODO(backend): route to the upgrade / contact-sales flow.
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t("portal.catalogue.detail.upgradeToUnlock")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!unlocked && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t("portal.catalogue.detail.locked.title")}
|
||||
description={t("portal.catalogue.detail.locked.description", {
|
||||
name: component.name,
|
||||
tier: component.minTier,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live-preview sandbox — a styled placeholder until a real host mounts. */}
|
||||
<div className="portal-components__preview" aria-hidden>
|
||||
{/* TODO(backend)/host: mount the live <Sandbox> here, booting the
|
||||
component against a demo document and the dev's publishable key. */}
|
||||
<span className="portal-components__preview-badge">
|
||||
{t("portal.catalogue.detail.preview.badge")}
|
||||
</span>
|
||||
<span className="portal-components__preview-note">
|
||||
{t("portal.catalogue.detail.preview.note")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs<DetailTab>
|
||||
className="portal-components__tabs"
|
||||
items={tabs}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel={t("portal.catalogue.detail.tabsAriaLabel")}
|
||||
/>
|
||||
|
||||
<div className="portal-components__tab-body">
|
||||
{tab === "overview" && (
|
||||
<div className="portal-components__overview">
|
||||
<p className="portal-components__overview-desc">
|
||||
{component.description}
|
||||
</p>
|
||||
<div className="portal-components__frameworks">
|
||||
{component.frameworks.map((fw) => (
|
||||
<Chip key={fw} size="sm">
|
||||
{fw}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.maturity")}
|
||||
value={t(maturity.label)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.price")}
|
||||
value={formatPrice(component.pricing, t)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.freeQuota")}
|
||||
value={
|
||||
component.pricing.freeQuota > 0
|
||||
? t("portal.catalogue.detail.stats.freeQuotaValue", {
|
||||
amount: component.pricing.freeQuota.toLocaleString(),
|
||||
})
|
||||
: t("portal.catalogue.detail.stats.none")
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.embeds30d")}
|
||||
value={component.embeds30d.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "code" && (
|
||||
<div className="portal-components__code">
|
||||
<CodeBlock
|
||||
code={component.install}
|
||||
lang="bash"
|
||||
caption={t("portal.catalogue.detail.code.install")}
|
||||
/>
|
||||
<CodeBlock
|
||||
code={component.usage}
|
||||
lang="typescript"
|
||||
caption={t("portal.catalogue.detail.code.usage")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "props" && <ComponentPropsTable props={component.props} />}
|
||||
|
||||
{tab === "pricing" && (
|
||||
<div className="portal-components__pricing">
|
||||
<div className="portal-components__stat-grid">
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.perAction")}
|
||||
value={formatPrice(component.pricing, t)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.billedOn")}
|
||||
value={component.pricing.unit}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.catalogue.detail.stats.freeQuota")}
|
||||
value={
|
||||
component.pricing.freeQuota > 0
|
||||
? t("portal.catalogue.detail.stats.freeQuotaValue", {
|
||||
amount: component.pricing.freeQuota.toLocaleString(),
|
||||
})
|
||||
: t("portal.catalogue.detail.stats.none")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="portal-components__pricing-note">
|
||||
{t("portal.catalogue.detail.pricing.note", {
|
||||
unit: component.pricing.unit,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentGrid } from "@portal/components/catalogue/ComponentGrid";
|
||||
|
||||
const meta: Meta<typeof ComponentGrid> = {
|
||||
title: "Portal/Components/ComponentGrid",
|
||||
component: ComponentGrid,
|
||||
parameters: { layout: "padded" },
|
||||
args: { components: componentsFor("pro"), tier: "pro", onOpen: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentGrid>;
|
||||
|
||||
export const Pro: Story = {};
|
||||
|
||||
/** Free locks every paid component — the whole grid shows upgrade nudges. */
|
||||
export const Free: Story = {
|
||||
args: { components: componentsFor("free"), tier: "free" },
|
||||
};
|
||||
|
||||
/** Enterprise unlocks the enterprise-only Beta components. */
|
||||
export const Enterprise: Story = {
|
||||
args: { components: componentsFor("enterprise"), tier: "enterprise" },
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type SdkComponent, isUnlocked } from "@portal/api/sdkComponents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { ComponentCard } from "@portal/components/catalogue/ComponentCard";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
interface ComponentGridProps {
|
||||
components: SdkComponent[];
|
||||
tier: Tier;
|
||||
onOpen: (component: SdkComponent) => void;
|
||||
}
|
||||
|
||||
/** Responsive grid of catalogue cards; locks components above the tier. */
|
||||
export function ComponentGrid({
|
||||
components,
|
||||
tier,
|
||||
onOpen,
|
||||
}: ComponentGridProps) {
|
||||
return (
|
||||
<div className="portal-components__grid">
|
||||
{components.map((c) => (
|
||||
<ComponentCard
|
||||
key={c.id}
|
||||
component={c}
|
||||
unlocked={isUnlocked(c, tier)}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { componentsFor } from "@portal/mocks/sdkComponents";
|
||||
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
|
||||
|
||||
const VIEWER = componentsFor("pro").find((c) => c.id === "viewer")!;
|
||||
|
||||
const meta: Meta<typeof ComponentPropsTable> = {
|
||||
title: "Portal/Components/ComponentPropsTable",
|
||||
component: ComponentPropsTable,
|
||||
parameters: { layout: "padded" },
|
||||
args: { props: VIEWER.props },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentPropsTable>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, Table, type TableColumn } from "@app/ui";
|
||||
import type { ComponentProp } from "@portal/api/sdkComponents";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
interface ComponentPropsTableProps {
|
||||
props: ComponentProp[];
|
||||
}
|
||||
|
||||
/** Small Props/API reference shown under the detail modal's Props tab. */
|
||||
export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo<TableColumn<ComponentProp>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("portal.catalogue.props.columns.name"),
|
||||
render: (p) => (
|
||||
<span className="portal-components__prop-name">{p.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: t("portal.catalogue.props.columns.type"),
|
||||
render: (p) => (
|
||||
<code className="portal-components__prop-type">{p.type}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "required",
|
||||
header: t("portal.catalogue.props.columns.required"),
|
||||
render: (p) =>
|
||||
p.required ? (
|
||||
<Chip size="sm" accent="warning">
|
||||
{t("portal.catalogue.props.required")}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="portal-components__muted">
|
||||
{t("portal.catalogue.props.optional")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
header: t("portal.catalogue.props.columns.description"),
|
||||
render: (p) => (
|
||||
<span className="portal-components__prop-desc">{p.description}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<ComponentProp>
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(p) => p.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@app/ui";
|
||||
import type { ComponentsResponse } from "@portal/api/sdkComponents";
|
||||
|
||||
/**
|
||||
* Labels are product copy — they describe what each metric IS, not its value,
|
||||
* so the strip's structure stays stable across loading / empty / ready states.
|
||||
* Only values flow from the API.
|
||||
*/
|
||||
const KPI_LABEL_KEYS = [
|
||||
"portal.catalogue.summary.componentsGa",
|
||||
"portal.catalogue.summary.inBeta",
|
||||
"portal.catalogue.summary.embedsThisMonth",
|
||||
"portal.catalogue.summary.componentSpendMtd",
|
||||
] as const;
|
||||
|
||||
interface ComponentsSummaryStripProps {
|
||||
data: ComponentsResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ComponentsSummaryStrip({
|
||||
data,
|
||||
loading,
|
||||
}: ComponentsSummaryStripProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = loading ? undefined : data?.summary;
|
||||
const values: (string | number)[] = [
|
||||
s?.gaCount ?? "—",
|
||||
s?.betaCount ?? "—",
|
||||
s ? s.embedsThisMonth.toLocaleString() : "—",
|
||||
s ? `$${s.spendThisMonth.toLocaleString()}` : "—",
|
||||
];
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{KPI_LABEL_KEYS.map((labelKey, i) => (
|
||||
<MetricCard key={labelKey} label={t(labelKey)} value={values[i]} />
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Skeleton, StatusBadge } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DocsNavSection } from "@portal/api/docs";
|
||||
|
||||
/**
|
||||
* Left-hand documentation nav: a hierarchical accordion. Section ids encode their
|
||||
* path ("functionality/security" is a child of "functionality"), so sub-sections
|
||||
* nest under their parent. The root "Overview" section is static (always open, no
|
||||
* toggle); every other section collapses, and only the branch leading to the
|
||||
* active doc opens by default. (Search lives in DocsSearch above this.)
|
||||
*/
|
||||
|
||||
// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible.
|
||||
const STATIC_SECTION_ID = "overview";
|
||||
|
||||
interface NavNode {
|
||||
section: DocsNavSection;
|
||||
children: NavNode[];
|
||||
}
|
||||
|
||||
/** Split "a/b/c" → "a/b"; null for a top-level id. */
|
||||
function parentId(id: string): string | null {
|
||||
const i = id.lastIndexOf("/");
|
||||
return i === -1 ? null : id.slice(0, i);
|
||||
}
|
||||
|
||||
/** Build the section tree from the flat, pre-sorted section list. */
|
||||
function buildTree(sections: DocsNavSection[]): NavNode[] {
|
||||
const byId = new Map<string, NavNode>(
|
||||
sections.map((s) => [s.id, { section: s, children: [] }]),
|
||||
);
|
||||
const roots: NavNode[] = [];
|
||||
for (const node of byId.values()) {
|
||||
const pid = parentId(node.section.id);
|
||||
const parent = pid ? byId.get(pid) : undefined;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Left-hand documentation nav tree; each leaf selects an in-page section. */
|
||||
export function DocsNav({
|
||||
sections,
|
||||
active,
|
||||
@@ -50,125 +13,50 @@ export function DocsNav({
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Per-section manual open/close, overriding the "active branch only" default.
|
||||
const [toggled, setToggled] = useState<Record<string, boolean>>({});
|
||||
const activeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const activeSectionId = useMemo(
|
||||
() => sections.find((s) => s.items.some((i) => i.id === active))?.id,
|
||||
[sections, active],
|
||||
);
|
||||
|
||||
const tree = useMemo(() => buildTree(sections), [sections]);
|
||||
|
||||
// Keep the active item in view when navigating (e.g. via a cross-link).
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const isOpen = (id: string): boolean => {
|
||||
if (id === STATIC_SECTION_ID) return true;
|
||||
// Default-open the branch containing the active doc (self or ancestor).
|
||||
const onActivePath =
|
||||
!!activeSectionId &&
|
||||
(activeSectionId === id || activeSectionId.startsWith(id + "/"));
|
||||
return toggled[id] ?? onActivePath;
|
||||
};
|
||||
|
||||
const renderNode = (node: NavNode) => {
|
||||
const { section, children } = node;
|
||||
const isStatic = section.id === STATIC_SECTION_ID;
|
||||
const open = isOpen(section.id);
|
||||
return (
|
||||
<div key={section.id} className="portal-docs__nav-group">
|
||||
{isStatic ? (
|
||||
<div className="portal-docs__nav-head">{section.label}</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="portal-docs__nav-head portal-docs__nav-head--button"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setToggled((prev) => ({ ...prev, [section.id]: !open }))
|
||||
}
|
||||
rightSection={
|
||||
<span className="portal-docs__nav-count">
|
||||
{section.items.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="portal-docs__nav-head-main">
|
||||
<span
|
||||
className={
|
||||
"portal-docs__nav-chevron" + (open ? " is-open" : "")
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
<span className="portal-docs__nav-headlabel">
|
||||
{section.label}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{section.items.length > 0 && (
|
||||
<ul className="portal-docs__nav-list">
|
||||
{section.items.map((item) => {
|
||||
const isActive = item.id === active;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Button
|
||||
ref={isActive ? activeRef : undefined}
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={
|
||||
"portal-docs__nav-link" +
|
||||
(isActive ? " is-active" : "")
|
||||
}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="portal-docs__nav-label">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge && (
|
||||
<StatusBadge
|
||||
tone={item.badge === "New" ? "success" : "info"}
|
||||
size="sm"
|
||||
>
|
||||
{item.badge}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{children.length > 0 && (
|
||||
<div className="portal-docs__nav-children">
|
||||
{children.map(renderNode)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="portal-docs__nav"
|
||||
aria-label={t("portal.docs.nav.ariaLabel")}
|
||||
>
|
||||
{tree.map(renderNode)}
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className="portal-docs__nav-group">
|
||||
<div className="portal-docs__nav-head">
|
||||
<span className="portal-docs__nav-icon" aria-hidden>
|
||||
{section.icon}
|
||||
</span>
|
||||
{section.label}
|
||||
</div>
|
||||
<ul className="portal-docs__nav-list">
|
||||
{section.items.map((item) => {
|
||||
const isActive = item.id === active;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={
|
||||
"portal-docs__nav-link" + (isActive ? " is-active" : "")
|
||||
}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="portal-docs__nav-label">{item.label}</span>
|
||||
{item.badge && (
|
||||
<StatusBadge
|
||||
tone={item.badge === "New" ? "success" : "info"}
|
||||
size="sm"
|
||||
>
|
||||
{item.badge}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -176,16 +64,14 @@ export function DocsNav({
|
||||
export function DocsNavSkeleton() {
|
||||
return (
|
||||
<nav className="portal-docs__nav" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, gi) => (
|
||||
{Array.from({ length: 4 }).map((_, gi) => (
|
||||
<div key={gi} className="portal-docs__nav-group">
|
||||
<Skeleton width="7rem" height="0.75rem" />
|
||||
{gi === 0 && (
|
||||
<div className="portal-docs__nav-list">
|
||||
{Array.from({ length: 4 }).map((_, li) => (
|
||||
<Skeleton key={li} width="80%" height="0.875rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-docs__nav-list">
|
||||
{Array.from({ length: 3 }).map((_, li) => (
|
||||
<Skeleton key={li} width="80%" height="0.875rem" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { SearchResult, Segment } from "@portal/docs/search";
|
||||
|
||||
/** Render highlighted segments, wrapping matched runs in <mark>. */
|
||||
function Highlighted({ segments }: { segments: Segment[] }) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((s, i) =>
|
||||
s.hit ? (
|
||||
<mark key={i} className="portal-docs__hl">
|
||||
{s.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{s.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs search box + results. While a query is active it shows a ranked list of
|
||||
* matching docs — each with its section, a highlighted title, and a content
|
||||
* snippet — that navigates on click (or Enter). Arrow keys move the selection.
|
||||
*/
|
||||
export function DocsSearch({
|
||||
query,
|
||||
onQueryChange,
|
||||
results,
|
||||
onSelect,
|
||||
}: {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
results: SearchResult[];
|
||||
onSelect: (docId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover.
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const hasQuery = query.trim().length > 0;
|
||||
|
||||
useEffect(() => setActiveIndex(-1), [query]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector<HTMLElement>('[data-active="true"]')
|
||||
?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
onQueryChange("");
|
||||
return;
|
||||
}
|
||||
if (!results.length) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const hit = results[activeIndex >= 0 ? activeIndex : 0];
|
||||
if (hit) onSelect(hit.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="portal-docs__search">
|
||||
<div className="portal-docs__search-box">
|
||||
<span className="portal-docs__search-icon" aria-hidden>
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="portal-docs__search-input"
|
||||
placeholder={t("portal.docs.search.placeholder")}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("portal.docs.search.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasQuery && (
|
||||
<div className="portal-docs__results">
|
||||
{results.length === 0 ? (
|
||||
<p className="portal-docs__nav-empty">
|
||||
{t("portal.docs.search.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="portal-docs__results-count">
|
||||
{t("portal.docs.search.results", { count: results.length })}
|
||||
</div>
|
||||
<ul ref={listRef} className="portal-docs__results-list">
|
||||
{results.map((r, i) => (
|
||||
<li key={r.id}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
justify="start"
|
||||
className={
|
||||
"portal-docs__result" +
|
||||
(i === activeIndex ? " is-active" : "")
|
||||
}
|
||||
data-active={i === activeIndex}
|
||||
onClick={() => onSelect(r.id)}
|
||||
>
|
||||
<span className="portal-docs__result-body">
|
||||
<span className="portal-docs__result-head">
|
||||
<span className="portal-docs__result-title">
|
||||
<Highlighted segments={r.titleSegments} />
|
||||
</span>
|
||||
<span className="portal-docs__result-section">
|
||||
{r.sectionLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-docs__result-snippet">
|
||||
<Highlighted segments={r.snippet} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Heading } from "@portal/docs/headings";
|
||||
|
||||
/**
|
||||
* "On this page" table of contents. Lists the current doc's H2/H3 headings,
|
||||
* scrolls the reading pane to a heading on click, and highlights the section
|
||||
* currently in view (scroll-spy against the pane's scroll container).
|
||||
*/
|
||||
export function DocsToc({
|
||||
headings,
|
||||
scrollRef,
|
||||
}: {
|
||||
headings: Heading[];
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [active, setActive] = useState<string>(headings[0]?.slug ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root || headings.length === 0) return;
|
||||
setActive(headings[0].slug);
|
||||
|
||||
const visible = new Set<string>();
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) visible.add(e.target.id);
|
||||
else visible.delete(e.target.id);
|
||||
}
|
||||
// The topmost heading currently within the active zone wins.
|
||||
const current = headings.find((h) => visible.has(h.slug));
|
||||
if (current) setActive(current.slug);
|
||||
},
|
||||
// Active zone = the top ~30% of the reading pane.
|
||||
{ root, rootMargin: "0px 0px -70% 0px", threshold: 0 },
|
||||
);
|
||||
|
||||
const els = headings
|
||||
.map((h) => root.querySelector(`[id="${h.slug}"]`))
|
||||
.filter((el): el is Element => el !== null);
|
||||
els.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [headings, scrollRef]);
|
||||
|
||||
const onSelect = (slug: string) => {
|
||||
scrollRef.current
|
||||
?.querySelector(`[id="${slug}"]`)
|
||||
?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
setActive(slug);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="portal-docs__toc" aria-label={t("portal.docs.toc.title")}>
|
||||
<div className="portal-docs__toc-title">{t("portal.docs.toc.title")}</div>
|
||||
<ul className="portal-docs__toc-list">
|
||||
{headings.map((h) => (
|
||||
<li key={h.slug}>
|
||||
<a
|
||||
href={`#${h.slug}`}
|
||||
className={
|
||||
"portal-docs__toc-link" +
|
||||
(h.level === 3 ? " is-sub" : "") +
|
||||
(active === h.slug ? " is-active" : "")
|
||||
}
|
||||
aria-current={active === h.slug ? "location" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(h.slug);
|
||||
}}
|
||||
>
|
||||
{h.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { isValidElement, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown, {
|
||||
defaultUrlTransform,
|
||||
type Components,
|
||||
} from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Button } from "@app/ui";
|
||||
import { makeSlugger } from "@portal/docs/headings";
|
||||
|
||||
/** Flatten a heading's React children to plain text for its anchor id. */
|
||||
function childText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(childText).join("");
|
||||
if (isValidElement(node)) {
|
||||
return childText((node.props as { children?: ReactNode }).children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Keep our internal `doc:` scheme; sanitize every other URL as react-markdown
|
||||
// would by default (it strips unknown protocols, which would kill doc: links).
|
||||
function urlTransform(url: string): string {
|
||||
return url.startsWith("doc:") ? url : defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a doc's normalised markdown. Internal cross-doc links carry the
|
||||
* `doc:` scheme (see the sync transform) and are intercepted here so they
|
||||
* navigate within the portal instead of leaving the app.
|
||||
*/
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="portal-docs__md-copy"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
}
|
||||
>
|
||||
{copied ? "✓ Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildComponents(
|
||||
onNavigate: (docId: string) => void,
|
||||
slug: (text: string) => string,
|
||||
): Components {
|
||||
return {
|
||||
h2: ({ children }) => <h2 id={slug(childText(children))}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 id={slug(childText(children))}>{children}</h3>,
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("doc:")) {
|
||||
const id = href.slice(4);
|
||||
return (
|
||||
<a
|
||||
href={`#${id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigate(id);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const external = /^https?:/i.test(href ?? "");
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Eager, not lazy: lazy-loading inside the docs' own scroll container isn't
|
||||
// reliably triggered, and docs pages have only a handful of images.
|
||||
img: ({ node: _node, ...props }) => (
|
||||
<img {...props} className="portal-docs__md-img" />
|
||||
),
|
||||
pre: ({ children }) => {
|
||||
const code = isValidElement(children)
|
||||
? String(
|
||||
(children.props as { children?: unknown }).children ?? "",
|
||||
).replace(/\n$/, "")
|
||||
: String(children ?? "");
|
||||
return (
|
||||
<div className="portal-docs__md-pre">
|
||||
<pre>{children}</pre>
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="portal-docs__md-tablewrap">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function MarkdownDoc({
|
||||
markdown,
|
||||
onNavigate,
|
||||
}: {
|
||||
markdown: string;
|
||||
onNavigate: (docId: string) => void;
|
||||
}) {
|
||||
// A fresh de-duping slugger per render; react-markdown invokes h2/h3 in
|
||||
// document order, so ids line up with the TOC's extractHeadings slugs.
|
||||
const slug = makeSlugger();
|
||||
return (
|
||||
<div className="portal-docs__md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
urlTransform={urlTransform}
|
||||
components={buildComponents(onNavigate, slug)}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloudQueueRounded from "@mui/icons-material/CloudQueueRounded";
|
||||
import HubRounded from "@mui/icons-material/HubRounded";
|
||||
import Inventory2Rounded from "@mui/icons-material/Inventory2Rounded";
|
||||
import { Button, Card, CodeBlock, StatusBadge } from "@app/ui";
|
||||
import {
|
||||
TARGET_META,
|
||||
type DeploymentTarget,
|
||||
type TargetKind,
|
||||
} from "@portal/api/editorDeploy";
|
||||
import { TARGET_META, type DeploymentTarget } from "@portal/api/editorDeploy";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
|
||||
const TARGET_ICON: Record<TargetKind, typeof CloudQueueRounded> = {
|
||||
cloud: CloudQueueRounded,
|
||||
docker: Inventory2Rounded,
|
||||
kubernetes: HubRounded,
|
||||
};
|
||||
|
||||
const STATE_BADGE_TONE: Record<
|
||||
DeploymentTarget["state"],
|
||||
"success" | "info" | "neutral"
|
||||
@@ -49,7 +36,6 @@ export function DeploymentTargets({ targets, onUpgrade }: Props) {
|
||||
<div className="portal-editor__targets">
|
||||
{targets.map((target) => {
|
||||
const meta = TARGET_META[target.kind];
|
||||
const Icon = TARGET_ICON[target.kind];
|
||||
const locked = target.state === "locked";
|
||||
return (
|
||||
<Card
|
||||
@@ -62,7 +48,7 @@ export function DeploymentTargets({ targets, onUpgrade }: Props) {
|
||||
className={`portal-editor__target-icon portal-editor__target-icon--${meta.tone}`}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon style={{ fontSize: "1.2rem" }} />
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="portal-editor__target-titles">
|
||||
<h3 className="portal-editor__target-name">{target.label}</h3>
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CastRounded from "@mui/icons-material/CastRounded";
|
||||
import TerminalRounded from "@mui/icons-material/TerminalRounded";
|
||||
import VpnKeyRounded from "@mui/icons-material/VpnKeyRounded";
|
||||
import { Button, Card, Chip, CodeBlock } from "@app/ui";
|
||||
import type { PairingMethod, PairingOption } from "@portal/api/editorDeploy";
|
||||
|
||||
const METHOD_ICON: Record<PairingMethod, typeof VpnKeyRounded> = {
|
||||
token: VpnKeyRounded,
|
||||
shortcode: CastRounded,
|
||||
iac: TerminalRounded,
|
||||
};
|
||||
|
||||
const METHOD_TONE: Record<PairingMethod, "blue" | "purple" | "green"> = {
|
||||
token: "blue",
|
||||
shortcode: "purple",
|
||||
iac: "green",
|
||||
const METHOD_ICON: Record<PairingMethod, string> = {
|
||||
token: "🔑",
|
||||
shortcode: "📺",
|
||||
iac: "🧱",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -45,7 +36,6 @@ export function PairingPanel({ pairings, onUpgrade }: Props) {
|
||||
<div className="portal-editor__pairings">
|
||||
{pairings.map((p) => {
|
||||
const isCode = p.method === "iac";
|
||||
const Icon = METHOD_ICON[p.method];
|
||||
return (
|
||||
<Card
|
||||
key={p.method}
|
||||
@@ -53,11 +43,8 @@ export function PairingPanel({ pairings, onUpgrade }: Props) {
|
||||
className="portal-editor__pairing"
|
||||
>
|
||||
<div className="portal-editor__pairing-head">
|
||||
<span
|
||||
className={`portal-editor__pairing-icon portal-editor__pairing-icon--${METHOD_TONE[p.method]}`}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon style={{ fontSize: "1.2rem" }} />
|
||||
<span className="portal-editor__pairing-icon" aria-hidden>
|
||||
{METHOD_ICON[p.method]}
|
||||
</span>
|
||||
<div className="portal-editor__pairing-titles">
|
||||
<h3 className="portal-editor__pairing-name">{p.label}</h3>
|
||||
|
||||
@@ -221,6 +221,17 @@ export function PoliciesIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentsIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentBuilderIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useState, type ComponentType, type CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowForwardRounded from "@mui/icons-material/ArrowForwardRounded";
|
||||
import CloudRounded from "@mui/icons-material/CloudRounded";
|
||||
import StorageRounded from "@mui/icons-material/StorageRounded";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -23,13 +20,13 @@ import {
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
import { pct } from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
const PROVIDER_ICON: Record<
|
||||
const PROVIDER_GLYPH: Record<
|
||||
StorageConfig["providers"][number]["kind"],
|
||||
ComponentType<{ style?: CSSProperties }>
|
||||
string
|
||||
> = {
|
||||
stirling: StorageRounded,
|
||||
s3: CloudRounded,
|
||||
azure: CloudRounded,
|
||||
stirling: "◆",
|
||||
s3: "▣",
|
||||
azure: "▤",
|
||||
};
|
||||
|
||||
/** Storage fills past this fraction of quota are surfaced in red. */
|
||||
@@ -146,38 +143,35 @@ export function StorageTab() {
|
||||
sub={t("portal.infrastructure.storage.providers.subheading")}
|
||||
/>
|
||||
<ul className="portal-infra__providers">
|
||||
{data.providers.map((p) => {
|
||||
const ProviderIcon = PROVIDER_ICON[p.kind];
|
||||
return (
|
||||
<li key={p.id} className="portal-infra__provider">
|
||||
<span className="portal-infra__provider-glyph" aria-hidden>
|
||||
<ProviderIcon style={{ fontSize: "1.2rem" }} />
|
||||
</span>
|
||||
<span className="portal-infra__provider-text">
|
||||
<span className="portal-infra__cell-strong">{p.name}</span>
|
||||
<span className="portal-infra__muted">{p.detail}</span>
|
||||
</span>
|
||||
{p.connected ? (
|
||||
<span className="portal-infra__provider-meta">
|
||||
<span className="portal-infra__mono">
|
||||
{t("portal.infrastructure.storage.gbValue", {
|
||||
value: p.usedGb,
|
||||
})}
|
||||
</span>
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("portal.infrastructure.storage.providers.connected")}
|
||||
</StatusBadge>
|
||||
{data.providers.map((p) => (
|
||||
<li key={p.id} className="portal-infra__provider">
|
||||
<span className="portal-infra__provider-glyph" aria-hidden>
|
||||
{PROVIDER_GLYPH[p.kind]}
|
||||
</span>
|
||||
<span className="portal-infra__provider-text">
|
||||
<span className="portal-infra__cell-strong">{p.name}</span>
|
||||
<span className="portal-infra__muted">{p.detail}</span>
|
||||
</span>
|
||||
{p.connected ? (
|
||||
<span className="portal-infra__provider-meta">
|
||||
<span className="portal-infra__mono">
|
||||
{t("portal.infrastructure.storage.gbValue", {
|
||||
value: p.usedGb,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
// TODO(backend): launch the provider OAuth/credential flow,
|
||||
// then POST /v1/infrastructure/storage/providers/{id}/connect
|
||||
<Button variant="secondary" size="sm">
|
||||
{t("portal.infrastructure.storage.providers.connect")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("portal.infrastructure.storage.providers.connected")}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
) : (
|
||||
// TODO(backend): launch the provider OAuth/credential flow,
|
||||
// then POST /v1/infrastructure/storage/providers/{id}/connect
|
||||
<Button variant="secondary" size="sm">
|
||||
{t("portal.infrastructure.storage.providers.connect")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
@@ -211,7 +205,7 @@ export function StorageTab() {
|
||||
</span>
|
||||
</div>
|
||||
<span className="portal-infra__lifecycle-arrow" aria-hidden>
|
||||
<ArrowForwardRounded style={{ fontSize: "1.2rem" }} />
|
||||
→
|
||||
</span>
|
||||
<div className="portal-infra__lifecycle-stage">
|
||||
<span className="portal-infra__lifecycle-dot" />
|
||||
@@ -223,7 +217,7 @@ export function StorageTab() {
|
||||
</span>
|
||||
</div>
|
||||
<span className="portal-infra__lifecycle-arrow" aria-hidden>
|
||||
<ArrowForwardRounded style={{ fontSize: "1.2rem" }} />
|
||||
→
|
||||
</span>
|
||||
<div className="portal-infra__lifecycle-stage">
|
||||
<span className="portal-infra__lifecycle-dot" />
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
type ChipAccent,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import type { CatalogueEntry, PolicyCategory } from "@portal/api/policies";
|
||||
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
interface PolicyCatalogueTableProps {
|
||||
entries: CatalogueEntry[];
|
||||
onOpen: (entry: CatalogueEntry) => void;
|
||||
}
|
||||
|
||||
/** Tint the "enforces" chips with the same hue as the category's icon badge. */
|
||||
const TONE_ACCENT: Record<PolicyCategory["tone"], ChipAccent> = {
|
||||
blue: "default",
|
||||
purple: "premium",
|
||||
green: "success",
|
||||
amber: "warning",
|
||||
red: "danger",
|
||||
neutral: "neutral",
|
||||
};
|
||||
|
||||
/**
|
||||
* The policy catalogue as a proper data table (Policy / Enforces / Applies to /
|
||||
* Docs / Status), replacing the stacked full-width cards that read as "blocky".
|
||||
* Same shared Table + StatusBadge + Chip primitives the Sources, Documents and
|
||||
* Home policy tables use, so every list page in the portal now reads alike.
|
||||
*/
|
||||
export function PolicyCatalogueTable({
|
||||
entries,
|
||||
onOpen,
|
||||
}: PolicyCatalogueTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = useMemo<TableColumn<CatalogueEntry>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "policy",
|
||||
header: t("portal.policies.table.policy", "Policy"),
|
||||
render: (entry) => (
|
||||
<div className="portal-policies__cell">
|
||||
<PolicyCategoryBadge category={entry.category} />
|
||||
<strong className="portal-policies__cell-name">
|
||||
{t(entry.category.label)}
|
||||
</strong>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "enforces",
|
||||
header: t("portal.policies.table.enforces", "Enforces"),
|
||||
render: (entry) => (
|
||||
<div className="portal-policies__rulechips">
|
||||
{entry.config.rules.map((r) => (
|
||||
<Chip key={r} accent={TONE_ACCENT[entry.category.tone]} size="sm">
|
||||
{t(r)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
header: t("portal.policies.table.appliesTo", "Applies to"),
|
||||
render: (entry) => (
|
||||
<span className="portal-policies__muted">
|
||||
{t(entry.config.scopeLabel)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
header: t("portal.policies.table.docs", "Docs enforced"),
|
||||
align: "right",
|
||||
width: "8rem",
|
||||
render: (entry) => (
|
||||
<span className="portal-policies__docs">
|
||||
{entry.policy ? entry.policy.stats.enforced.toLocaleString() : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("portal.policies.table.status", "Status"),
|
||||
align: "right",
|
||||
width: "8.5rem",
|
||||
render: (entry) => {
|
||||
if (entry.category.comingSoon) {
|
||||
// One consistent neutral chip for every "Upgrade to Enterprise" —
|
||||
// the same action should read the same on every row.
|
||||
return (
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t("portal.policies.card.comingSoon")}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
if (entry.policy) {
|
||||
const paused = entry.policy.state.status === "paused";
|
||||
return (
|
||||
<StatusBadge
|
||||
tone={paused ? "warning" : "success"}
|
||||
size="sm"
|
||||
pulse={!paused}
|
||||
>
|
||||
{paused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active")}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button size="sm" variant="secondary" onClick={() => onOpen(entry)}>
|
||||
{t("portal.policySummary.action.setUp")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[t, onOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<CatalogueEntry>
|
||||
className="portal-policies__table"
|
||||
columns={columns}
|
||||
rows={entries}
|
||||
rowKey={(e) => e.category.id}
|
||||
onRowClick={(entry) =>
|
||||
entry.category.comingSoon ? undefined : onOpen(entry)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/* Tinted rounded icon badge for a policy category — colour comes from the
|
||||
category tone. Lives with the component so every consumer (the Policies table
|
||||
and the Home policy summary) gets the styling without importing a view CSS. */
|
||||
.pcat-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.pcat-badge svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pcat-badge--blue {
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
.pcat-badge--purple {
|
||||
background: var(--color-purple-light);
|
||||
color: var(--color-purple);
|
||||
}
|
||||
.pcat-badge--green {
|
||||
background: var(--color-green-light);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
.pcat-badge--amber {
|
||||
background: var(--color-amber-light);
|
||||
color: var(--color-amber-dark);
|
||||
}
|
||||
.pcat-badge--red {
|
||||
background: var(--color-red-light);
|
||||
color: var(--color-red);
|
||||
}
|
||||
.pcat-badge--neutral {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { PolicyCategory } from "@portal/api/policies";
|
||||
import "@portal/components/policies/PolicyCategoryIcon.css";
|
||||
|
||||
/**
|
||||
* Stroke-SVG icons for the policy catalogue, keyed by the category's string icon
|
||||
* key. Replaces the old Unicode glyph map (▤ 🛡 ✓ …) so category rows use the
|
||||
* same line-icon system as the rest of the portal instead of box-drawing
|
||||
* characters. Unknown keys fall back to a neutral document mark (never a bare
|
||||
* bullet — that was the "schedule"/"clock" key mismatch's tell).
|
||||
*/
|
||||
|
||||
const PATHS: Record<string, string> = {
|
||||
layers: "M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5M3 17l9 5 9-5",
|
||||
shield: "M12 3l7 3v5c0 4-3 7-7 9-4-2-7-5-7-9V6l7-3z",
|
||||
check: "M20 7L10 17l-5-5",
|
||||
route:
|
||||
"M6 4a2 2 0 100 4 2 2 0 000-4zM18 16a2 2 0 100 4 2 2 0 000-4zM6 8v6a4 4 0 004 4h4",
|
||||
clock: "M12 7v5l3 2M12 21a9 9 0 100-18 9 9 0 000 18z",
|
||||
schedule: "M12 7v5l3 2M12 21a9 9 0 100-18 9 9 0 000 18z",
|
||||
file: "M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V7l-4-4zM14 3v4h4",
|
||||
device:
|
||||
"M5 4h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1zM9 20h6",
|
||||
globe:
|
||||
"M12 21a9 9 0 100-18 9 9 0 000 18zM3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18",
|
||||
cloud: "M7 18a4 4 0 010-8 5 5 0 019.6-1.3A3.5 3.5 0 0117 18H7z",
|
||||
mail: "M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1zM4 7l8 6 8-6",
|
||||
folder: "M3 7h6l2 2h10v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7z",
|
||||
};
|
||||
|
||||
const TONE_CLASS: Record<PolicyCategory["tone"], string> = {
|
||||
blue: "pcat-badge--blue",
|
||||
purple: "pcat-badge--purple",
|
||||
green: "pcat-badge--green",
|
||||
amber: "pcat-badge--amber",
|
||||
red: "pcat-badge--red",
|
||||
neutral: "pcat-badge--neutral",
|
||||
};
|
||||
|
||||
/** A tinted rounded badge holding the category's stroke icon. */
|
||||
export function PolicyCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: PolicyCategory;
|
||||
}) {
|
||||
const d = PATHS[category.icon] ?? PATHS.file;
|
||||
return (
|
||||
<span
|
||||
className={`pcat-badge ${TONE_CLASS[category.tone] ?? TONE_CLASS.neutral}`}
|
||||
aria-hidden
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d={d}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
|
||||
import { SourceTypeIcon } from "@portal/components/sources/SourceTypeIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
@@ -310,7 +310,9 @@ function PolicySetupWizardBody({
|
||||
width="lg"
|
||||
title={
|
||||
<span className="portal-policies__wizard-title">
|
||||
<PolicyCategoryBadge category={category} />
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{isEdit
|
||||
? t("portal.policies.wizard.title.edit", {
|
||||
category: t(category.label),
|
||||
@@ -494,7 +496,7 @@ function PolicySetupWizardBody({
|
||||
onClick={() => toggleSource(src.id)}
|
||||
>
|
||||
<span className="portal-policies__source-icon" aria-hidden>
|
||||
<SourceTypeIcon type={src.type} />
|
||||
{sourceTypeMeta(src.type).icon}
|
||||
</span>
|
||||
<span className="portal-policies__source-text">
|
||||
<span className="portal-policies__source-label">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||
import { Button } from "@app/ui";
|
||||
import type { ProcurementSnapshot } from "@portal/api/procurement";
|
||||
import { CalendlyInline } from "@portal/components/procurement/CalendlyInline";
|
||||
@@ -61,7 +60,7 @@ function SideModal({
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.procurement.modal.close")}
|
||||
>
|
||||
<CloseRounded style={{ fontSize: "1.2rem" }} />
|
||||
✕
|
||||
</button>
|
||||
<div className="portal-sidemodal__header">
|
||||
<h3 className="portal-sidemodal__title">{title}</h3>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
|
||||
@@ -91,7 +90,7 @@ export function ProcurementModal({
|
||||
onClick={onClose}
|
||||
aria-label={t("portal.procurement.modal.close")}
|
||||
>
|
||||
<CloseRounded style={{ fontSize: "1.2rem" }} />
|
||||
✕
|
||||
</button>
|
||||
<div className="portal-procmodal__header">
|
||||
<h2 className="portal-procmodal__title">{title}</h2>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckRounded from "@mui/icons-material/CheckRounded";
|
||||
import { Button } from "@app/ui";
|
||||
import {
|
||||
DocumentsIcon,
|
||||
@@ -527,7 +526,7 @@ function AddOn({
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="portal-qb__addon-box" aria-hidden>
|
||||
{on ? <CheckRounded style={{ fontSize: "0.85rem" }} /> : ""}
|
||||
{on ? "✓" : ""}
|
||||
</span>
|
||||
<span>
|
||||
<span className="portal-qb__addon-title">{title}</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PoliciesIcon,
|
||||
PipelinesIcon,
|
||||
DocumentsIcon,
|
||||
ComponentsIcon,
|
||||
InfrastructureIcon,
|
||||
UsageIcon,
|
||||
DocsIcon,
|
||||
@@ -19,6 +20,10 @@ export interface NavEntry {
|
||||
externalUrl?: string;
|
||||
}
|
||||
|
||||
// Developer docs has no built-in portal page yet, so the tab opens the hosted docs
|
||||
// site in a new tab rather than routing to an empty page.
|
||||
const DEVELOPER_DOCS_URL = "https://docs.stirlingpdf.com/";
|
||||
|
||||
// Sidebar nav groups. This is a flavor seam: the SaaS build shadows this file to
|
||||
// drop sections not yet shipped there (see src/portal-saas/components/sidebarGroups).
|
||||
export const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: <HomeIcon /> }];
|
||||
@@ -29,10 +34,11 @@ export const GROUP_OPERATIONAL: NavEntry[] = [
|
||||
{ id: "policies", icon: <PoliciesIcon /> },
|
||||
{ id: "pipelines", icon: <PipelinesIcon /> },
|
||||
{ id: "documents", icon: <DocumentsIcon /> },
|
||||
{ id: "components", icon: <ComponentsIcon /> },
|
||||
];
|
||||
|
||||
export const GROUP_PLATFORM: NavEntry[] = [
|
||||
{ id: "infrastructure", icon: <InfrastructureIcon /> },
|
||||
{ id: "usage", icon: <UsageIcon /> },
|
||||
{ id: "docs", icon: <DocsIcon /> },
|
||||
{ id: "docs", icon: <DocsIcon />, externalUrl: DEVELOPER_DOCS_URL },
|
||||
];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckRounded from "@mui/icons-material/CheckRounded";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -15,9 +14,9 @@ import { createSource, type Source } from "@portal/api/sources";
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
import {
|
||||
defaultOptions,
|
||||
sourceTypeMeta,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import { SourceTypeIcon } from "@portal/components/sources/SourceTypeIcon";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const OFFERED_TYPES = creatableSourceTypes();
|
||||
@@ -192,11 +191,7 @@ export function ConnectWizard({
|
||||
}
|
||||
>
|
||||
<span className="portal-sources__step-mark">
|
||||
{i < stepIndex ? (
|
||||
<CheckRounded style={{ fontSize: "0.9rem" }} />
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
{i < stepIndex ? "✓" : i + 1}
|
||||
</span>
|
||||
{stepLabels[id]}
|
||||
</li>
|
||||
@@ -216,7 +211,7 @@ export function ConnectWizard({
|
||||
onClick={() => chooseType(ct)}
|
||||
>
|
||||
<span className="portal-sources__type-icon" aria-hidden>
|
||||
<SourceTypeIcon type={ct.type} />
|
||||
{sourceTypeMeta(ct.type).icon}
|
||||
</span>
|
||||
<span className="portal-sources__type-name">
|
||||
{t(ct.labelKey)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Button } from "@app/ui";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
|
||||
import { SourceTypeIcon } from "@portal/components/sources/SourceTypeIcon";
|
||||
import {
|
||||
EDITOR_SOURCE_TYPE,
|
||||
sourceTypeMeta,
|
||||
@@ -43,7 +42,7 @@ export function SourceDetailCard({
|
||||
className={`portal-sources__type-dot portal-sources__type-dot--${meta.accent}`}
|
||||
aria-hidden
|
||||
>
|
||||
<SourceTypeIcon type={source.type} />
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="portal-sources__expanded-title">
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Stroke-SVG icons for source types, replacing the black Unicode glyphs
|
||||
* (⛁ ☁ ✏ ◇) that rendered off-style and mis-centred inside the type tiles and
|
||||
* table badges. Keyed by the backend source `type`; unknown types fall back to
|
||||
* a neutral document mark.
|
||||
*/
|
||||
|
||||
const PATHS: Record<string, string> = {
|
||||
folder: "M3 7h6l2 2h10v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7z",
|
||||
s3: "M7 18a4 4 0 010-8 5 5 0 019.6-1.3A3.5 3.5 0 0117 18H7z",
|
||||
editor: "M4 20h16M14 4l6 6-9 9H5v-6l9-9z",
|
||||
_default:
|
||||
"M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V7l-4-4zM14 3v4h4",
|
||||
};
|
||||
|
||||
export function SourceTypeIcon({ type }: { type: string }) {
|
||||
const d = PATHS[type] ?? PATHS._default;
|
||||
return (
|
||||
<svg
|
||||
className="portal-sources__type-svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d={d}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowRightRounded from "@mui/icons-material/KeyboardArrowRightRounded";
|
||||
import {
|
||||
Chip,
|
||||
StatusBadge,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
EDITOR_SOURCE_TYPE,
|
||||
sourceTypeMeta,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import { SourceTypeIcon } from "@portal/components/sources/SourceTypeIcon";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const STATUS_TONE: Record<SourceStatus, StatusTone> = {
|
||||
@@ -51,7 +49,7 @@ export function SourcesTable({
|
||||
className={`portal-sources__type-dot portal-sources__type-dot--${meta.accent}`}
|
||||
aria-hidden
|
||||
>
|
||||
<SourceTypeIcon type={s.type} />
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="portal-sources__name-text">
|
||||
<strong>{isEditor ? t(meta.labelKey) : s.name}</strong>
|
||||
@@ -104,7 +102,7 @@ export function SourcesTable({
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
<KeyboardArrowRightRounded style={{ fontSize: "1.2rem" }} />
|
||||
▸
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ViewId =
|
||||
| "policies"
|
||||
| "pipelines"
|
||||
| "documents"
|
||||
| "components"
|
||||
| "infrastructure"
|
||||
| "usage"
|
||||
| "docs"
|
||||
@@ -26,9 +27,10 @@ export const VIEW_LABELS: Record<ViewId, string> = {
|
||||
policies: "Policies",
|
||||
pipelines: "Pipelines",
|
||||
documents: "Documents",
|
||||
components: "Components",
|
||||
infrastructure: "Infrastructure",
|
||||
usage: "Usage & Billing",
|
||||
docs: "Documentation",
|
||||
docs: "Developer Docs",
|
||||
procurement: "Procurement",
|
||||
settings: "Settings",
|
||||
};
|
||||
@@ -42,6 +44,7 @@ export const VIEW_PATHS: Record<ViewId, string> = {
|
||||
policies: "/policies",
|
||||
pipelines: "/pipelines",
|
||||
documents: "/documents",
|
||||
components: "/components",
|
||||
infrastructure: "/infrastructure",
|
||||
usage: "/usage",
|
||||
docs: "/docs",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractHeadings, slugify } from "@portal/docs/headings";
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases, hyphenates, and trims punctuation", () => {
|
||||
expect(slugify("How it Works!")).toBe("how-it-works");
|
||||
expect(slugify(" Trailing & spaces ")).toBe("trailing-spaces");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractHeadings", () => {
|
||||
it("extracts H2/H3 only, with slugs matching the rendered ids", () => {
|
||||
const md = [
|
||||
"# Page Title",
|
||||
"## How it Works",
|
||||
"text",
|
||||
"### Sub Section",
|
||||
"#### Too Deep",
|
||||
"## Operations",
|
||||
].join("\n");
|
||||
expect(extractHeadings(md)).toEqual([
|
||||
{ level: 2, text: "How it Works", slug: "how-it-works" },
|
||||
{ level: 3, text: "Sub Section", slug: "sub-section" },
|
||||
{ level: 2, text: "Operations", slug: "operations" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("de-duplicates repeated heading text into unique slugs", () => {
|
||||
const md = ["## What Changed", "### What Changed", "## What Changed"].join(
|
||||
"\n",
|
||||
);
|
||||
expect(extractHeadings(md).map((h) => h.slug)).toEqual([
|
||||
"what-changed",
|
||||
"what-changed-1",
|
||||
"what-changed-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores headings inside fenced code and strips inline marks", () => {
|
||||
const md = ["```", "## not a heading", "```", "## `Code` and *em*"].join(
|
||||
"\n",
|
||||
);
|
||||
expect(extractHeadings(md)).toEqual([
|
||||
{ level: 2, text: "Code and em", slug: "code-and-em" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Heading extraction for the "On this page" table of contents. The same
|
||||
* `slugify` is used here and in MarkdownDoc's heading renderer, so the TOC links
|
||||
* and the rendered heading ids always match.
|
||||
*/
|
||||
|
||||
export interface Heading {
|
||||
/** 2 or 3 (H2/H3). */
|
||||
level: number;
|
||||
text: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/** "How it Works!" → "how-it-works" (the base id, before de-duplication). */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A stateful slugger that de-duplicates: repeated heading text gets `-1`, `-2`, …
|
||||
* The TOC extraction and MarkdownDoc's heading renderer each make one and feed it
|
||||
* headings in document order, so their slugs (and thus link ↔ id) always match.
|
||||
*/
|
||||
export function makeSlugger(): (text: string) => string {
|
||||
const seen = new Map<string, number>();
|
||||
return (text: string) => {
|
||||
const base = slugify(text) || "section";
|
||||
const n = seen.get(base) ?? 0;
|
||||
seen.set(base, n + 1);
|
||||
return n === 0 ? base : `${base}-${n}`;
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract H2/H3 headings from a doc body, skipping fenced code blocks. */
|
||||
export function extractHeadings(markdown: string): Heading[] {
|
||||
const noCode = markdown.replace(/^(```|~~~)[\s\S]*?^\1[ \t]*$/gm, "");
|
||||
const slug = makeSlugger();
|
||||
const headings: Heading[] = [];
|
||||
for (const m of noCode.matchAll(/^ {0,3}(#{2,3})[ \t]+(.+?)[ \t]*#*$/gm)) {
|
||||
const text = m[2].replace(/[`*_]/g, "").trim();
|
||||
if (text) headings.push({ level: m[1].length, text, slug: slug(text) });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Runtime accessors over the generated docs manifest. This is the only module
|
||||
* that imports the (large) JSON, so lazy-loading the docs view keeps it in its
|
||||
* own chunk. Regenerate the JSON with `npm run docs:sync`.
|
||||
*/
|
||||
// Imported as a raw string (not a JSON module) so tsc doesn't infer a ~half-MB
|
||||
// literal type; parsed once here into the typed manifest.
|
||||
import manifestRaw from "@portal/generated/docsManifest.json?raw";
|
||||
import type {
|
||||
DocEntry,
|
||||
DocsManifest,
|
||||
DocsNavSection,
|
||||
} from "@portal/docs/manifest/transform";
|
||||
|
||||
const manifest = JSON.parse(manifestRaw) as DocsManifest;
|
||||
|
||||
/** Provenance of the current manifest (repo + ref it was generated from). */
|
||||
export const docsSource = manifest.source;
|
||||
|
||||
/** The auto-sorted nav tree (sections → items). */
|
||||
export function loadDocsNav(): DocsNavSection[] {
|
||||
return manifest.nav;
|
||||
}
|
||||
|
||||
/** A single doc by id, or undefined if it isn't in the manifest. */
|
||||
export function loadDoc(id: string): DocEntry | undefined {
|
||||
return manifest.docs[id];
|
||||
}
|
||||
|
||||
/** Every doc, for building the search index. */
|
||||
export function allDocs(): DocEntry[] {
|
||||
return Object.values(manifest.docs);
|
||||
}
|
||||
|
||||
/** The first doc id (first item of the first section) — the default landing. */
|
||||
export function firstDocId(): string | undefined {
|
||||
return manifest.nav[0]?.items[0]?.id;
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildManifest,
|
||||
convertAdmonitions,
|
||||
demoteHeadings,
|
||||
docIdForPath,
|
||||
humanize,
|
||||
parseFrontmatter,
|
||||
resolveRelative,
|
||||
rewriteReferences,
|
||||
sectionIcon,
|
||||
stripJsxTags,
|
||||
stripMdxImports,
|
||||
stripRedundantH1,
|
||||
type CategoryMap,
|
||||
type RawDoc,
|
||||
} from "@portal/docs/manifest/transform";
|
||||
|
||||
const OPTS = {
|
||||
repo: "Owner/Repo",
|
||||
ref: "main",
|
||||
root: "docs",
|
||||
siteBaseUrl: "https://docs.example.com",
|
||||
};
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
it("splits scalar YAML frontmatter from the body", () => {
|
||||
const { data, body } = parseFrontmatter(
|
||||
"---\ntitle: OCR\nsidebar_position: 7\n---\n# Heading\ntext",
|
||||
);
|
||||
expect(data.title).toBe("OCR");
|
||||
expect(data.sidebar_position).toBe(7);
|
||||
expect(body).toBe("# Heading\ntext");
|
||||
});
|
||||
|
||||
it("returns the whole content as body when there is no frontmatter", () => {
|
||||
const { data, body } = parseFrontmatter("# Just a doc\nbody");
|
||||
expect(data).toEqual({});
|
||||
expect(body).toBe("# Just a doc\nbody");
|
||||
});
|
||||
|
||||
it("normalises CRLF line endings", () => {
|
||||
const { data } = parseFrontmatter("---\r\nid: x\r\n---\r\nbody");
|
||||
expect(data.id).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("id + label helpers", () => {
|
||||
it("slugifies nested paths", () => {
|
||||
expect(docIdForPath("Configuration/OCR.md")).toBe("configuration/ocr");
|
||||
expect(docIdForPath("Getting Started.md")).toBe("getting-started");
|
||||
});
|
||||
|
||||
it("humanises file/dir names", () => {
|
||||
expect(humanize("Getting-Started.md")).toBe("Getting Started");
|
||||
});
|
||||
|
||||
it("picks a section icon from the label", () => {
|
||||
expect(sectionIcon("Configuration")).toBe("⚙");
|
||||
expect(sectionIcon("Totally Unknown")).toBe("◇");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MDX normalisation", () => {
|
||||
it("converts admonitions to titled blockquotes", () => {
|
||||
const out = convertAdmonitions(":::tip Upgrading?\nread this\n:::");
|
||||
expect(out).toContain("> **💡 Tip: Upgrading?**");
|
||||
expect(out).toContain("> read this");
|
||||
});
|
||||
|
||||
it("strips import/export statements", () => {
|
||||
const out = stripMdxImports("import Tabs from '@theme/Tabs';\n# Keep");
|
||||
expect(out).toBe("# Keep");
|
||||
});
|
||||
|
||||
it("removes JSX component tags but keeps inner content", () => {
|
||||
expect(
|
||||
stripJsxTags("<Tabs>\n<TabItem value='a'>keep</TabItem>\n</Tabs>"),
|
||||
).toContain("keep");
|
||||
expect(stripJsxTags("<TabItem>x</TabItem>")).not.toMatch(/<TabItem/);
|
||||
});
|
||||
|
||||
it("demotes body H1 to H2 but leaves code comments alone", () => {
|
||||
const md = "# Title\n\n```bash\n# a shell comment\n```";
|
||||
const out = demoteHeadings(md);
|
||||
expect(out).toContain("## Title");
|
||||
expect(out).toContain("# a shell comment");
|
||||
});
|
||||
|
||||
it("strips a leading H1 that duplicates the page title", () => {
|
||||
expect(stripRedundantH1("# OCR\nbody", "OCR")).toBe("body");
|
||||
expect(stripRedundantH1("# Other\nbody", "OCR")).toBe("# Other\nbody");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRelative", () => {
|
||||
it("collapses ./ and ../ against a base dir", () => {
|
||||
expect(resolveRelative("Configuration", "./OCR.md")).toBe(
|
||||
"Configuration/OCR.md",
|
||||
);
|
||||
expect(
|
||||
resolveRelative("Configuration", "../Functionality/Compare.md"),
|
||||
).toBe("Functionality/Compare.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteReferences", () => {
|
||||
const ctx = {
|
||||
dir: "Configuration",
|
||||
// Keys are lowercased file paths (spaces preserved), as buildManifest builds them.
|
||||
pathToId: new Map([
|
||||
[
|
||||
"configuration/system and security",
|
||||
"configuration/system-and-security",
|
||||
],
|
||||
]),
|
||||
rawBase: "https://raw.example.com/Owner/Repo/main",
|
||||
siteBaseUrl: "https://docs.example.com",
|
||||
};
|
||||
|
||||
it("rewrites resolvable internal links to the doc: scheme (decoded + case-insensitive)", () => {
|
||||
const out = rewriteReferences(
|
||||
"see [sec](./System%20and%20Security.md)",
|
||||
ctx,
|
||||
"docs",
|
||||
);
|
||||
expect(out).toBe("see [sec](doc:configuration/system-and-security)");
|
||||
});
|
||||
|
||||
it("falls back to the live docs site for unresolved internal links", () => {
|
||||
const out = rewriteReferences("[x](./Missing.md)", ctx, "docs");
|
||||
expect(out).toBe("[x](https://docs.example.com/Configuration/Missing)");
|
||||
});
|
||||
|
||||
it("leaves absolute and anchor links untouched", () => {
|
||||
const md = "[a](https://x.com) and [b](#top)";
|
||||
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
|
||||
});
|
||||
|
||||
it("rewrites relative images to absolute raw URLs", () => {
|
||||
expect(rewriteReferences("", ctx, "docs")).toBe(
|
||||
"",
|
||||
);
|
||||
expect(rewriteReferences("", ctx, "docs")).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite inside fenced code blocks", () => {
|
||||
const md = "```\n[x](./y.md)\n```";
|
||||
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildManifest", () => {
|
||||
const rawDocs: RawDoc[] = [
|
||||
{
|
||||
relPath: "Getting Started.md",
|
||||
content: "---\nsidebar_position: 0\n---\nintro",
|
||||
},
|
||||
{
|
||||
relPath: "Configuration/OCR.md",
|
||||
content: "---\ntitle: OCR\nsidebar_position: 7\n---\n# OCR\nbody",
|
||||
},
|
||||
{
|
||||
relPath: "Configuration/DATABASE.md",
|
||||
content: "---\nsidebar_position: 1\n---\n# Database\nsee [ocr](./OCR.md)",
|
||||
},
|
||||
];
|
||||
const categories: CategoryMap = {
|
||||
Configuration: { label: "Configuration", position: 5 },
|
||||
};
|
||||
|
||||
it("auto-sorts sections (root Overview first, then by category position)", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
expect(m.nav.map((s) => s.id)).toEqual(["overview", "configuration"]);
|
||||
expect(m.nav[0].label).toBe("Overview");
|
||||
expect(m.nav[1].label).toBe("Configuration");
|
||||
});
|
||||
|
||||
it("orders nav items by sidebar_position", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
const config = m.nav.find((s) => s.id === "configuration")!;
|
||||
expect(config.items.map((i) => i.id)).toEqual([
|
||||
"configuration/database",
|
||||
"configuration/ocr",
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives titles from frontmatter, heading, then filename", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
expect(m.docs["configuration/ocr"].title).toBe("OCR");
|
||||
expect(m.docs["getting-started"].title).toBe("Getting Started");
|
||||
});
|
||||
|
||||
it("resolves cross-doc links and records source/edit urls", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
expect(m.docs["configuration/database"].markdown).toContain(
|
||||
"[ocr](doc:configuration/ocr)",
|
||||
);
|
||||
expect(m.docs["configuration/ocr"].sourcePath).toBe(
|
||||
"docs/Configuration/OCR.md",
|
||||
);
|
||||
expect(m.docs["configuration/ocr"].editUrl).toBe(
|
||||
"https://github.com/Owner/Repo/blob/main/docs/Configuration/OCR.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,481 +0,0 @@
|
||||
/**
|
||||
* Pure transforms that turn the Docusaurus docs repo into the portal docs
|
||||
* manifest. No I/O and no external deps so `tsx` (the sync CLI) and vitest can
|
||||
* both use it. The sync CLI does the fetching; this module does the shaping.
|
||||
*
|
||||
* The auto-sort rules:
|
||||
* - every directory that directly holds markdown becomes a nav section,
|
||||
* labelled + ordered by its `_category_.json` (root files → "Overview"),
|
||||
* - each `.md`/`.mdx` file becomes a nav item, ordered by frontmatter
|
||||
* `sidebar_position` then title,
|
||||
* - Docusaurus MDX is normalised to plain GitHub-flavoured markdown that
|
||||
* react-markdown can render (admonitions, JSX, relative links, images).
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Manifest shape (mirrored structurally by @portal/api/docs) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DocsNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface DocsNavSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
items: DocsNavItem[];
|
||||
}
|
||||
|
||||
export interface DocEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
section: string;
|
||||
markdown: string;
|
||||
sourcePath: string;
|
||||
editUrl: string;
|
||||
}
|
||||
|
||||
export interface DocsManifest {
|
||||
source: { repo: string; ref: string; root: string };
|
||||
nav: DocsNavSection[];
|
||||
docs: Record<string, DocEntry>;
|
||||
}
|
||||
|
||||
/** One markdown file read from the repo, before shaping. */
|
||||
export interface RawDoc {
|
||||
/** Posix path relative to the docs root, e.g. "Configuration/OCR.md". */
|
||||
relPath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** `_category_.json` contents, keyed by posix dir path relative to docs root. */
|
||||
export type CategoryMap = Record<string, { label?: string; position?: number }>;
|
||||
|
||||
export interface BuildOptions {
|
||||
repo: string;
|
||||
ref: string;
|
||||
/** Docs root within the repo, e.g. "docs". */
|
||||
root: string;
|
||||
/** Live docs site base, used as the fallback for unresolved internal links. */
|
||||
siteBaseUrl: string;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Small helpers */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const SECTION_ICONS: Array<[RegExp, string]> = [
|
||||
[/overview|getting started|start/i, "▶"],
|
||||
[/config/i, "⚙"],
|
||||
[/function|tool|feature/i, "▤"],
|
||||
[/install|deploy/i, "⤓"],
|
||||
[/migrat|upgrade/i, "⇄"],
|
||||
[/security|sign|auth/i, "🛡"],
|
||||
[/convert/i, "⇋"],
|
||||
[/page/i, "▦"],
|
||||
[/api|develop/i, "{ }"],
|
||||
];
|
||||
|
||||
/** A single-glyph icon for a section, chosen from its label. */
|
||||
export function sectionIcon(label: string): string {
|
||||
for (const [re, glyph] of SECTION_ICONS) if (re.test(label)) return glyph;
|
||||
return "◇";
|
||||
}
|
||||
|
||||
/** "Getting-Started_Guide" → "Getting Started Guide". */
|
||||
export function humanize(name: string): string {
|
||||
return name
|
||||
.replace(/\.mdx?$/i, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Lowercase, hyphenated, url-safe id for a path segment. */
|
||||
export function slugifySegment(name: string): string {
|
||||
return name
|
||||
.replace(/\.mdx?$/i, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Docs-root-relative posix path → stable doc id, e.g. "configuration/ocr". */
|
||||
export function docIdForPath(relPath: string): string {
|
||||
return relPath.split("/").map(slugifySegment).filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
/** Posix dirname ("" for a root-level file). */
|
||||
export function dirOf(relPath: string): string {
|
||||
const i = relPath.lastIndexOf("/");
|
||||
return i === -1 ? "" : relPath.slice(0, i);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Frontmatter */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface Frontmatter {
|
||||
data: Record<string, string | number>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Split leading `--- ... ---` YAML frontmatter (scalar keys only) from body. */
|
||||
export function parseFrontmatter(content: string): Frontmatter {
|
||||
const normalised = content.replace(/\r\n/g, "\n");
|
||||
if (!normalised.startsWith("---\n")) return { data: {}, body: normalised };
|
||||
const end = normalised.indexOf("\n---", 4);
|
||||
if (end === -1) return { data: {}, body: normalised };
|
||||
const raw = normalised.slice(4, end);
|
||||
const rest = normalised.slice(end + 4).replace(/^\n/, "");
|
||||
const data: Record<string, string | number> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
|
||||
if (!m) continue;
|
||||
let value: string | number = m[2].trim().replace(/^["']|["']$/g, "");
|
||||
if (/^-?\d+(\.\d+)?$/.test(value)) value = Number(value);
|
||||
data[m[1]] = value;
|
||||
}
|
||||
return { data, body: rest };
|
||||
}
|
||||
|
||||
/** First `# H1` heading text in a body, if any. */
|
||||
export function firstHeading(body: string): string | undefined {
|
||||
const m = /^#\s+(.+?)\s*$/m.exec(stripCodeFences(body));
|
||||
return m ? m[1].trim() : undefined;
|
||||
}
|
||||
|
||||
/** Blank out fenced code blocks so heading/link scans ignore their contents. */
|
||||
function stripCodeFences(md: string): string {
|
||||
return md.replace(/^(```|~~~)[\s\S]*?^\1\s*$/gm, "");
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Body normalisation (MDX → plain markdown) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Run `fn` over the non-fenced-code spans of `md`, leaving code blocks intact. */
|
||||
function mapOutsideCode(md: string, fn: (text: string) => string): string {
|
||||
const parts = md.split(/(^(?:```|~~~)[\s\S]*?^(?:```|~~~)\s*$)/gm);
|
||||
return parts.map((part, i) => (i % 2 === 0 ? fn(part) : part)).join("");
|
||||
}
|
||||
|
||||
const ADMONITION_META: Record<string, { icon: string; label: string }> = {
|
||||
tip: { icon: "💡", label: "Tip" },
|
||||
note: { icon: "📝", label: "Note" },
|
||||
info: { icon: "ℹ️", label: "Info" },
|
||||
warning: { icon: "⚠️", label: "Warning" },
|
||||
caution: { icon: "⚠️", label: "Caution" },
|
||||
danger: { icon: "🚫", label: "Danger" },
|
||||
};
|
||||
|
||||
/** `:::tip Title\n…\n:::` → a blockquote with a bold titled first line. */
|
||||
export function convertAdmonitions(md: string): string {
|
||||
const re = /^:::(\w+)[ \t]*(.*)\n([\s\S]*?)^:::[ \t]*$/gm;
|
||||
return md.replace(re, (_all, type: string, title: string, body: string) => {
|
||||
const meta = ADMONITION_META[type.toLowerCase()] ?? {
|
||||
icon: "•",
|
||||
label: humanize(type),
|
||||
};
|
||||
const heading = title.trim()
|
||||
? `${meta.label}: ${title.trim()}`
|
||||
: meta.label;
|
||||
const quoted = body
|
||||
.replace(/\s+$/, "")
|
||||
.split("\n")
|
||||
.map((l) => (l ? `> ${l}` : ">"))
|
||||
.join("\n");
|
||||
return `> **${meta.icon} ${heading}**\n>\n${quoted}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop MDX `import`/`export` statement lines (outside code). */
|
||||
export function stripMdxImports(md: string): string {
|
||||
return md
|
||||
.split("\n")
|
||||
.filter((l) => !/^\s*(import|export)\s.+from\s.+;?\s*$/.test(l))
|
||||
.filter((l) => !/^\s*import\s+['"][^'"]+['"];?\s*$/.test(l))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Remove JSX component tags (Capitalised), keeping any inner content. */
|
||||
export function stripJsxTags(md: string): string {
|
||||
return md.replace(/<\/?[A-Z][A-Za-z0-9.]*(?:\s[^>]*?)?\/?>/g, "");
|
||||
}
|
||||
|
||||
/** Resolve a relative posix path against a base dir, collapsing `.`/`..`. */
|
||||
export function resolveRelative(baseDir: string, target: string): string {
|
||||
const stack = baseDir ? baseDir.split("/") : [];
|
||||
for (const seg of target.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") stack.pop();
|
||||
else stack.push(seg);
|
||||
}
|
||||
return stack.join("/");
|
||||
}
|
||||
|
||||
interface LinkContext {
|
||||
dir: string;
|
||||
pathToId: Map<string, string>;
|
||||
rawBase: string;
|
||||
siteBaseUrl: string;
|
||||
}
|
||||
|
||||
/** Split "path#anchor" → [path, "#anchor" | ""]. */
|
||||
function splitAnchor(target: string): [string, string] {
|
||||
const i = target.indexOf("#");
|
||||
return i === -1 ? [target, ""] : [target.slice(0, i), target.slice(i)];
|
||||
}
|
||||
|
||||
/** Percent-decode a link path, tolerating malformed escapes. */
|
||||
function decodePath(p: string): string {
|
||||
try {
|
||||
return decodeURIComponent(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the doc id for a relative link target. Keys in pathToId are lowercased
|
||||
* so links work whether they use the filename or a slug, in any case, and with
|
||||
* percent-encoded spaces (the repo links to "System%20and%20Security").
|
||||
*/
|
||||
function resolveDocId(ctx: LinkContext, rawPath: string): string | undefined {
|
||||
const resolved = resolveRelative(ctx.dir, decodePath(rawPath)).toLowerCase();
|
||||
const noExt = resolved.replace(/\.mdx?$/i, "");
|
||||
const candidates = [
|
||||
resolved,
|
||||
noExt,
|
||||
`${noExt.replace(/\/$/, "")}/index`,
|
||||
noExt.replace(/\/index$/i, ""),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const id = ctx.pathToId.get(c);
|
||||
if (id) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Rewrite a single markdown link target to a portal-usable href. */
|
||||
function rewriteLinkTarget(ctx: LinkContext, target: string): string {
|
||||
const trimmed = target.trim();
|
||||
if (/^(https?:|mailto:|tel:|#|doc:)/i.test(trimmed)) return trimmed;
|
||||
const [path, anchor] = splitAnchor(trimmed);
|
||||
if (!path) return trimmed;
|
||||
const id = resolveDocId(ctx, path);
|
||||
if (id) return `doc:${id}`;
|
||||
// Unresolved internal link → fall back to the live docs site (encode spaces).
|
||||
const slug = resolveRelative(ctx.dir, decodePath(path)).replace(
|
||||
/\.mdx?$/i,
|
||||
"",
|
||||
);
|
||||
const encoded = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return `${ctx.siteBaseUrl}/${encoded}${anchor}`;
|
||||
}
|
||||
|
||||
/** Rewrite a relative image src to an absolute raw-content URL. */
|
||||
function rewriteImageSrc(ctx: LinkContext, src: string, root: string): string {
|
||||
const trimmed = src.trim();
|
||||
if (/^(https?:|data:)/i.test(trimmed)) return trimmed;
|
||||
if (trimmed.startsWith("/")) return `${ctx.rawBase}/static${trimmed}`;
|
||||
const resolved = resolveRelative(`${root}/${ctx.dir}`, trimmed);
|
||||
return `${ctx.rawBase}/${resolved}`;
|
||||
}
|
||||
|
||||
/** Rewrite markdown links + images (outside code) to portal/absolute targets. */
|
||||
export function rewriteReferences(
|
||||
md: string,
|
||||
ctx: LinkContext,
|
||||
root: string,
|
||||
): string {
|
||||
return mapOutsideCode(md, (text) => {
|
||||
// Images first so their `!` prefix isn't eaten by the link pattern.
|
||||
let out = text.replace(
|
||||
/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g,
|
||||
(_m, alt: string, src: string, tail: string) =>
|
||||
`}${tail})`,
|
||||
);
|
||||
out = out.replace(
|
||||
/(^|[^!])\[([^\]]+)\]\(([^)\s]+)([^)]*)\)/g,
|
||||
(_m, pre: string, label: string, href: string, tail: string) =>
|
||||
`${pre}[${label}](${rewriteLinkTarget(ctx, href)}${tail})`,
|
||||
);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a leading `# H1` whose text equals the page title (avoids a dup head). */
|
||||
export function stripRedundantH1(md: string, title: string): string {
|
||||
const m = /^\s*#\s+(.+?)\s*(\n|$)/.exec(md);
|
||||
if (m && m[1].trim().toLowerCase() === title.trim().toLowerCase()) {
|
||||
return md.slice(m[0].length).replace(/^\n+/, "");
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Demote body `# H1` headings to `## H2` so the page title is the sole H1. */
|
||||
export function demoteHeadings(md: string): string {
|
||||
return mapOutsideCode(md, (text) => text.replace(/^# (?=\S)/gm, "## "));
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Section ordering */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const ROOT_SECTION_ID = "overview";
|
||||
|
||||
/** Composite order key: `_category_.json.position` down the dir path. */
|
||||
function sectionOrderKey(dir: string, categories: CategoryMap): number[] {
|
||||
if (dir === "") return [-1];
|
||||
const key: number[] = [];
|
||||
const segs = dir.split("/");
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const sub = segs.slice(0, i + 1).join("/");
|
||||
key.push(categories[sub]?.position ?? 999);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function compareKeys(a: number[], b: number[]): number {
|
||||
const n = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
||||
if (d !== 0) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Build */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface ShapedDoc extends DocEntry {
|
||||
navLabel: string;
|
||||
sidebarPosition: number;
|
||||
orderKey: number[];
|
||||
sectionLabel: string;
|
||||
}
|
||||
|
||||
/** Turn raw docs + category metadata into the full portal docs manifest. */
|
||||
export function buildManifest(
|
||||
rawDocs: RawDoc[],
|
||||
categories: CategoryMap,
|
||||
opts: BuildOptions,
|
||||
): DocsManifest {
|
||||
const rawBase = `https://raw.githubusercontent.com/${opts.repo}/${opts.ref}`;
|
||||
const editBase = `https://github.com/${opts.repo}/blob/${opts.ref}`;
|
||||
|
||||
// First pass: assign stable ids so links between docs can resolve. Keys are
|
||||
// lowercased (case-insensitive link matching); both with and without ext.
|
||||
const pathToId = new Map<string, string>();
|
||||
for (const doc of rawDocs) {
|
||||
const id = docIdForPath(doc.relPath);
|
||||
const lower = doc.relPath.toLowerCase();
|
||||
pathToId.set(lower, id);
|
||||
pathToId.set(lower.replace(/\.mdx?$/i, ""), id);
|
||||
}
|
||||
|
||||
const shaped: ShapedDoc[] = rawDocs.map((doc) => {
|
||||
const dir = dirOf(doc.relPath);
|
||||
const { data, body } = parseFrontmatter(doc.content);
|
||||
const title =
|
||||
(typeof data.title === "string" && data.title) ||
|
||||
firstHeading(body) ||
|
||||
humanize(doc.relPath.split("/").pop() ?? doc.relPath);
|
||||
const navLabel =
|
||||
(typeof data.sidebar_label === "string" && data.sidebar_label) || title;
|
||||
const description =
|
||||
typeof data.description === "string" ? data.description : undefined;
|
||||
|
||||
const ctx: LinkContext = {
|
||||
dir,
|
||||
pathToId,
|
||||
rawBase,
|
||||
siteBaseUrl: opts.siteBaseUrl.replace(/\/$/, ""),
|
||||
};
|
||||
let markdown = body;
|
||||
markdown = stripMdxImports(markdown);
|
||||
markdown = convertAdmonitions(markdown);
|
||||
markdown = stripJsxTags(markdown);
|
||||
markdown = rewriteReferences(markdown, ctx, opts.root);
|
||||
markdown = stripRedundantH1(markdown, title);
|
||||
markdown = demoteHeadings(markdown).trim();
|
||||
|
||||
const sectionId = dir === "" ? ROOT_SECTION_ID : docIdForPath(dir);
|
||||
const sectionLabel =
|
||||
dir === ""
|
||||
? "Overview"
|
||||
: (categories[dir]?.label ?? humanize(dir.split("/").pop() ?? dir));
|
||||
|
||||
return {
|
||||
id: docIdForPath(doc.relPath),
|
||||
title,
|
||||
navLabel,
|
||||
description,
|
||||
section: sectionId,
|
||||
markdown,
|
||||
sourcePath: `${opts.root}/${doc.relPath}`,
|
||||
editUrl: `${editBase}/${opts.root}/${doc.relPath}`,
|
||||
sidebarPosition:
|
||||
typeof data.sidebar_position === "number" ? data.sidebar_position : 999,
|
||||
orderKey: sectionOrderKey(dir, categories),
|
||||
sectionLabel,
|
||||
};
|
||||
});
|
||||
|
||||
// Group into sections and order everything deterministically.
|
||||
const bySection = new Map<string, ShapedDoc[]>();
|
||||
for (const doc of shaped) {
|
||||
const list = bySection.get(doc.section) ?? [];
|
||||
list.push(doc);
|
||||
bySection.set(doc.section, list);
|
||||
}
|
||||
|
||||
const nav: DocsNavSection[] = [...bySection.entries()]
|
||||
.map(([id, docs]) => {
|
||||
const first = docs[0];
|
||||
const items = [...docs]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.sidebarPosition - b.sidebarPosition ||
|
||||
a.navLabel.localeCompare(b.navLabel),
|
||||
)
|
||||
.map<DocsNavItem>((d) => ({ id: d.id, label: d.navLabel }));
|
||||
return {
|
||||
id,
|
||||
label: first.sectionLabel,
|
||||
icon: sectionIcon(first.sectionLabel),
|
||||
items,
|
||||
_key: first.orderKey,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) => compareKeys(a._key, b._key) || a.label.localeCompare(b.label),
|
||||
)
|
||||
.map(({ _key, ...section }) => section);
|
||||
|
||||
const docs: Record<string, DocEntry> = {};
|
||||
for (const d of shaped) {
|
||||
docs[d.id] = {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
section: d.section,
|
||||
markdown: d.markdown,
|
||||
sourcePath: d.sourcePath,
|
||||
editUrl: d.editUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: { repo: opts.repo, ref: opts.ref, root: opts.root },
|
||||
nav,
|
||||
docs,
|
||||
};
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSnippet,
|
||||
highlight,
|
||||
searchDocs,
|
||||
toPlainText,
|
||||
type SearchDoc,
|
||||
} from "@portal/docs/search";
|
||||
|
||||
const DOCS: SearchDoc[] = [
|
||||
{
|
||||
id: "ocr",
|
||||
title: "OCR Guide",
|
||||
sectionLabel: "Configuration",
|
||||
text: "Stirling PDF uses Tesseract for its text recognition and language packs.",
|
||||
},
|
||||
{
|
||||
id: "docker",
|
||||
title: "Docker Install",
|
||||
sectionLabel: "Installation",
|
||||
text: "Run Stirling with docker compose up to start the container.",
|
||||
},
|
||||
{
|
||||
id: "ranky",
|
||||
title: "Something else",
|
||||
sectionLabel: "Misc",
|
||||
text: "docker docker docker appears many times in the body here.",
|
||||
},
|
||||
];
|
||||
|
||||
describe("toPlainText", () => {
|
||||
it("strips headings, links, inline code, and code fences", () => {
|
||||
const md =
|
||||
"# Title\n\nSee [the guide](doc:x) and run `npm i`.\n\n```bash\nnpm run build\n```";
|
||||
const out = toPlainText(md);
|
||||
expect(out).toContain("Title");
|
||||
expect(out).toContain("the guide");
|
||||
expect(out).toContain("npm i");
|
||||
expect(out).toContain("npm run build"); // code text kept, fences dropped
|
||||
expect(out).not.toContain("#");
|
||||
expect(out).not.toContain("```");
|
||||
expect(out).not.toContain("](");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlight", () => {
|
||||
it("splits text into hit/non-hit segments", () => {
|
||||
expect(highlight("Hello world", ["world"])).toEqual([
|
||||
{ text: "Hello ", hit: false },
|
||||
{ text: "world", hit: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is safe against regex metacharacters in terms", () => {
|
||||
expect(() => highlight("a (b) c", ["("])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSnippet", () => {
|
||||
it("windows around the first match and marks it", () => {
|
||||
const text =
|
||||
"lorem ipsum ".repeat(20) + "the TARGET keyword " + "dolor ".repeat(20);
|
||||
const segs = buildSnippet(text, ["target"]);
|
||||
expect(segs.some((s) => s.hit && /target/i.test(s.text))).toBe(true);
|
||||
// Windowed, so it should be far shorter than the full text.
|
||||
expect(segs.map((s) => s.text).join("").length).toBeLessThan(text.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchDocs", () => {
|
||||
it("returns nothing for an empty query", () => {
|
||||
expect(searchDocs(DOCS, " ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches body content, not just titles (with a snippet)", () => {
|
||||
const res = searchDocs(DOCS, "tesseract");
|
||||
expect(res.map((r) => r.id)).toEqual(["ocr"]);
|
||||
expect(res[0].snippet.some((s) => s.hit && /tesseract/i.test(s.text))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ranks title matches above body matches", () => {
|
||||
const res = searchDocs(DOCS, "docker");
|
||||
// "Docker Install" (title hit) outranks "Something else" (body-only hits).
|
||||
expect(res[0].id).toBe("docker");
|
||||
expect(res.map((r) => r.id)).toContain("ranky");
|
||||
});
|
||||
|
||||
it("requires every term to match (AND)", () => {
|
||||
expect(searchDocs(DOCS, "docker compose").map((r) => r.id)).toEqual([
|
||||
"docker",
|
||||
]);
|
||||
expect(searchDocs(DOCS, "docker tesseract")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not throw on regex-special-character queries", () => {
|
||||
expect(() => searchDocs(DOCS, "a(b")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* Full-text search over the docs manifest. Pure + dependency-free so it's unit
|
||||
* testable. Indexes each doc's title + plaintext body; ranks title matches above
|
||||
* body matches; returns highlighted title + a content snippet per hit.
|
||||
*/
|
||||
|
||||
export interface SearchDoc {
|
||||
id: string;
|
||||
title: string;
|
||||
sectionLabel: string;
|
||||
/** Plaintext body (markdown stripped), original case. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A run of result text, flagged when it matches a query term (for <mark>). */
|
||||
export interface Segment {
|
||||
text: string;
|
||||
hit: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
sectionLabel: string;
|
||||
titleSegments: Segment[];
|
||||
snippet: Segment[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Strip markdown/MDX down to readable plaintext for indexing + snippets. */
|
||||
export function toPlainText(md: string): string {
|
||||
return md
|
||||
.replace(/^(```|~~~).*$/gm, " ") // fence delimiters (keep the code text)
|
||||
.replace(/`([^`]+)`/g, "$1") // inline code
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → their text
|
||||
.replace(/^\s{0,3}>\s?/gm, "") // blockquotes
|
||||
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // headings
|
||||
.replace(/^\s*[-*+]\s+/gm, "") // list bullets
|
||||
.replace(/[*_~]/g, "") // emphasis marks
|
||||
.replace(/\|/g, " ") // table pipes
|
||||
.replace(/\s+/g, " ") // collapse whitespace
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
let count = 0;
|
||||
let i = haystack.indexOf(needle);
|
||||
while (i !== -1) {
|
||||
count++;
|
||||
i = haystack.indexOf(needle, i + needle.length);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Split `text` into segments, flagging any run that matches a query term. */
|
||||
export function highlight(text: string, terms: string[]): Segment[] {
|
||||
const cleaned = terms.map(escapeRegExp).filter(Boolean);
|
||||
if (!cleaned.length) return [{ text, hit: false }];
|
||||
const re = new RegExp(`(${cleaned.join("|")})`, "gi");
|
||||
const segments: Segment[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
segments.push({ text: text.slice(last, m.index), hit: false });
|
||||
}
|
||||
segments.push({ text: m[0], hit: true });
|
||||
last = m.index + m[0].length;
|
||||
if (m.index === re.lastIndex) re.lastIndex++; // guard against zero-width
|
||||
}
|
||||
if (last < text.length) segments.push({ text: text.slice(last), hit: false });
|
||||
return segments.length ? segments : [{ text, hit: false }];
|
||||
}
|
||||
|
||||
/** Build a ~context-window snippet around the earliest term match. */
|
||||
export function buildSnippet(
|
||||
text: string,
|
||||
terms: string[],
|
||||
radius = 90,
|
||||
): Segment[] {
|
||||
const lower = text.toLowerCase();
|
||||
let pos = -1;
|
||||
for (const term of terms) {
|
||||
const i = lower.indexOf(term);
|
||||
if (i !== -1 && (pos === -1 || i < pos)) pos = i;
|
||||
}
|
||||
if (pos === -1) {
|
||||
const head = text.slice(0, radius * 2);
|
||||
return highlight(head + (text.length > head.length ? "…" : ""), terms);
|
||||
}
|
||||
let start = Math.max(0, pos - radius);
|
||||
let end = Math.min(text.length, pos + radius);
|
||||
// Snap to word boundaries so we don't slice mid-word.
|
||||
if (start > 0) {
|
||||
const space = text.indexOf(" ", start);
|
||||
if (space !== -1 && space < pos) start = space + 1;
|
||||
}
|
||||
if (end < text.length) {
|
||||
const space = text.lastIndexOf(" ", end);
|
||||
if (space > pos) end = space;
|
||||
}
|
||||
let snippet = text.slice(start, end).trim();
|
||||
if (start > 0) snippet = "…" + snippet;
|
||||
if (end < text.length) snippet = snippet + "…";
|
||||
return highlight(snippet, terms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank docs against a query. A doc matches when every term appears in its title
|
||||
* or body; title hits score highest.
|
||||
*/
|
||||
export function searchDocs(
|
||||
docs: SearchDoc[],
|
||||
query: string,
|
||||
limit = 40,
|
||||
): SearchResult[] {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!terms.length) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
for (const doc of docs) {
|
||||
const titleLower = doc.title.toLowerCase();
|
||||
const textLower = doc.text.toLowerCase();
|
||||
const matchesAll = terms.every(
|
||||
(term) => titleLower.includes(term) || textLower.includes(term),
|
||||
);
|
||||
if (!matchesAll) continue;
|
||||
|
||||
let score = 0;
|
||||
for (const term of terms) {
|
||||
if (titleLower.includes(term)) score += 10;
|
||||
if (titleLower.startsWith(term)) score += 5;
|
||||
score += Math.min(countOccurrences(textLower, term), 5);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
sectionLabel: doc.sectionLabel,
|
||||
titleSegments: highlight(doc.title, terms),
|
||||
snippet: buildSnippet(doc.text, terms),
|
||||
score,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -13,6 +13,7 @@ import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas";
|
||||
import { agentsHandlers } from "@portal/mocks/handlers/agents";
|
||||
import { policiesHandlers } from "@portal/mocks/handlers/policies";
|
||||
import { documentsHandlers } from "@portal/mocks/handlers/documents";
|
||||
import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents";
|
||||
import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy";
|
||||
import { linkHandlers } from "@portal/mocks/handlers/link";
|
||||
|
||||
@@ -32,6 +33,7 @@ export const handlers = [
|
||||
...agentsHandlers,
|
||||
...policiesHandlers,
|
||||
...documentsHandlers,
|
||||
...sdkComponentsHandlers,
|
||||
...editorDeployHandlers,
|
||||
...linkHandlers,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { buildComponentsResponse } from "@portal/mocks/sdkComponents";
|
||||
|
||||
export const sdkComponentsHandlers = [
|
||||
http.get("/v1/components", async ({ request }) => {
|
||||
await delay(120);
|
||||
const url = new URL(request.url);
|
||||
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
|
||||
return HttpResponse.json(buildComponentsResponse(tier));
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Components surface fixtures. Types and presentation metadata live in
|
||||
* api/sdkComponents.ts (the backend contract); this module only builds fake
|
||||
* data for Storybook and tests.
|
||||
*
|
||||
* "Components" are embeddable React/Vue/Vanilla SDK widgets a developer drops
|
||||
* into their own product — a PDF Viewer, an E-Sign flow, an AI Review panel —
|
||||
* each metered per action (per render, per review, per signature). Every
|
||||
* component carries its npm package, maturity, supported frameworks, per-action
|
||||
* price, an install/usage snippet, and its key props.
|
||||
*/
|
||||
|
||||
import { isUnlocked } from "@portal/api/sdkComponents";
|
||||
import type {
|
||||
ComponentPricing,
|
||||
ComponentsResponse,
|
||||
ComponentsSummary,
|
||||
SdkComponent,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Fixtures */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The catalogue. Authored once, then filtered/repriced per tier so a dev can
|
||||
* see tier variation (locked Beta components on free, enterprise discounts).
|
||||
*/
|
||||
const CATALOGUE: SdkComponent[] = [
|
||||
{
|
||||
id: "viewer",
|
||||
name: "Viewer",
|
||||
package: "viewer",
|
||||
description:
|
||||
"Drop-in PDF viewer with text selection, search, thumbnails and lazy page rendering. The cheapest, highest-volume primitive.",
|
||||
maturity: "ga",
|
||||
frameworks: ["React", "Vue", "Vanilla"],
|
||||
pricing: { pricePerAction: 0.01, unit: "render", freeQuota: 1000 },
|
||||
install: "npm install @stirling/viewer",
|
||||
usage: `import { Viewer } from "@stirling/viewer";
|
||||
|
||||
export function DocumentPage({ fileUrl }: { fileUrl: string }) {
|
||||
return (
|
||||
<Viewer
|
||||
src={fileUrl}
|
||||
apiKey={process.env.STIRLING_KEY}
|
||||
onRender={(p) => console.log("rendered page", p.page)}
|
||||
/>
|
||||
);
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "src",
|
||||
type: "string | Blob",
|
||||
required: true,
|
||||
description: "Document URL or in-memory blob to render.",
|
||||
},
|
||||
{
|
||||
name: "apiKey",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Publishable key used to meter renders.",
|
||||
},
|
||||
{
|
||||
name: "initialPage",
|
||||
type: "number",
|
||||
required: false,
|
||||
description: "Page to open on first render. Defaults to 1.",
|
||||
},
|
||||
{
|
||||
name: "onRender",
|
||||
type: "(e: RenderEvent) => void",
|
||||
required: false,
|
||||
description: "Fires per rendered page — one billed render each.",
|
||||
},
|
||||
],
|
||||
embeds30d: 184200,
|
||||
minTier: "pro",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
name: "Review",
|
||||
package: "review",
|
||||
description:
|
||||
"AI-assisted document review panel — surfaces extracted fields, risks and suggested redactions for a human to accept or reject.",
|
||||
maturity: "ga",
|
||||
frameworks: ["React", "Vue"],
|
||||
pricing: { pricePerAction: 0.04, unit: "review", freeQuota: 50 },
|
||||
install: "npm install @stirling/review",
|
||||
usage: `import { Review } from "@stirling/review";
|
||||
|
||||
export function ReviewPane({ docId }: { docId: string }) {
|
||||
return (
|
||||
<Review
|
||||
documentId={docId}
|
||||
pipeline="invoice-v3"
|
||||
apiKey={process.env.STIRLING_KEY}
|
||||
onComplete={(r) => save(r.fields)}
|
||||
/>
|
||||
);
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "documentId",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Id of an ingested document to review.",
|
||||
},
|
||||
{
|
||||
name: "pipeline",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Pipeline whose extraction schema drives the panel.",
|
||||
},
|
||||
{
|
||||
name: "onComplete",
|
||||
type: "(r: ReviewResult) => void",
|
||||
required: false,
|
||||
description: "Called when the reviewer submits — bills one review.",
|
||||
},
|
||||
],
|
||||
embeds30d: 42800,
|
||||
minTier: "pro",
|
||||
},
|
||||
{
|
||||
id: "confidence",
|
||||
name: "Confidence",
|
||||
package: "confidence",
|
||||
description:
|
||||
"Inline confidence overlay — paints per-field certainty heatmaps over extracted values so reviewers triage low-confidence fields first.",
|
||||
maturity: "ga",
|
||||
frameworks: ["React", "Vue", "Vanilla"],
|
||||
pricing: { pricePerAction: 0.02, unit: "check", freeQuota: 200 },
|
||||
install: "npm install @stirling/confidence",
|
||||
usage: `import { Confidence } from "@stirling/confidence";
|
||||
|
||||
export function FieldOverlay({ result }: { result: ExtractResult }) {
|
||||
return <Confidence fields={result.fields} threshold={0.85} />;
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "fields",
|
||||
type: "ExtractedField[]",
|
||||
required: true,
|
||||
description: "Fields with per-value confidence scores.",
|
||||
},
|
||||
{
|
||||
name: "threshold",
|
||||
type: "number",
|
||||
required: false,
|
||||
description: "Highlight fields below this 0..1 score. Defaults to 0.8.",
|
||||
},
|
||||
],
|
||||
embeds30d: 31500,
|
||||
minTier: "pro",
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
name: "Approval",
|
||||
package: "approval",
|
||||
description:
|
||||
"Routable approval flow — assign reviewers, collect sign-offs and enforce approval order before a document advances.",
|
||||
maturity: "ga",
|
||||
frameworks: ["React", "Vue"],
|
||||
pricing: { pricePerAction: 0.1, unit: "approval", freeQuota: 0 },
|
||||
install: "npm install @stirling/approval",
|
||||
usage: `import { Approval } from "@stirling/approval";
|
||||
|
||||
export function ApprovalGate({ docId }: { docId: string }) {
|
||||
return (
|
||||
<Approval
|
||||
documentId={docId}
|
||||
approvers={["ap@acme.com", "controller@acme.com"]}
|
||||
order="sequential"
|
||||
onApproved={() => advance(docId)}
|
||||
/>
|
||||
);
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "documentId",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Document the approval gate guards.",
|
||||
},
|
||||
{
|
||||
name: "approvers",
|
||||
type: "string[]",
|
||||
required: true,
|
||||
description: "Ordered list of approver email addresses.",
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
type: '"sequential" | "parallel"',
|
||||
required: false,
|
||||
description: "Routing mode. Defaults to sequential.",
|
||||
},
|
||||
],
|
||||
embeds30d: 9600,
|
||||
minTier: "pro",
|
||||
},
|
||||
{
|
||||
id: "esign",
|
||||
name: "E-Sign",
|
||||
package: "esign",
|
||||
description:
|
||||
"Legally-binding e-signature ceremony — signer fields, audit-grade consent capture and a tamper-evident completion certificate.",
|
||||
maturity: "ga",
|
||||
frameworks: ["React", "Vue", "Vanilla"],
|
||||
pricing: { pricePerAction: 0.35, unit: "signature", freeQuota: 0 },
|
||||
install: "npm install @stirling/esign",
|
||||
usage: `import { ESign } from "@stirling/esign";
|
||||
|
||||
export function SignFlow({ docId }: { docId: string }) {
|
||||
return (
|
||||
<ESign
|
||||
documentId={docId}
|
||||
signers={[{ email: "client@acme.com", role: "signer" }]}
|
||||
onSigned={(cert) => store(cert.certificateUrl)}
|
||||
/>
|
||||
);
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "documentId",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Document to be signed.",
|
||||
},
|
||||
{
|
||||
name: "signers",
|
||||
type: "Signer[]",
|
||||
required: true,
|
||||
description: "Ordered signers with email + role.",
|
||||
},
|
||||
{
|
||||
name: "onSigned",
|
||||
type: "(c: Certificate) => void",
|
||||
required: false,
|
||||
description: "Fires on completion — bills one signature per signer.",
|
||||
},
|
||||
],
|
||||
embeds30d: 5400,
|
||||
minTier: "pro",
|
||||
},
|
||||
{
|
||||
id: "audit-trail",
|
||||
name: "Audit Trail",
|
||||
package: "audit-trail",
|
||||
description:
|
||||
"Read-only event timeline for a document — every render, edit, approval and signature, exportable as a compliance-grade ledger.",
|
||||
maturity: "ga",
|
||||
frameworks: ["React", "Vue"],
|
||||
pricing: { pricePerAction: 0.01, unit: "event", freeQuota: 500 },
|
||||
install: "npm install @stirling/audit-trail",
|
||||
usage: `import { AuditTrail } from "@stirling/audit-trail";
|
||||
|
||||
export function History({ docId }: { docId: string }) {
|
||||
return <AuditTrail documentId={docId} export="pdf" />;
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "documentId",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Document whose events are shown.",
|
||||
},
|
||||
{
|
||||
name: "export",
|
||||
type: '"pdf" | "csv" | false',
|
||||
required: false,
|
||||
description: "Enable a ledger export button. Defaults to false.",
|
||||
},
|
||||
],
|
||||
embeds30d: 14200,
|
||||
minTier: "pro",
|
||||
},
|
||||
{
|
||||
id: "markup",
|
||||
name: "Markup",
|
||||
package: "markup",
|
||||
description:
|
||||
"Collaborative annotation layer — highlights, comments, stamps and freehand drawing synced live across reviewers.",
|
||||
maturity: "beta",
|
||||
frameworks: ["React", "Vue"],
|
||||
pricing: { pricePerAction: 0.03, unit: "session", freeQuota: 25 },
|
||||
install: "npm install @stirling/markup@beta",
|
||||
usage: `import { Markup } from "@stirling/markup";
|
||||
|
||||
export function AnnotateLayer({ docId }: { docId: string }) {
|
||||
return (
|
||||
<Markup
|
||||
documentId={docId}
|
||||
collaborative
|
||||
tools={["highlight", "comment", "stamp"]}
|
||||
/>
|
||||
);
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "documentId",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Document to annotate.",
|
||||
},
|
||||
{
|
||||
name: "collaborative",
|
||||
type: "boolean",
|
||||
required: false,
|
||||
description: "Enable live multi-user sync. Defaults to false.",
|
||||
},
|
||||
{
|
||||
name: "tools",
|
||||
type: "MarkupTool[]",
|
||||
required: false,
|
||||
description: "Visible annotation tools. Defaults to all.",
|
||||
},
|
||||
],
|
||||
embeds30d: 2100,
|
||||
minTier: "pro",
|
||||
},
|
||||
{
|
||||
id: "toolkit",
|
||||
name: "Toolkit",
|
||||
package: "toolkit",
|
||||
description:
|
||||
"Headless operations toolkit — merge, split, redact, compress and convert exposed as composable hooks with no UI of its own.",
|
||||
maturity: "beta",
|
||||
frameworks: ["React", "Vue", "Vanilla"],
|
||||
pricing: { pricePerAction: 0.05, unit: "event", freeQuota: 0 },
|
||||
install: "npm install @stirling/toolkit@beta",
|
||||
usage: `import { useToolkit } from "@stirling/toolkit";
|
||||
|
||||
export function MergeButton({ files }: { files: File[] }) {
|
||||
const { merge } = useToolkit({ apiKey: process.env.STIRLING_KEY });
|
||||
return <button onClick={() => merge(files)}>Merge</button>;
|
||||
}`,
|
||||
props: [
|
||||
{
|
||||
name: "apiKey",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Publishable key used to meter operations.",
|
||||
},
|
||||
{
|
||||
name: "operations",
|
||||
type: "Operation[]",
|
||||
required: false,
|
||||
description: "Restrict the toolkit to a subset of operations.",
|
||||
},
|
||||
],
|
||||
// Beta + enterprise-only: still gated to design partners, never embedded
|
||||
// on lower tiers.
|
||||
embeds30d: 0,
|
||||
minTier: "enterprise",
|
||||
},
|
||||
];
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tier shaping */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Enterprise negotiates volume pricing — renders and reviews come in cheaper.
|
||||
* Applied as a flat per-tier multiplier so the catalogue stays single-sourced.
|
||||
*/
|
||||
function priceFor(component: SdkComponent, tier: Tier): ComponentPricing {
|
||||
if (tier !== "enterprise") return component.pricing;
|
||||
return {
|
||||
...component.pricing,
|
||||
pricePerAction: Number((component.pricing.pricePerAction * 0.8).toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue for a tier. Every component is returned at every tier so the
|
||||
* grid is browsable — the view locks the ones above the tier and shows an
|
||||
* upgrade nudge. Pricing is re-stamped per tier.
|
||||
*/
|
||||
export function componentsFor(tier: Tier): SdkComponent[] {
|
||||
return CATALOGUE.map((c) => ({ ...c, pricing: priceFor(c, tier) }));
|
||||
}
|
||||
|
||||
export function summaryFor(tier: Tier): ComponentsSummary {
|
||||
const components = componentsFor(tier);
|
||||
const unlocked = components.filter((c) => isUnlocked(c, tier));
|
||||
const gaCount = unlocked.filter((c) => c.maturity === "ga").length;
|
||||
const betaCount = unlocked.filter((c) => c.maturity === "beta").length;
|
||||
|
||||
if (tier === "free") {
|
||||
// Free hasn't embedded anything yet — the strip reads as a cold start.
|
||||
return { gaCount, betaCount, embedsThisMonth: 0, spendThisMonth: 0 };
|
||||
}
|
||||
|
||||
const embedsThisMonth = unlocked.reduce((sum, c) => sum + c.embeds30d, 0);
|
||||
// Spend = embeds beyond the free quota, billed at the tier price.
|
||||
const spendThisMonth = unlocked.reduce((sum, c) => {
|
||||
const billable = Math.max(0, c.embeds30d - c.pricing.freeQuota);
|
||||
return sum + billable * c.pricing.pricePerAction;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
gaCount,
|
||||
betaCount,
|
||||
embedsThisMonth,
|
||||
spendThisMonth: Number(spendThisMonth.toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildComponentsResponse(tier: Tier): ComponentsResponse {
|
||||
return { summary: summaryFor(tier), components: componentsFor(tier) };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
.portal-components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 84rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.portal-components__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-components__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-components__sub {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Catalogue grid */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-components__grid,
|
||||
.portal-components__grid-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-components__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Locked cards read as available-but-gated: dimmed, never disabled. */
|
||||
.portal-components__card.is-locked {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.portal-components__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-components__card-name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-components__lock {
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.portal-components__card-desc {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.portal-components__card-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-components__price {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-components__pkg {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-components__frameworks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Detail modal */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-components__modal-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-components__modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Live-preview sandbox placeholder */
|
||||
.portal-components__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
min-height: 9rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px dashed var(--color-border-strong, var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--color-bg-subtle),
|
||||
var(--color-bg-subtle) 10px,
|
||||
var(--color-surface) 10px,
|
||||
var(--color-surface) 20px
|
||||
);
|
||||
}
|
||||
|
||||
.portal-components__preview-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-blue);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-components__preview-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-components__tabs {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.portal-components__tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.portal-components__overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-components__overview-desc {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-components__code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-components__stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.portal-components__stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.portal-components__pricing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-components__pricing-note {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Props table cells */
|
||||
.portal-components__prop-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-components__prop-type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-components__prop-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-components__muted {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, EmptyState, Skeleton } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchComponents,
|
||||
isUnlocked,
|
||||
type ComponentsResponse,
|
||||
type SdkComponent,
|
||||
} from "@portal/api/sdkComponents";
|
||||
import { ComponentsSummaryStrip } from "@portal/components/catalogue/ComponentsSummaryStrip";
|
||||
import { ComponentGrid } from "@portal/components/catalogue/ComponentGrid";
|
||||
import { ComponentDetailModal } from "@portal/components/catalogue/ComponentDetailModal";
|
||||
import "@portal/views/Components.css";
|
||||
|
||||
export function Components() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<ComponentsResponse>(
|
||||
() => fetchComponents(tier),
|
||||
[tier],
|
||||
);
|
||||
const { data, loading } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const components = data?.components ?? [];
|
||||
const open = components.find((c) => c.id === openId) ?? null;
|
||||
const hasLocked = components.some((c) => !isUnlocked(c, tier));
|
||||
|
||||
function handleOpen(component: SdkComponent) {
|
||||
setOpenId(component.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-components">
|
||||
<header className="portal-components__head">
|
||||
<div>
|
||||
<h1 className="portal-components__title">
|
||||
{t("portal.componentsView.title")}
|
||||
</h1>
|
||||
<p className="portal-components__sub">
|
||||
{t("portal.componentsView.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ComponentsSummaryStrip data={data} loading={loading} />
|
||||
|
||||
{tier === "free" && hasLocked && (
|
||||
<Banner
|
||||
tone="info"
|
||||
title={t("portal.componentsView.lockedBanner.title")}
|
||||
description={t("portal.componentsView.lockedBanner.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-components__grid-skeleton" aria-hidden>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height="11rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title={t("portal.componentsView.empty.title")}
|
||||
description={t("portal.componentsView.empty.description")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isEmpty && components.length > 0 && (
|
||||
<ComponentGrid
|
||||
components={components}
|
||||
tier={tier}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ComponentDetailModal
|
||||
component={open}
|
||||
unlocked={open ? isUnlocked(open, tier) : false}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +1,73 @@
|
||||
/* Full-height docs surface: the sidebar and reading pane each scroll on their
|
||||
own inside the portal shell's scroll container, so a 70+ item nav no longer
|
||||
drags the whole page around. */
|
||||
.portal-docs {
|
||||
display: grid;
|
||||
grid-template-columns: 16rem minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 84rem;
|
||||
margin: 0 auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* With an "On this page" TOC, add a right-hand column. */
|
||||
.portal-docs--with-toc {
|
||||
grid-template-columns: 16rem minmax(0, 1fr) 15rem;
|
||||
}
|
||||
|
||||
/* Not enough room for three columns → drop the TOC. */
|
||||
@media (max-width: 72rem) {
|
||||
.portal-docs--with-toc {
|
||||
grid-template-columns: 16rem minmax(0, 1fr);
|
||||
}
|
||||
.portal-docs__toc-col {
|
||||
display: none;
|
||||
@media (max-width: 60rem) {
|
||||
.portal-docs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-docs--empty {
|
||||
display: block;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Left nav (own scroll column) ──────────────────────────────────────── */
|
||||
/* ── Left nav ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__sidebar {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
padding: 1.25rem 0;
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.portal-docs__sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-docs__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.portal-docs__search {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.portal-docs__search-icon {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.portal-docs__search-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem 0.4rem 1.9rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.portal-docs__search-input:focus {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-docs__nav-empty {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Search results ────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__results-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-4);
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__results-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hairline divider between results for clear, calm separation. */
|
||||
.portal-docs__results-list li + li {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__result {
|
||||
height: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.portal-docs__result:hover,
|
||||
.portal-docs__result.is-active {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-docs__result-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Title + section share one line; the title truncates before the section. */
|
||||
.portal-docs__result-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-docs__result-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.portal-docs__result-section {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-docs__result-snippet {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-4);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle match emphasis — coloured text, not a filled block. */
|
||||
.portal-docs__hl {
|
||||
color: var(--color-blue);
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.portal-docs__nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Section label: shared typography for the static Overview label and the
|
||||
collapsible accordion headers below. */
|
||||
.portal-docs__nav-head {
|
||||
padding: 0.4rem 0.75rem 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-4);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Collapsible section header (Overview stays a static label). */
|
||||
.portal-docs__nav-head--button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-docs__nav-head-main {
|
||||
display: flex;
|
||||
.portal-docs__nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-docs__nav-headlabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-docs__nav-chevron {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-4);
|
||||
transition: transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-docs__nav-chevron.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.portal-docs__nav-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
justify-content: center;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
font-size: 0.6875rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 0.05rem 0.4rem;
|
||||
}
|
||||
|
||||
/* Space consecutive sections apart (not before the first). */
|
||||
.portal-docs__nav-group + .portal-docs__nav-group {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Nested sub-sections: indented under their parent with a guide line. */
|
||||
.portal-docs__nav-children {
|
||||
margin: 0.125rem 0 0.25rem 0.85rem;
|
||||
padding-left: 0.4rem;
|
||||
border-left: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-docs__nav-list {
|
||||
list-style: none;
|
||||
margin: 0 0 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -261,10 +80,10 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
@@ -291,129 +110,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mobile-only nav toggle (a drawer button); hidden on desktop. */
|
||||
.portal-docs__nav-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Reading pane (own scroll column) ──────────────────────────────────── */
|
||||
/* ── Content shell ─────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
}
|
||||
|
||||
.portal-docs__content-inner {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── On this page (right rail) ─────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__toc-col {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--color-border-light);
|
||||
padding: 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.portal-docs__toc-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-4);
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc-link {
|
||||
display: block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-3);
|
||||
text-decoration: none;
|
||||
transition: color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-docs__toc-link.is-sub {
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc-link:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-docs__toc-link.is-active {
|
||||
color: var(--color-blue);
|
||||
border-left-color: var(--color-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Scroll-to-heading lands just below the pane top, not flush against it. */
|
||||
.portal-docs__md h2,
|
||||
.portal-docs__md h3 {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ── Small screens: nav collapses into a drawer above the content ──────── */
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.portal-docs {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.portal-docs__nav-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 1rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-docs__sidebar {
|
||||
display: none;
|
||||
height: auto;
|
||||
max-height: 60vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__sidebar.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.portal-docs__content {
|
||||
padding: 1.25rem 1.25rem 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-docs__section {
|
||||
@@ -823,160 +523,3 @@
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ── Markdown content ──────────────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__md {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-docs__md h1,
|
||||
.portal-docs__md h2,
|
||||
.portal-docs__md h3,
|
||||
.portal-docs__md h4 {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
margin: 1.75rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__md h2 {
|
||||
font-size: 1.3125rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__md h3 {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.portal-docs__md h4 {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.portal-docs__md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.portal-docs__md p,
|
||||
.portal-docs__md ul,
|
||||
.portal-docs__md ol {
|
||||
margin: 0 0 0.875rem;
|
||||
}
|
||||
|
||||
.portal-docs__md li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.portal-docs__md a {
|
||||
color: var(--color-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-docs__md a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.portal-docs__md code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85em;
|
||||
background: var(--color-bg-subtle);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.portal-docs__md blockquote {
|
||||
margin: 0 0 0.875rem;
|
||||
padding: 0.125rem 0.875rem;
|
||||
border-left: 3px solid var(--color-blue);
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-docs__md blockquote p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.portal-docs__md-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__md hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.portal-docs__md-pre {
|
||||
position: relative;
|
||||
margin: 0 0 0.875rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-pre pre {
|
||||
margin: 0;
|
||||
padding: 0.875rem 4rem 0.875rem 0.875rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.portal-docs__md-pre pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.portal-docs__md-copy {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap {
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.875rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap th,
|
||||
.portal-docs__md-tablewrap td {
|
||||
border: 1px solid var(--color-border-light);
|
||||
padding: 0.4rem 0.625rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap th {
|
||||
background: var(--color-bg-subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-docs__source {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-docs__source a {
|
||||
color: var(--color-text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-docs__source a:hover {
|
||||
color: var(--color-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
|
||||
// Renders the real docs manifest (generated by scripts/sync-portal-docs.mts), so
|
||||
// this story is a live view of the auto-built Documentation browser. The view
|
||||
// fills its container's height (independent sidebar/content scroll), so the
|
||||
// decorator gives it a viewport-height frame.
|
||||
const meta: Meta<typeof DeveloperDocs> = {
|
||||
title: "Portal/DeveloperDocs/View",
|
||||
component: DeveloperDocs,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DeveloperDocs>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
// Deterministic labels; the view + DocsNav + MarkdownDoc all use useTranslation.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// 2nd arg may be a default string or i18next interpolation options ({count}).
|
||||
t: (key: string, opts?: unknown) => (typeof opts === "string" ? opts : key),
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
|
||||
const renderDocs = (ui: ReactElement) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/processor/docs"]}>
|
||||
<MantineProvider>{ui}</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe("DeveloperDocs — markdown browser over the generated manifest", () => {
|
||||
it("keeps Overview static (open, no toggle) and other sections collapsed", () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
expect(screen.getByRole("searchbox")).toBeInTheDocument();
|
||||
// Overview is static: its items show, and it has no toggle button.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Production Deployment Guide" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^Overview/i }),
|
||||
).not.toBeInTheDocument();
|
||||
// Other sections collapse, so their items are hidden until expanded.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Kubernetes Guide" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/locally hosted web application/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands a collapsible section when its header is clicked", () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Installation/i }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Kubernetes Guide" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches doc content (not just titles), shows a snippet, and navigates", async () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
// "Tesseract" appears in the OCR doc body but in no doc title — a result
|
||||
// whose snippet contains it proves full-text (content) search.
|
||||
fireEvent.change(screen.getByRole("searchbox"), {
|
||||
target: { value: "Tesseract" },
|
||||
});
|
||||
const hits = await screen.findAllByRole("button", { name: /Tesseract/i });
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
fireEvent.click(hits[0]);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/locally hosted web application/i),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("follows an internal doc: link inside the rendered markdown", async () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
// The Getting Started body links to the Migration guide via the doc: scheme.
|
||||
fireEvent.click(screen.getByRole("link", { name: /Migration Guide/i }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/locally hosted web application/i),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +1,97 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button, EmptyState } from "@app/ui";
|
||||
import { DocsNav } from "@portal/components/docs/DocsNav";
|
||||
import { DocsSearch } from "@portal/components/docs/DocsSearch";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
import { DocsToc } from "@portal/components/docs/DocsToc";
|
||||
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
|
||||
import { extractHeadings } from "@portal/docs/headings";
|
||||
import { EmptyState } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
allDocs,
|
||||
firstDocId,
|
||||
loadDoc,
|
||||
loadDocsNav,
|
||||
} from "@portal/docs/manifest/registry";
|
||||
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
|
||||
fetchDocsContent,
|
||||
fetchDocsNav,
|
||||
type DocsContent,
|
||||
type DocsNavSection,
|
||||
} from "@portal/api/docs";
|
||||
import { DocsNav, DocsNavSkeleton } from "@portal/components/docs/DocsNav";
|
||||
import { GettingStartedSection } from "@portal/components/docs/GettingStartedSection";
|
||||
import { AuthenticationSection } from "@portal/components/docs/AuthenticationSection";
|
||||
import { RateLimitsSection } from "@portal/components/docs/RateLimitsSection";
|
||||
import { EndpointReferenceSection } from "@portal/components/docs/EndpointReferenceSection";
|
||||
import { ErrorsSection } from "@portal/components/docs/ErrorsSection";
|
||||
import { WebhooksSection } from "@portal/components/docs/WebhooksSection";
|
||||
import { SdksSection } from "@portal/components/docs/SdksSection";
|
||||
import { ComponentsSection } from "@portal/components/docs/ComponentsSection";
|
||||
import { PlaybooksSection } from "@portal/components/docs/PlaybooksSection";
|
||||
import { SkillsSection } from "@portal/components/docs/SkillsSection";
|
||||
import "@portal/views/DeveloperDocs.css";
|
||||
|
||||
/**
|
||||
* Developer Docs — a markdown browser over the docs manifest generated from the
|
||||
* Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted
|
||||
* from the repo's folders + frontmatter; content is the repo markdown, and the
|
||||
* search box does full-text search across every doc.
|
||||
*/
|
||||
/** Renders the content pane for the active nav leaf against fetched content. */
|
||||
function DocsContentPane({
|
||||
active,
|
||||
content,
|
||||
}: {
|
||||
active: string;
|
||||
content: DocsContent;
|
||||
}) {
|
||||
switch (active) {
|
||||
case "authentication":
|
||||
return <AuthenticationSection />;
|
||||
case "rate-limits":
|
||||
return <RateLimitsSection rateLimit={content.rateLimit} />;
|
||||
case "endpoints":
|
||||
return <EndpointReferenceSection />;
|
||||
case "errors":
|
||||
return <ErrorsSection errors={content.errors} />;
|
||||
case "webhooks":
|
||||
return <WebhooksSection />;
|
||||
case "sdk-overview":
|
||||
return <SdksSection sdks={content.sdks} />;
|
||||
case "component-library":
|
||||
return <ComponentsSection components={content.components} />;
|
||||
case "recipes":
|
||||
return <PlaybooksSection playbooks={content.playbooks} />;
|
||||
case "skill-catalog":
|
||||
return <SkillsSection skills={content.skills} />;
|
||||
default:
|
||||
return (
|
||||
<GettingStartedSection
|
||||
samples={content.quickstartSamples}
|
||||
response={content.quickstartResponse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function DeveloperDocs() {
|
||||
const { t } = useTranslation();
|
||||
const { hash } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const { tier } = useTier();
|
||||
const [active, setActive] = useState("quickstart");
|
||||
|
||||
const nav = useMemo(() => loadDocsNav(), []);
|
||||
const fallback = useMemo(() => firstDocId(), []);
|
||||
const navState = useAsync<DocsNavSection[]>(() => fetchDocsNav(), []);
|
||||
const { data: nav } = navState;
|
||||
const { isLoading, isEmpty } = useSectionFlags(navState);
|
||||
|
||||
// Full-text index over every doc's plaintext body (built once).
|
||||
const index = useMemo<SearchDoc[]>(() => {
|
||||
const labels = new Map(nav.map((s) => [s.id, s.label]));
|
||||
return allDocs().map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
sectionLabel: labels.get(d.section) ?? "",
|
||||
text: toPlainText(d.markdown),
|
||||
}));
|
||||
}, [nav]);
|
||||
const results = useMemo(() => searchDocs(index, query), [index, query]);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
// Deep-link support: the active doc id lives in the URL hash.
|
||||
const hashId = decodeURIComponent(hash.replace(/^#/, ""));
|
||||
const activeId = hashId && loadDoc(hashId) ? hashId : fallback;
|
||||
const doc = activeId ? loadDoc(activeId) : undefined;
|
||||
const section = useMemo(
|
||||
() => nav.find((s) => s.items.some((i) => i.id === activeId)),
|
||||
[nav, activeId],
|
||||
const { data: content } = useAsync<DocsContent>(
|
||||
() => fetchDocsContent(tier),
|
||||
[tier],
|
||||
);
|
||||
// "On this page" headings for the current doc.
|
||||
const headings = useMemo(
|
||||
() => (doc ? extractHeadings(doc.markdown) : []),
|
||||
[doc],
|
||||
);
|
||||
|
||||
// Navigating closes the mobile drawer, clears the search, and resets the pane.
|
||||
const onSelect = useCallback(
|
||||
(id: string) => {
|
||||
navigate({ hash: id });
|
||||
setNavOpen(false);
|
||||
setQuery("");
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo?.({ top: 0 });
|
||||
}, [activeId]);
|
||||
|
||||
if (nav.length === 0 || !doc) {
|
||||
return (
|
||||
<div className="portal-docs portal-docs--empty">
|
||||
<EmptyState
|
||||
title={t("portal.docs.nav.empty.title")}
|
||||
description={t("portal.docs.nav.empty.description")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasToc = headings.length > 0;
|
||||
|
||||
return (
|
||||
<div className={"portal-docs" + (hasToc ? " portal-docs--with-toc" : "")}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="portal-docs__nav-toggle"
|
||||
aria-expanded={navOpen}
|
||||
onClick={() => setNavOpen((open) => !open)}
|
||||
leftSection={<span aria-hidden>☰</span>}
|
||||
>
|
||||
{t("portal.docs.browse")}
|
||||
</Button>
|
||||
|
||||
<aside className={"portal-docs__sidebar" + (navOpen ? " is-open" : "")}>
|
||||
<DocsSearch
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
results={results}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
{!searching && (
|
||||
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
|
||||
<div className="portal-docs">
|
||||
<aside className="portal-docs__sidebar">
|
||||
{isLoading && <DocsNavSkeleton />}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.docs.nav.empty.title")}
|
||||
description={t("portal.docs.nav.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{nav && nav.length > 0 && (
|
||||
<DocsNav sections={nav} active={active} onSelect={setActive} />
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="portal-docs__content" ref={contentRef}>
|
||||
<div className="portal-docs__content-inner">
|
||||
<DocsSection
|
||||
id={doc.id}
|
||||
eyebrow={section?.label ?? ""}
|
||||
title={doc.title}
|
||||
lead={doc.description}
|
||||
>
|
||||
<MarkdownDoc markdown={doc.markdown} onNavigate={onSelect} />
|
||||
<div className="portal-docs__source">
|
||||
<a href={doc.editUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("portal.docs.viewSource")}
|
||||
</a>
|
||||
</div>
|
||||
</DocsSection>
|
||||
</div>
|
||||
<main className="portal-docs__content">
|
||||
{content && <DocsContentPane active={active} content={content} />}
|
||||
</main>
|
||||
|
||||
{hasToc && (
|
||||
<aside className="portal-docs__toc-col">
|
||||
<DocsToc headings={headings} scrollRef={contentRef} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,26 +181,8 @@
|
||||
}
|
||||
|
||||
.portal-editor__pairing-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-editor__pairing-icon--blue {
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
.portal-editor__pairing-icon--purple {
|
||||
background: var(--color-purple-light);
|
||||
color: var(--color-purple);
|
||||
}
|
||||
.portal-editor__pairing-icon--green {
|
||||
background: var(--color-green-light);
|
||||
color: var(--color-green);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.portal-editor__pairing-titles {
|
||||
|
||||
@@ -93,11 +93,8 @@
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.portal-home__quick-icon svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-home__quick-text {
|
||||
@@ -119,13 +116,9 @@
|
||||
}
|
||||
|
||||
.portal-home__quick-arrow {
|
||||
display: inline-flex;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
.portal-home__quick-arrow svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.portal-home__quick-row:hover .portal-home__quick-arrow {
|
||||
color: var(--color-blue);
|
||||
|
||||
@@ -13,52 +13,31 @@ import "@portal/views/Home.css";
|
||||
/* Quick actions card */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** A stroke icon used inside the quick-action badge (replaces the old text
|
||||
glyphs ⌃ ⇢ ⚙, which rendered off-style vs the portal's icon set). */
|
||||
function QuickIcon({ d }: { d: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rows for the Quick Actions list. Each `view` navigates the portal. */
|
||||
const QUICK_ACTIONS: Array<{
|
||||
key: string;
|
||||
iconD: string;
|
||||
glyph: string;
|
||||
bg: string;
|
||||
fg: string;
|
||||
view: ViewId;
|
||||
}> = [
|
||||
{
|
||||
key: "buildPipeline",
|
||||
iconD:
|
||||
"M6 4a2 2 0 100 4 2 2 0 000-4zM18 16a2 2 0 100 4 2 2 0 000-4zM6 8v6a4 4 0 004 4h4",
|
||||
glyph: "⌃",
|
||||
bg: "var(--color-purple-light)",
|
||||
fg: "var(--color-purple)",
|
||||
view: "pipelines",
|
||||
},
|
||||
{
|
||||
key: "connectSource",
|
||||
iconD:
|
||||
"M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1M14 11a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1",
|
||||
glyph: "⇢",
|
||||
bg: "var(--color-green-light)",
|
||||
fg: "var(--color-green-dark)",
|
||||
view: "sources",
|
||||
},
|
||||
{
|
||||
key: "issueApiKey",
|
||||
iconD:
|
||||
"M2.6 17.4A2 2 0 002 18.8V21a1 1 0 001 1h3a1 1 0 001-1v-1a1 1 0 011-1h1a1 1 0 001-1v-1a1 1 0 011-1h.2a2 2 0 001.4-.6l.8-.8a6.5 6.5 0 10-4-4z M16.5 7.5 h.01",
|
||||
glyph: "⚙",
|
||||
bg: "var(--color-amber-light)",
|
||||
fg: "var(--color-amber-dark)",
|
||||
view: "infrastructure",
|
||||
@@ -96,21 +75,12 @@ function QuickActions() {
|
||||
style={{ background: action.bg, color: action.fg }}
|
||||
aria-hidden
|
||||
>
|
||||
<QuickIcon d={action.iconD} />
|
||||
{action.glyph}
|
||||
</span>
|
||||
}
|
||||
rightSection={
|
||||
<span className="portal-home__quick-arrow" aria-hidden>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
→
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -546,38 +546,3 @@
|
||||
.portal-policies__detail-stats .sui-stat + .sui-stat {
|
||||
border-left: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* ── Catalogue table (replaces the stacked category cards) ──────────────── */
|
||||
.portal-policies__cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policies__cell-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policies__rulechips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
|
||||
.portal-policies__muted {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-policies__docs {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* The .pcat-badge icon styles live with the PolicyCategoryIcon component
|
||||
(PolicyCategoryIcon.css) so the Home policy summary picks them up too. */
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
|
||||
import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogueTable";
|
||||
import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard";
|
||||
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
import "@portal/views/Policies.css";
|
||||
@@ -151,7 +151,15 @@ export function Policies() {
|
||||
)}
|
||||
|
||||
{!isLoading && !fetchError && (
|
||||
<PolicyCatalogueTable entries={displayCatalogue} onOpen={openEntry} />
|
||||
<div className="portal-policies__grid">
|
||||
{displayCatalogue.map((entry) => (
|
||||
<PolicyCategoryCard
|
||||
key={entry.category.id}
|
||||
entry={entry}
|
||||
onOpen={openEntry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PolicyDetailPanel
|
||||
|
||||
@@ -466,8 +466,8 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-primary, #2383e2);
|
||||
background: var(--color-primary-light, #eaf2fb);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.4rem;
|
||||
@@ -515,10 +515,10 @@
|
||||
.portal-proc__field input,
|
||||
.portal-proc__field select {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-border, #eae8e3);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface);
|
||||
background: var(--color-bg-elevated, #fff);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.portal-proc__builder-addons {
|
||||
@@ -543,7 +543,7 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border, #eae8e3);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.portal-proc__quote-number {
|
||||
@@ -565,10 +565,10 @@
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid var(--color-border-light, #f0eee9);
|
||||
}
|
||||
.portal-proc__quote-lines li[data-kind="DISCOUNT"] {
|
||||
color: var(--color-green-dark);
|
||||
color: var(--color-success, #0f7b6c);
|
||||
}
|
||||
.portal-proc__quote-total {
|
||||
display: flex;
|
||||
@@ -588,10 +588,10 @@
|
||||
|
||||
/* ══ Quote builder — copied from the marketing prototype (tight density) ═════ */
|
||||
.portal-qb {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
background: #ffffff;
|
||||
border: 1px solid #e3e1dc;
|
||||
border-radius: 14px;
|
||||
box-shadow: inset 0 0 0 1px var(--color-border-light);
|
||||
box-shadow: inset 0 0 0 1px #eae8e3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.portal-qb__head {
|
||||
@@ -599,10 +599,10 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 24px 14px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid #f0eee9;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-blue) 5.5%, transparent) 0%,
|
||||
rgba(35, 131, 226, 0.055) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@@ -610,13 +610,13 @@
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__stepchip {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-bg-subtle);
|
||||
color: #9b9a97;
|
||||
background: #f5f4f1;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -629,11 +629,11 @@
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border-light);
|
||||
background: #f0eee9;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.portal-qb__progress span[data-on] {
|
||||
background: var(--color-blue);
|
||||
background: #2383e2;
|
||||
}
|
||||
.portal-qb__body {
|
||||
padding: 20px 24px;
|
||||
@@ -650,7 +650,7 @@
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-blue-light);
|
||||
background: #eaf2fb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -660,11 +660,11 @@
|
||||
.portal-qb__intro-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__intro-sub {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.portal-qb__field {
|
||||
@@ -675,7 +675,7 @@
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-3);
|
||||
color: #787774;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.portal-qb__field input,
|
||||
@@ -685,11 +685,11 @@
|
||||
padding: 9px 11px;
|
||||
font-size: 13.5px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid #e3e1dc;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-1);
|
||||
background: #fff;
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__row {
|
||||
display: flex;
|
||||
@@ -703,7 +703,7 @@
|
||||
.portal-qb__hint {
|
||||
margin: 7px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.portal-qb__pills {
|
||||
@@ -716,20 +716,20 @@
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-1);
|
||||
border: 1px solid #e3e1dc;
|
||||
background: #fff;
|
||||
color: #37352f;
|
||||
cursor: pointer;
|
||||
}
|
||||
.portal-qb__pills button[data-on] {
|
||||
background: var(--color-blue);
|
||||
border-color: var(--color-blue);
|
||||
color: var(--color-text-on-accent);
|
||||
background: #2383e2;
|
||||
border-color: #2383e2;
|
||||
color: #fff;
|
||||
}
|
||||
.portal-qb__discount {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--color-green-dark);
|
||||
color: #0f7b6c;
|
||||
}
|
||||
.portal-qb__opts {
|
||||
display: flex;
|
||||
@@ -742,28 +742,28 @@
|
||||
min-width: 150px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid #e3e1dc;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.portal-qb__opt[data-on] {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
border-color: #2383e2;
|
||||
background: #eaf2fb;
|
||||
}
|
||||
.portal-qb__opt-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__opt[data-on] .portal-qb__opt-title {
|
||||
color: var(--color-blue-dark);
|
||||
color: #1b6ec2;
|
||||
}
|
||||
.portal-qb__opt-sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.portal-qb__addons {
|
||||
@@ -777,43 +777,43 @@
|
||||
gap: 11px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid #e3e1dc;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.portal-qb__addon[data-on] {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
border-color: #2383e2;
|
||||
background: #eaf2fb;
|
||||
}
|
||||
.portal-qb__addon-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid #d3d1cb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: transparent;
|
||||
background: var(--color-surface);
|
||||
background: #fff;
|
||||
}
|
||||
.portal-qb__addon[data-on] .portal-qb__addon-box {
|
||||
background: var(--color-blue);
|
||||
border-color: var(--color-blue);
|
||||
color: var(--color-text-on-accent);
|
||||
background: #2383e2;
|
||||
border-color: #2383e2;
|
||||
color: #fff;
|
||||
}
|
||||
.portal-qb__addon-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__addon-sub {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.portal-qb__eula {
|
||||
@@ -822,10 +822,10 @@
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid #f0eee9;
|
||||
background: #f5f4f1;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -835,12 +835,12 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 24px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
border-top: 1px solid #f0eee9;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.portal-qb__running {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
}
|
||||
.portal-qb__foot-btns {
|
||||
display: flex;
|
||||
@@ -848,15 +848,15 @@
|
||||
}
|
||||
/* Step 4 — the itemised quote paper */
|
||||
.portal-qb__papertray {
|
||||
background: var(--color-bg-subtle);
|
||||
background: #f5f4f1;
|
||||
padding: 18px;
|
||||
max-height: 56vh;
|
||||
overflow-y: auto;
|
||||
margin: -20px -24px;
|
||||
}
|
||||
.portal-qb__paper {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: #fff;
|
||||
border: 1px solid #f0eee9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
@@ -866,16 +866,16 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid #f0eee9;
|
||||
}
|
||||
.portal-qb__paper-brand {
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__paper-eyebrow {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
}
|
||||
.portal-qb__paper-meta {
|
||||
text-align: right;
|
||||
@@ -883,11 +883,11 @@
|
||||
.portal-qb__quote-number {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__paper-meta div:not(.portal-qb__quote-number) {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
}
|
||||
.portal-qb__paper-for {
|
||||
padding: 18px 24px 0;
|
||||
@@ -895,7 +895,7 @@
|
||||
.portal-qb__paper-company {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__lines {
|
||||
list-style: none;
|
||||
@@ -907,16 +907,16 @@
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid #f0eee9;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__lines li[data-kind="DISCOUNT"] {
|
||||
color: var(--color-green-dark);
|
||||
color: #0f7b6c;
|
||||
}
|
||||
.portal-qb__lines li[data-kind="INCLUDED"] span:last-child {
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
font-weight: 500;
|
||||
}
|
||||
.portal-qb__total {
|
||||
@@ -926,17 +926,17 @@
|
||||
margin: 16px 24px 24px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-blue-light);
|
||||
border: 1px solid var(--color-blue-border);
|
||||
background: #eaf2fb;
|
||||
border: 1px solid #b8d5f2;
|
||||
}
|
||||
.portal-qb__total-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
}
|
||||
.portal-qb__total-tcv {
|
||||
font-size: 11.5px;
|
||||
color: var(--color-text-3);
|
||||
color: #787774;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.portal-qb__total-num {
|
||||
@@ -946,12 +946,12 @@
|
||||
display: block;
|
||||
font-size: 23px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-1);
|
||||
color: #37352f;
|
||||
line-height: 1;
|
||||
}
|
||||
.portal-qb__total-num span {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-4);
|
||||
color: #9b9a97;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
@@ -968,7 +968,7 @@
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 140% at 100% 0%,
|
||||
color-mix(in srgb, var(--color-purple) 8%, transparent),
|
||||
rgba(124, 58, 237, 0.08),
|
||||
transparent 55%
|
||||
),
|
||||
var(--color-surface);
|
||||
@@ -989,7 +989,7 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-blue);
|
||||
color: var(--color-primary, #2383e2);
|
||||
}
|
||||
.portal-hero__company {
|
||||
display: block;
|
||||
@@ -1035,8 +1035,8 @@
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-blue);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-blue) 18%, transparent);
|
||||
background: var(--color-primary, #2383e2);
|
||||
box-shadow: 0 0 0 3px rgba(35, 131, 226, 0.18);
|
||||
}
|
||||
|
||||
/* ── Full-screen takeover modal (procurement flow) ────────────────────────── */
|
||||
@@ -1122,9 +1122,9 @@
|
||||
.portal-proc__license {
|
||||
margin-top: 1.25rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-bg-subtle);
|
||||
background: var(--color-surface-2, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
.portal-proc__license-label {
|
||||
display: block;
|
||||
@@ -1139,7 +1139,7 @@
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-bg-muted);
|
||||
background: var(--color-surface-3, rgba(0, 0, 0, 0.05));
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
@@ -1386,8 +1386,8 @@
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
.portal-docs__row-action[data-status="action"] {
|
||||
color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-primary, #2383e2);
|
||||
background: var(--color-primary-light, #eaf2fb);
|
||||
}
|
||||
.portal-docs__row-action[data-status="request"] {
|
||||
color: var(--color-text-5);
|
||||
@@ -1398,7 +1398,7 @@
|
||||
.portal-calendly {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* ── Agreement (security) step ────────────────────────────────────────────── */
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
.portal-sources__name-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -367,7 +366,7 @@
|
||||
|
||||
.portal-sources__type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -377,17 +376,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared Button lays its children out in a flex-row label; force the type
|
||||
tile back to a centred icon-over-label column so the SVG sits above the name
|
||||
with real spacing (not jammed against it). */
|
||||
.portal-sources__type-card .mantine-Button-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.portal-sources__type-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -414,29 +402,9 @@
|
||||
box-shadow: 0 0 0 1px var(--color-blue) inset;
|
||||
}
|
||||
|
||||
/* Icon inside the type picker: a plain stroke icon above the label (no badge
|
||||
box — the tinted box read as an extra bordered chip floating on the tile). */
|
||||
.portal-sources__type-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-sources__type-card.is-selected .portal-sources__type-icon {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-sources__type-icon .portal-sources__type-svg {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
}
|
||||
|
||||
/* Sized to sit centred inside the 1.875rem table badge / picker dot. */
|
||||
.portal-sources__type-svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-sources__type-name {
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.portal-users__row-role {
|
||||
width: 12.5rem;
|
||||
width: 148px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,16 +78,14 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
|
||||
const contentType =
|
||||
(response.headers &&
|
||||
(response.headers["content-type"] ||
|
||||
response.headers["Content-Type"])) ||
|
||||
"";
|
||||
const disposition =
|
||||
(response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"";
|
||||
const contentType = ((response.headers &&
|
||||
(response.headers["content-type"] ||
|
||||
response.headers["Content-Type"])) ||
|
||||
"") as string;
|
||||
const disposition = ((response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"") as string;
|
||||
const filename =
|
||||
parseContentDispositionFilename(disposition) || "shared-file";
|
||||
const blob = response.data as Blob;
|
||||
|
||||
@@ -44,15 +44,13 @@ export async function downloadShareLink(token: string): Promise<{
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
const contentType =
|
||||
(response.headers &&
|
||||
(response.headers["content-type"] || response.headers["Content-Type"])) ||
|
||||
"";
|
||||
const disposition =
|
||||
(response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"";
|
||||
const contentType = ((response.headers &&
|
||||
(response.headers["content-type"] || response.headers["Content-Type"])) ||
|
||||
"") as string;
|
||||
const disposition = ((response.headers &&
|
||||
(response.headers["content-disposition"] ||
|
||||
response.headers["Content-Disposition"])) ||
|
||||
"") as string;
|
||||
const filename =
|
||||
parseContentDispositionFilename(disposition) || "shared-file";
|
||||
const blob = response.data as Blob;
|
||||
|
||||
Generated
+404
-1074
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,7 @@
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"posthog-js": "^1.268.0",
|
||||
"posthog-js": "^1.393.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
@@ -83,7 +83,6 @@
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:sync": "tsx editor/scripts/sync-portal-docs.mts",
|
||||
"update:minor": "node scripts/update-minor.js",
|
||||
"update:major": "npx npm-check-updates -u && npm install",
|
||||
"update:interactive": "npx npm-check-updates -i",
|
||||
@@ -133,7 +132,7 @@
|
||||
"@typescript-eslint/parser": "^8.61.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260624.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"dotenv": "^16.4.7",
|
||||
"dpdm": "^3.14.0",
|
||||
"eslint": "^10.0.2",
|
||||
@@ -158,7 +157,7 @@
|
||||
"vite-plugin-compression2": "^2.5.3",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"depcheck": {
|
||||
"ignoreMatches": [
|
||||
@@ -172,7 +171,11 @@
|
||||
"devalue": "^5.8.1",
|
||||
"tsconfck": {
|
||||
"typescript": "$typescript"
|
||||
}
|
||||
},
|
||||
"form-data": "^4.0.6",
|
||||
"protobufjs": "^7.6.3",
|
||||
"esbuild": "^0.28.1",
|
||||
"@babel/core": "^7.29.6"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
|
||||
Reference in New Issue
Block a user