mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 12:15:29 +02:00
Portal (#6391)
# Description of Changes ## What & why This PR introduces the **Stirling developer portal** — a new control-plane frontend that sits alongside the existing PDF editor — plus the shared design system and workspace structure needed to host both apps in one frontend. The portal is the parent product surface: where users connect sources, compose pipelines, wire agents, and manage usage / billing / infrastructure, with the PDF editor as one capability inside it. This PR lays the **foundation** — workspace reshape, design system, app shell, navigation, and a mock-driven home — rather than wiring real backends (those surfaces are placeholders for follow-up phases). ## What's in this PR **1. Frontend repo reshape (`frontend/src/` → `frontend/editor/`)** The existing editor app moved under `frontend/editor/`, so `editor`, `portal`, and `shared` are siblings in one workspace. All references were updated accordingly: `LICENSE`, `.dockerignore`, `.gitignore`, build/sign shell scripts, the GH language-check script, the Taskfile, and Docker config. **No editor source logic changed — path references only.** **2. New shared design system (`frontend/shared/`)** - **Design tokens** in `tokens.css` as the single runtime source of truth (light/dark, category accents, gradients). `tokens.ts` now holds only the `Tier` type — the old JS palette mirror was removed (nothing consumed it and it had drifted). - ~30 framework-light **components** (Card, Button, Input, Select, Tabs, Modal, Drawer, Toast, MetricCard, StatusBadge, Skeleton, EmptyState, …) with Storybook stories. - **Typed data catalogues**: `endpoints.ts` (10 verticals / 64 endpoints) and `ops.ts`. **3. New developer portal app (`frontend/portal/`)** - App shell: `Header`, `Sidebar`, `AssistantPanel`, search modal, notifications, tier switcher, theme toggle, MSW toggle. - **Tier-aware** home (free / pay-as-you-go / enterprise): KPI strip, 30-day usage chart, onboarding checklist, quick actions, recent activity, region health, product grid, and a curated **"Popular use cases"** teaser. - **Documents** view hosting the full, tab-filterable endpoint catalogue. - Placeholder views for Sources / Pipelines / Agents / Editor / Infrastructure / Usage & Billing / Developer Docs / Settings (follow-up phases). - **MSW-mocked** API layer: `api/*` issues real `fetch`, intercepted by mocks in dev/Storybook; pointing at a real backend is just a matter of not registering MSW. `react-router` URLs; Tier / View / UI contexts. **4. Tooling & guardrails** - ESLint extended to `portal` + `shared`, with **layering-boundary rules**: `shared/` may depend only on third-party packages and itself (no `@app` / `@portal` / `@core` / `@proprietary` / Tauri), so it stays cleanly extractable into a standalone package later. - `dpdm` circular-dependency check now walks editor + portal + shared (the old glob matched only 2 files). - New **devDependencies only** — Storybook (+ a11y/docs/themes addons), MSW. No runtime dependencies added. - New tasks: `frontend:dev:portal`, `frontend:build:portal`. ## Testing done locally - `tsc` for both `portal` and `shared` projects — clean - `eslint --max-warnings=0` across the whole frontend — clean - `dpdm` circular-dependency check — no cycles - Editor builds clean: `vite build editor --mode core` (✓ built, only the pre-existing >500 kB chunk-size advisory) - Editor runs in dev (core mode) with **zero console errors**; portal runs in dev across all three tiers ## Notes for reviewers - The change is overwhelmingly **additive**: `shared/` and `portal/` are brand-new; the existing editor is path-reference changes only. - The portal is intentionally **mock-driven** at this stage — real backends and the remaining views land in follow-up phases. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b355ccec9e
commit
919f0ade99
@@ -0,0 +1,19 @@
|
||||
.portal-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-shell__main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; /* prevent grid blowout on narrow content */
|
||||
}
|
||||
|
||||
.portal-shell__view {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@portal/components/Sidebar";
|
||||
import { Header } from "@portal/components/Header";
|
||||
import "@portal/components/AppShell.css";
|
||||
|
||||
/**
|
||||
* Two-column layout: fixed-width sidebar on the left, sticky header + scrolling
|
||||
* main column on the right. The Sidebar and Header read their state from
|
||||
* context, so this shell stays prop-free.
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="portal-shell">
|
||||
<Sidebar />
|
||||
<div className="portal-shell__main">
|
||||
<Header />
|
||||
<main className="portal-shell__view">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.portal-assistant-btn {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-blue) 0%,
|
||||
var(--color-purple) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 0.5rem 1.25rem rgba(59, 130, 246, 0.35),
|
||||
inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2);
|
||||
transition:
|
||||
transform var(--motion-base),
|
||||
box-shadow var(--motion-base);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.portal-assistant-btn:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow:
|
||||
0 0.75rem 1.75rem rgba(59, 130, 246, 0.45),
|
||||
inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.portal-assistant-btn:active {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { SparklesIcon } from "@portal/components/icons";
|
||||
import "@portal/components/AssistantButton.css";
|
||||
|
||||
export function AssistantButton() {
|
||||
const { assistantOpen, toggleAssistant } = useUI();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={"portal-assistant-btn" + (assistantOpen ? " is-active" : "")}
|
||||
onClick={toggleAssistant}
|
||||
aria-label={assistantOpen ? "Close assistant" : "Open assistant"}
|
||||
aria-expanded={assistantOpen}
|
||||
title="Assistant"
|
||||
>
|
||||
{assistantOpen ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
) : (
|
||||
<SparklesIcon size={22} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
.portal-assistant {
|
||||
position: fixed;
|
||||
bottom: 5.5rem;
|
||||
right: 1.5rem;
|
||||
width: 23.75rem;
|
||||
height: 32.5rem;
|
||||
max-height: calc(100vh - 7rem);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 35;
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
}
|
||||
|
||||
.portal-assistant__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--color-text-on-accent);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-blue) 0%,
|
||||
var(--color-purple) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.portal-assistant__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-assistant__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-assistant__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.portal-assistant__close:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.portal-assistant__messages {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.portal-assistant__suggestions {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-assistant__suggestions-eyebrow {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-assistant__suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-assistant__suggestion {
|
||||
padding: 0.4375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
color: var(--color-text-2);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
.portal-assistant__suggestion:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-blue-border);
|
||||
}
|
||||
|
||||
.portal-assistant__bubble {
|
||||
max-width: 80%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--radius-md);
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.portal-assistant__bubble--user {
|
||||
align-self: flex-end;
|
||||
background: var(--color-blue);
|
||||
color: var(--color-text-on-accent);
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-assistant__bubble--assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-assistant__typing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.portal-assistant__typing span {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-5);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.portal-assistant__typing span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.portal-assistant__typing span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.portal-assistant__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.portal-assistant__input {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4375rem 0.625rem;
|
||||
color: var(--color-text-1);
|
||||
outline: none;
|
||||
transition: border-color var(--motion-fast);
|
||||
}
|
||||
.portal-assistant__input:focus {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-assistant__send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--grad-blue-btn);
|
||||
color: var(--color-text-on-accent);
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
.portal-assistant__send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useEffect } from "react";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
function ForceOpen() {
|
||||
const { openAssistant } = useUI();
|
||||
useEffect(() => {
|
||||
openAssistant();
|
||||
}, [openAssistant]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof AssistantPanel> = {
|
||||
title: "Portal/Assistant/AssistantPanel",
|
||||
component: AssistantPanel,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<ForceOpen />
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AssistantPanel>;
|
||||
|
||||
export const SuggestionsOnly: Story = {};
|
||||
|
||||
export const SlowReply: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post("/v1/assistant/messages", async () => {
|
||||
await delay(3000);
|
||||
return HttpResponse.json({
|
||||
reply:
|
||||
"After a long pause: here's a deliberately slow reply to demo the typing indicator under real network latency.",
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ReplyFails: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post("/v1/assistant/messages", () =>
|
||||
HttpResponse.json({ error: "rate limit exceeded" }, { status: 429 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchAssistantSuggestions,
|
||||
getAssistantReply,
|
||||
} from "@portal/api/assistant";
|
||||
import { CloseIcon, SendIcon, SparklesIcon } from "@portal/components/icons";
|
||||
import "@portal/components/AssistantPanel.css";
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function AssistantPanel() {
|
||||
const { assistantOpen, closeAssistant } = useUI();
|
||||
const { data: suggestions } = useAsync<readonly string[]>(
|
||||
() => fetchAssistantSuggestions(),
|
||||
[],
|
||||
);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const messagesRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const nextIdRef = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (assistantOpen) {
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 60);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [assistantOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current?.scrollTo({
|
||||
top: messagesRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [messages, typing]);
|
||||
|
||||
async function send(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || typing) return;
|
||||
const userMsg: Message = {
|
||||
id: nextIdRef.current++,
|
||||
role: "user",
|
||||
text: trimmed,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput("");
|
||||
setTyping(true);
|
||||
|
||||
try {
|
||||
const reply = await getAssistantReply(trimmed);
|
||||
const assistantMsg: Message = {
|
||||
id: nextIdRef.current++,
|
||||
role: "assistant",
|
||||
text: reply,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
} catch (err) {
|
||||
const failMsg: Message = {
|
||||
id: nextIdRef.current++,
|
||||
role: "assistant",
|
||||
text:
|
||||
err instanceof Error
|
||||
? `Couldn't reach the assistant: ${err.message}`
|
||||
: "Couldn't reach the assistant.",
|
||||
};
|
||||
setMessages((prev) => [...prev, failMsg]);
|
||||
} finally {
|
||||
setTyping(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!assistantOpen) return null;
|
||||
|
||||
return (
|
||||
<aside className="portal-assistant" role="dialog" aria-label="Assistant">
|
||||
<header className="portal-assistant__header">
|
||||
<div className="portal-assistant__header-left">
|
||||
<SparklesIcon size={16} />
|
||||
<span className="portal-assistant__title">Assistant</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-assistant__close"
|
||||
onClick={closeAssistant}
|
||||
aria-label="Close assistant"
|
||||
>
|
||||
<CloseIcon size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="portal-assistant__messages" ref={messagesRef}>
|
||||
{messages.length === 0 && suggestions && (
|
||||
<div className="portal-assistant__suggestions">
|
||||
<div className="portal-assistant__suggestions-eyebrow">
|
||||
Try asking
|
||||
</div>
|
||||
<div className="portal-assistant__suggestions-list">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className="portal-assistant__suggestion"
|
||||
onClick={() => send(s)}
|
||||
disabled={typing}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={
|
||||
"portal-assistant__bubble portal-assistant__bubble--" + m.role
|
||||
}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
{typing && (
|
||||
<div className="portal-assistant__bubble portal-assistant__bubble--assistant">
|
||||
<span className="portal-assistant__typing" aria-label="Typing">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="portal-assistant__input-row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
send(input);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask about Stirling…"
|
||||
aria-label="Ask the assistant"
|
||||
className="portal-assistant__input"
|
||||
disabled={typing}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="portal-assistant__send"
|
||||
disabled={!input.trim() || typing}
|
||||
aria-label="Send"
|
||||
>
|
||||
<SendIcon size={14} />
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
.portal-doctype {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-doctype__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.portal-doctype__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-doctype__sub {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.portal-doctype__tabs {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-doctype__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-doctype__tab:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-doctype__tab.is-active {
|
||||
color: var(--portal-tab-accent, var(--color-blue));
|
||||
border-color: var(--portal-tab-accent, var(--color-blue));
|
||||
background: var(--color-surface);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portal-doctype__tab-dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-doctype__tab-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
/* Vertical groups */
|
||||
.portal-doctype__groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.portal-doctype__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-doctype__group-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-doctype__group-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-doctype__scroller {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.portal-doctype__scroller::-webkit-scrollbar {
|
||||
height: 0.375rem;
|
||||
}
|
||||
.portal-doctype__scroller::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-hover);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.portal-doctype__card {
|
||||
flex: 0 0 17rem;
|
||||
display: flex;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
scroll-snap-align: start;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
box-shadow var(--motion-fast),
|
||||
transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-doctype__card:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-0.0625rem);
|
||||
}
|
||||
|
||||
.portal-doctype__card-accent {
|
||||
width: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-doctype__card-body {
|
||||
flex: 1 1 auto;
|
||||
padding: 0.875rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-doctype__card-eyebrow {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-doctype__card-title {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-doctype__card-desc {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-4);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-doctype__card-meta {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-doctype__regions {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-doctype__regions-more {
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-doctype__card-cta {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-doctype__card-cta:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.portal-doctype__skel,
|
||||
.portal-doctype__card-skel {
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-muted) 0%,
|
||||
var(--color-bg-hover) 50%,
|
||||
var(--color-bg-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.portal-doctype__skel {
|
||||
display: inline-block;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-doctype__card-skel {
|
||||
flex: 0 0 17rem;
|
||||
height: 8rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
|
||||
|
||||
const meta: Meta<typeof DocumentTypeGrid> = {
|
||||
title: "Portal/Home/DocumentTypeGrid",
|
||||
component: DocumentTypeGrid,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "72rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentTypeGrid>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/endpoints", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.get("/v1/endpoints", () => HttpResponse.json([]))],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
Tabs,
|
||||
type TabItem,
|
||||
} from "@shared/components";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchVerticals,
|
||||
type Endpoint,
|
||||
type Vertical,
|
||||
type VerticalKey,
|
||||
} from "@portal/api/endpoints";
|
||||
import "@portal/components/DocumentTypeGrid.css";
|
||||
|
||||
type ActiveTab = VerticalKey | "all";
|
||||
|
||||
const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const;
|
||||
const TIER_TONE = ["success", "info", "purple"] as const;
|
||||
|
||||
function tierBadge(tier: 0 | 1 | 2) {
|
||||
return (
|
||||
<StatusBadge tone={TIER_TONE[tier]} size="sm">
|
||||
{TIER_LABEL[tier]}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointCard({
|
||||
endpoint,
|
||||
vertical,
|
||||
}: {
|
||||
endpoint: Endpoint;
|
||||
vertical: Vertical;
|
||||
}) {
|
||||
const visibleRegions = endpoint.regions.slice(0, 2);
|
||||
const extraRegions = endpoint.regions.length - visibleRegions.length;
|
||||
return (
|
||||
<article className="portal-doctype__card">
|
||||
<div
|
||||
className="portal-doctype__card-accent"
|
||||
style={{ background: vertical.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="portal-doctype__card-body">
|
||||
<div className="portal-doctype__card-eyebrow">
|
||||
{vertical.label.toUpperCase()}
|
||||
</div>
|
||||
<h3 className="portal-doctype__card-title">{endpoint.name}</h3>
|
||||
<p className="portal-doctype__card-desc">{endpoint.desc}</p>
|
||||
<div className="portal-doctype__card-meta">
|
||||
<span className="portal-doctype__regions">
|
||||
{visibleRegions.join(" · ")}
|
||||
{extraRegions > 0 && (
|
||||
<span className="portal-doctype__regions-more">
|
||||
{" "}
|
||||
+{extraRegions} more
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{tierBadge(endpoint.tier)}
|
||||
</div>
|
||||
<a
|
||||
className="portal-doctype__card-cta"
|
||||
style={{ color: vertical.color }}
|
||||
href={`#${endpoint.endpoint}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
Explore <span aria-hidden>→</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function GridSkeleton() {
|
||||
return (
|
||||
<div className="portal-doctype__groups" aria-hidden>
|
||||
{Array.from({ length: 2 }).map((_, gi) => (
|
||||
<div key={gi} className="portal-doctype__group">
|
||||
<div className="portal-doctype__group-head">
|
||||
<Skeleton shape="circle" width="0.4375rem" height="0.4375rem" />
|
||||
<Skeleton width="6rem" />
|
||||
</div>
|
||||
<div className="portal-doctype__scroller">
|
||||
{Array.from({ length: 4 }).map((_, ci) => (
|
||||
<Skeleton key={ci} shape="rect" width="17rem" height="8rem" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentTypeGrid() {
|
||||
const [tab, setTab] = useState<ActiveTab>("all");
|
||||
const state = useAsync<Vertical[]>(() => fetchVerticals(), []);
|
||||
const { data: verticals } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
const hasVerticals = verticals !== null && verticals.length > 0;
|
||||
|
||||
const tabItems: TabItem<ActiveTab>[] = hasVerticals
|
||||
? [
|
||||
{ key: "all", label: "All" },
|
||||
...verticals.map<TabItem<ActiveTab>>((v) => ({
|
||||
key: v.key,
|
||||
label: v.label,
|
||||
count: v.endpoints.length,
|
||||
accentColor: v.color,
|
||||
dotColor: v.color,
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="portal-doctype" aria-label="Document types">
|
||||
<header className="portal-doctype__head">
|
||||
<h2 className="portal-doctype__title">Document types</h2>
|
||||
<p className="portal-doctype__sub">
|
||||
Typed endpoints across every supported vertical — each carries a
|
||||
schema, region availability and tier gate.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{hasVerticals && (
|
||||
<Tabs<ActiveTab>
|
||||
items={tabItems}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
ariaLabel="Document type verticals"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && <GridSkeleton />}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title="No document types yet"
|
||||
description="When endpoints are registered they'll appear in this catalogue."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasVerticals && (
|
||||
<div className="portal-doctype__groups">
|
||||
{(tab === "all"
|
||||
? verticals
|
||||
: verticals.filter((v) => v.key === tab)
|
||||
).map((v) => (
|
||||
<div key={v.key} className="portal-doctype__group">
|
||||
{tab === "all" && (
|
||||
<div className="portal-doctype__group-head">
|
||||
<span
|
||||
className="portal-doctype__tab-dot"
|
||||
style={{ background: v.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="portal-doctype__group-title">{v.label}</h3>
|
||||
<span className="portal-doctype__group-count">
|
||||
{v.endpoints.length} endpoints
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-doctype__scroller">
|
||||
{v.endpoints.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={endpoint.endpoint}
|
||||
endpoint={endpoint}
|
||||
vertical={v}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
.portal-header {
|
||||
height: 3.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1rem;
|
||||
background: var(--color-header-bg);
|
||||
border-bottom: 1px solid var(--color-header-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.portal-header__left {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
|
||||
.portal-header__breadcrumb {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-header-text);
|
||||
}
|
||||
|
||||
/* Search trigger */
|
||||
.portal-header__search {
|
||||
flex: 0 1 20rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: var(--color-search-bg);
|
||||
border: 1px solid var(--color-search-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-search-text);
|
||||
font-size: 0.75rem;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
.portal-header__search:hover {
|
||||
border-color: var(--color-search-border-hover);
|
||||
}
|
||||
|
||||
.portal-header__search-placeholder {
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portal-header__search-kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Right cluster */
|
||||
.portal-header__right {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-header__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-bell-stroke, var(--color-text-3));
|
||||
position: relative;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.portal-header__icon-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-header__bell-dot {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
right: 0.4375rem;
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-red);
|
||||
border: 2px solid var(--color-header-bg);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
/* Tier switcher */
|
||||
.portal-header__tier {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.portal-header__tier-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-2);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
.portal-header__tier-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.portal-header__tier-dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.portal-header__tier-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portal-header__tier-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
min-width: 12rem;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
background: var(--color-dropdown-bg);
|
||||
border: 1px solid var(--color-dropdown-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 20;
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
}
|
||||
|
||||
.portal-header__tier-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
}
|
||||
.portal-header__tier-option:hover {
|
||||
background: var(--color-dropdown-hover);
|
||||
}
|
||||
.portal-header__tier-option.is-active {
|
||||
background: var(--color-nav-active);
|
||||
color: var(--color-nav-active-text);
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.portal-header__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--grad-blue-btn);
|
||||
color: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Header } from "@portal/components/Header";
|
||||
|
||||
const meta: Meta<typeof Header> = {
|
||||
title: "Portal/Shell/Header",
|
||||
component: Header,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ background: "var(--color-bg)" }}>
|
||||
<S />
|
||||
<div style={{ padding: "1.5rem", color: "var(--color-text-3)" }}>
|
||||
Page body would render below the header.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Header>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const FreeTier: Story = { globals: { tier: "free" } };
|
||||
export const ProTier: Story = { globals: { tier: "pro" } };
|
||||
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Avatar, Dropdown } from "@shared/components";
|
||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
|
||||
import { useView, VIEW_LABELS } from "@portal/contexts/ViewContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import {
|
||||
SearchIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@portal/components/icons";
|
||||
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
|
||||
import { MocksToggle } from "@portal/components/MocksToggle";
|
||||
import "@portal/components/Header.css";
|
||||
|
||||
function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-header__icon-btn"
|
||||
onClick={toggle}
|
||||
aria-label={
|
||||
theme === "light" ? "Switch to dark theme" : "Switch to light theme"
|
||||
}
|
||||
title={theme === "light" ? "Dark mode" : "Light mode"}
|
||||
>
|
||||
{theme === "light" ? <MoonIcon size={16} /> : <SunIcon size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TierSwitcher() {
|
||||
const { tier, setTier } = useTier();
|
||||
const info = TIER_INFO[tier];
|
||||
return (
|
||||
<Dropdown.Root align="end">
|
||||
<Dropdown.Trigger>
|
||||
<button type="button" className="portal-header__tier-btn">
|
||||
<span
|
||||
className="portal-header__tier-dot"
|
||||
style={{ background: info.dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="portal-header__tier-label">{info.label}</span>
|
||||
<ChevronDownIcon size={14} />
|
||||
</button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="12rem">
|
||||
{(Object.keys(TIER_INFO) as Tier[]).map((id) => (
|
||||
<Dropdown.Item
|
||||
key={id}
|
||||
active={tier === id}
|
||||
onSelect={() => setTier(id)}
|
||||
leading={
|
||||
<span
|
||||
className="portal-header__tier-dot"
|
||||
style={{ background: TIER_INFO[id].dotColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
>
|
||||
{TIER_INFO[id].label}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const { activeView } = useView();
|
||||
const { openSearch } = useUI();
|
||||
return (
|
||||
<header className="portal-header">
|
||||
<div className="portal-header__left">
|
||||
<span className="portal-header__breadcrumb">
|
||||
{VIEW_LABELS[activeView]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="portal-header__search"
|
||||
aria-label="Search"
|
||||
onClick={openSearch}
|
||||
>
|
||||
<SearchIcon size={14} />
|
||||
<span className="portal-header__search-placeholder">Search…</span>
|
||||
<span className="portal-header__search-kbd" aria-hidden>
|
||||
⌘K
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="portal-header__right">
|
||||
<MocksToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationsDropdown />
|
||||
<TierSwitcher />
|
||||
<Avatar name="Reece" size="md" tone="blue" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.portal-mocks-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed transparent;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
border-color var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-on {
|
||||
color: var(--color-amber-dark);
|
||||
background: var(--color-amber-light);
|
||||
border-color: var(--color-amber-border);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-off {
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-bg-muted);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle:hover {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-pending {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.portal-mocks-toggle__dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
|
||||
}
|
||||
|
||||
.portal-mocks-toggle.is-off .portal-mocks-toggle__dot {
|
||||
background: var(--color-text-5);
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { useState } from "react";
|
||||
import {
|
||||
readMocksPreference,
|
||||
writeMocksPreference,
|
||||
} from "@portal/mocks/preference";
|
||||
import "@portal/components/MocksToggle.css";
|
||||
|
||||
/**
|
||||
* Dev-only header chip that flips MSW interception on and off. Persists the
|
||||
* preference to localStorage so it survives reloads. Hidden entirely in
|
||||
* production builds — there's no MSW worker to toggle there.
|
||||
*
|
||||
* Toggling reloads the page. Without a reload, components that already
|
||||
* fetched data via useAsync keep showing the cached result, which makes the
|
||||
* toggle feel like it does nothing. A reload gives a clean view of what the
|
||||
* app looks like with/without mocks.
|
||||
*/
|
||||
export function MocksToggle() {
|
||||
const [enabled] = useState(() => readMocksPreference());
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
function toggle() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
writeMocksPreference(!enabled);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"portal-mocks-toggle" +
|
||||
(enabled ? " is-on" : " is-off") +
|
||||
(pending ? " is-pending" : "")
|
||||
}
|
||||
onClick={toggle}
|
||||
aria-pressed={enabled}
|
||||
title={
|
||||
enabled
|
||||
? "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
|
||||
: "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
|
||||
}
|
||||
>
|
||||
<span className="portal-mocks-toggle__dot" aria-hidden />
|
||||
<span className="portal-mocks-toggle__label">
|
||||
Mocks {enabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
.portal-notif {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.portal-notif__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
width: 22.5rem;
|
||||
background: var(--color-notif-bg, var(--color-surface));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
z-index: 25;
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
}
|
||||
|
||||
.portal-notif__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-notif__title {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-notif__count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-blue-light);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.portal-notif__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.portal-notif__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-notif__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.portal-notif__item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-notif__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
margin-top: 0.4375rem;
|
||||
}
|
||||
|
||||
.portal-notif__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-notif__item-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.portal-notif__desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-notif__time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-notif__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.portal-notif__action {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-notif__action:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-notif__action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.portal-notif__count--quiet {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-notif__empty {
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
|
||||
import { NOTIFICATIONS } from "@portal/mocks/notifications";
|
||||
|
||||
const meta: Meta<typeof NotificationsDropdown> = {
|
||||
title: "Portal/Header/NotificationsDropdown",
|
||||
component: NotificationsDropdown,
|
||||
parameters: { layout: "centered" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
padding: "4rem 8rem",
|
||||
minHeight: "30rem",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NotificationsDropdown>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/notifications", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const HighVolume: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/notifications", () => {
|
||||
const items = Array.from({ length: 24 }, (_, i) => ({
|
||||
...NOTIFICATIONS[i % NOTIFICATIONS.length],
|
||||
id: `n${i + 1}`,
|
||||
}));
|
||||
return HttpResponse.json(items);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NetworkError: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/notifications", () =>
|
||||
HttpResponse.json({ error: "Service unavailable" }, { status: 503 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { Dropdown, EmptyState, Skeleton } from "@shared/components";
|
||||
import { BellIcon } from "@portal/components/icons";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsRead,
|
||||
type Notification,
|
||||
type NotificationCategory,
|
||||
} from "@portal/api/notifications";
|
||||
import "@portal/components/NotificationsDropdown.css";
|
||||
|
||||
const CATEGORY_COLOUR: Record<NotificationCategory, string> = {
|
||||
pipeline: "var(--color-blue)",
|
||||
deploy: "var(--color-green)",
|
||||
billing: "var(--color-amber)",
|
||||
audit: "var(--color-purple)",
|
||||
agent: "var(--color-cat-insurance)",
|
||||
doc: "var(--color-cat-healthcare)",
|
||||
};
|
||||
|
||||
export function NotificationsDropdown() {
|
||||
const state = useAsync<Notification[]>(() => fetchNotifications(), []);
|
||||
const { data: items } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
// Optimistically clear on "mark all read"; revert if the request fails.
|
||||
const [cleared, setCleared] = useState(false);
|
||||
const visible = cleared ? [] : (items ?? []);
|
||||
|
||||
async function onMarkAllRead() {
|
||||
setCleared(true);
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
} catch {
|
||||
setCleared(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = !isLoading && visible.length === 0;
|
||||
const hasUnread = visible.length > 0;
|
||||
|
||||
return (
|
||||
<Dropdown.Root align="end">
|
||||
<Dropdown.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-header__icon-btn portal-header__icon-btn--badge"
|
||||
aria-label={
|
||||
hasUnread
|
||||
? `Notifications, ${visible.length} unread`
|
||||
: "Notifications, no unread"
|
||||
}
|
||||
>
|
||||
<BellIcon size={16} />
|
||||
{hasUnread && (
|
||||
<span className="portal-header__bell-dot" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
|
||||
<div className="portal-notif__header">
|
||||
<span className="portal-notif__title">Notifications</span>
|
||||
{hasUnread ? (
|
||||
<span className="portal-notif__count">{visible.length} new</span>
|
||||
) : isLoading ? (
|
||||
<span className="portal-notif__count portal-notif__count--quiet">
|
||||
loading
|
||||
</span>
|
||||
) : (
|
||||
<span className="portal-notif__count portal-notif__count--quiet">
|
||||
all read
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="portal-notif__loading">
|
||||
<Skeleton height="0.875rem" />
|
||||
<Skeleton height="0.6875rem" width="60%" />
|
||||
<Skeleton height="0.875rem" />
|
||||
<Skeleton height="0.6875rem" width="60%" />
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="You're all caught up"
|
||||
description="No new notifications."
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isEmpty && (
|
||||
<ul className="portal-notif__list">
|
||||
{visible.map((item) => (
|
||||
<li key={item.id} className="portal-notif__item">
|
||||
<span
|
||||
className="portal-notif__dot"
|
||||
style={{ background: CATEGORY_COLOUR[item.category] }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="portal-notif__body">
|
||||
<div className="portal-notif__item-title">{item.title}</div>
|
||||
<div className="portal-notif__desc">{item.description}</div>
|
||||
<div className="portal-notif__time">{item.time}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="portal-notif__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="portal-notif__action"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={!hasUnread}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
<button type="button" className="portal-notif__action">
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.portal-usecases {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-usecases__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-usecases__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-usecases__viewall,
|
||||
.portal-usecases__cta {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-usecases__viewall {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-usecases__viewall:hover,
|
||||
.portal-usecases__cta:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.portal-usecases__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.portal-usecases__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-usecases__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-usecases__eyebrow {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.portal-usecases__card-title {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-usecases__blurb {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-usecases__cta {
|
||||
margin-top: 0.5rem;
|
||||
align-self: flex-start;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Card, type CardProps } from "@shared/components";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import "@portal/components/PopularUseCases.css";
|
||||
|
||||
type Accent = NonNullable<CardProps["accent"]>;
|
||||
|
||||
interface UseCase {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
cta: string;
|
||||
accent: Accent;
|
||||
}
|
||||
|
||||
const ACCENT_COLOR: Record<Accent, string> = {
|
||||
blue: "var(--color-blue)",
|
||||
purple: "var(--color-purple)",
|
||||
green: "var(--color-green)",
|
||||
amber: "var(--color-amber)",
|
||||
red: "var(--color-red)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Curated landing-page use cases — a teaser, not the full catalogue. The
|
||||
* exhaustive per-vertical endpoint list lives on the Documents view; here we
|
||||
* surface the four cross-cutting pipelines people reach for first. Copy mirrors
|
||||
* the prototype's "Popular use cases" block.
|
||||
*/
|
||||
const USE_CASES: UseCase[] = [
|
||||
{
|
||||
eyebrow: "AUTO-ROUTING",
|
||||
title: "Auto-classify and route incoming documents",
|
||||
blurb:
|
||||
"One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow.",
|
||||
cta: "Build a classifier pipeline",
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
eyebrow: "PII REDACTION",
|
||||
title: "Redact PII before it leaves your stack",
|
||||
blurb:
|
||||
"Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline.",
|
||||
cta: "See redaction pipelines",
|
||||
accent: "red",
|
||||
},
|
||||
{
|
||||
eyebrow: "TRAINING DATA",
|
||||
title: "Turn PDFs into training data",
|
||||
blurb:
|
||||
"Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable.",
|
||||
cta: "Build a training-data pipeline",
|
||||
accent: "purple",
|
||||
},
|
||||
{
|
||||
eyebrow: "AUTHENTICITY",
|
||||
title: "Verify signatures and detect tampering",
|
||||
blurb:
|
||||
"Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code.",
|
||||
cta: "Try authenticity check",
|
||||
accent: "green",
|
||||
},
|
||||
];
|
||||
|
||||
export function PopularUseCases() {
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
<section className="portal-usecases" aria-label="Popular use cases">
|
||||
<header className="portal-usecases__head">
|
||||
<h2 className="portal-usecases__title">Popular use cases</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-usecases__viewall"
|
||||
onClick={() => setActiveView("pipelines")}
|
||||
>
|
||||
View all pipelines <span aria-hidden>→</span>
|
||||
</button>
|
||||
</header>
|
||||
<div className="portal-usecases__grid">
|
||||
{USE_CASES.map((uc) => (
|
||||
<Card
|
||||
key={uc.eyebrow}
|
||||
accent={uc.accent}
|
||||
padding="loose"
|
||||
className="portal-usecases__card"
|
||||
>
|
||||
<span
|
||||
className="portal-usecases__eyebrow"
|
||||
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||
>
|
||||
{uc.eyebrow}
|
||||
</span>
|
||||
<h3 className="portal-usecases__card-title">{uc.title}</h3>
|
||||
<p className="portal-usecases__blurb">{uc.blurb}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-usecases__cta"
|
||||
style={{ color: ACCENT_COLOR[uc.accent] }}
|
||||
onClick={() => setActiveView("pipelines")}
|
||||
>
|
||||
{uc.cta} <span aria-hidden>→</span>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
.portal-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.portal-activity__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-activity__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-activity__more {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-activity__more:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-activity__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.portal-activity__item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-activity__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.portal-activity__item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-activity__rail {
|
||||
width: 0.1875rem;
|
||||
border-radius: var(--radius-pill);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-activity__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-activity__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-activity__action {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-activity__time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-activity__subject {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 0.0625rem;
|
||||
}
|
||||
|
||||
.portal-activity__detail-row {
|
||||
margin-top: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-activity__detail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-activity__item--skeleton {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.portal-activity__item--skeleton .portal-activity__rail {
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-activity__skel {
|
||||
height: 0.75rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-muted) 0%,
|
||||
var(--color-bg-hover) 50%,
|
||||
var(--color-bg-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-activity__skel--md {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.portal-activity__skel--sm {
|
||||
width: 50%;
|
||||
height: 0.625rem;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { RecentActivity } from "@portal/components/RecentActivity";
|
||||
|
||||
const meta: Meta<typeof RecentActivity> = {
|
||||
title: "Portal/Home/RecentActivity",
|
||||
component: RecentActivity,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "40rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RecentActivity>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/activity", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.get("/v1/activity", () => HttpResponse.json([]))],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Card, EmptyState, Skeleton, StatusBadge } from "@shared/components";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchRecentActivity,
|
||||
type ActivityEvent,
|
||||
type ActivityKind,
|
||||
} from "@portal/api/home";
|
||||
import "@portal/components/RecentActivity.css";
|
||||
|
||||
const KIND_COLOUR: Record<ActivityKind, string> = {
|
||||
"pipeline-run": "var(--color-blue)",
|
||||
deploy: "var(--color-green)",
|
||||
drift: "var(--color-amber)",
|
||||
eval: "var(--color-purple)",
|
||||
agent: "var(--color-cat-insurance)",
|
||||
billing: "var(--color-red)",
|
||||
};
|
||||
|
||||
export function RecentActivity() {
|
||||
const state = useAsync<ActivityEvent[]>(() => fetchRecentActivity(), []);
|
||||
const { data: events } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="none"
|
||||
className="portal-activity"
|
||||
aria-label="Recent activity"
|
||||
>
|
||||
<header className="portal-activity__head">
|
||||
<h2 className="portal-activity__title">Recent activity</h2>
|
||||
<button type="button" className="portal-activity__more">
|
||||
View all →
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isLoading && (
|
||||
<ul className="portal-activity__list" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="portal-activity__item portal-activity__item--skeleton"
|
||||
>
|
||||
<span className="portal-activity__rail" />
|
||||
<div className="portal-activity__body">
|
||||
<Skeleton width="70%" />
|
||||
<Skeleton width="50%" height="0.625rem" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="Nothing here yet"
|
||||
description="Pipeline runs, deploys and agent events will appear here."
|
||||
/>
|
||||
)}
|
||||
|
||||
{events && events.length > 0 && (
|
||||
<ol className="portal-activity__list">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="portal-activity__item">
|
||||
<span
|
||||
className="portal-activity__rail"
|
||||
style={{ background: KIND_COLOUR[event.kind] }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="portal-activity__body">
|
||||
<div className="portal-activity__row">
|
||||
<span className="portal-activity__action">
|
||||
{event.action}
|
||||
</span>
|
||||
<span className="portal-activity__time">{event.time}</span>
|
||||
</div>
|
||||
<div className="portal-activity__subject">{event.subject}</div>
|
||||
<div className="portal-activity__detail-row">
|
||||
<span className="portal-activity__detail">
|
||||
{event.detail}
|
||||
</span>
|
||||
<StatusBadge tone={event.status} size="sm">
|
||||
{event.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.portal-search__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.25rem 0.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-search__input {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-search__input::placeholder {
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
.portal-search__esc {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-search__results {
|
||||
padding-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.portal-search__group-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
padding: 0 0.5rem 0.375rem;
|
||||
}
|
||||
|
||||
.portal-search__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-2);
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portal-search__item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-search__item-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useEffect } from "react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
function ForceOpen() {
|
||||
const { openSearch } = useUI();
|
||||
useEffect(() => {
|
||||
openSearch();
|
||||
}, [openSearch]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchModal> = {
|
||||
title: "Portal/Header/SearchModal",
|
||||
component: SearchModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<ForceOpen />
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SearchModal>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const EmptyCatalogue: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EmptyState, Modal, Skeleton } from "@shared/components";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
|
||||
import { SearchIcon } from "@portal/components/icons";
|
||||
import "@portal/components/SearchModal.css";
|
||||
|
||||
export function SearchModal() {
|
||||
const { searchOpen, closeSearch } = useUI();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
|
||||
const { data: actions } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) {
|
||||
setQuery("");
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [searchOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!actions) return [] as QuickAction[];
|
||||
if (!query.trim()) return actions;
|
||||
const needle = query.trim().toLowerCase();
|
||||
return actions.filter((item) => item.label.toLowerCase().includes(needle));
|
||||
}, [actions, query]);
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
|
||||
if (!acc[item.group]) acc[item.group] = [];
|
||||
acc[item.group].push(item);
|
||||
return acc;
|
||||
}, {}),
|
||||
[filtered],
|
||||
);
|
||||
|
||||
const groupKeys = Object.keys(groups);
|
||||
const isEmpty = !isLoading && groupKeys.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={searchOpen}
|
||||
onClose={closeSearch}
|
||||
width="lg"
|
||||
ariaLabel="Search"
|
||||
>
|
||||
<div className="portal-search">
|
||||
<div className="portal-search__input-row">
|
||||
<SearchIcon size={16} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search Stirling — endpoints, pipelines, docs…"
|
||||
aria-label="Search"
|
||||
className="portal-search__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="portal-search__esc" aria-hidden>
|
||||
ESC
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="portal-search__results">
|
||||
{isLoading && (
|
||||
<div className="portal-search__loading">
|
||||
<Skeleton height="1rem" width="40%" />
|
||||
<Skeleton height="0.875rem" />
|
||||
<Skeleton height="0.875rem" width="80%" />
|
||||
<Skeleton height="0.875rem" width="65%" />
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={
|
||||
query.trim()
|
||||
? `No matches for "${query.trim()}"`
|
||||
: "No quick actions"
|
||||
}
|
||||
description={
|
||||
query.trim()
|
||||
? "Try a different keyword or browse the catalogue."
|
||||
: "Quick actions will appear here once they're available."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isEmpty &&
|
||||
Object.entries(groups).map(([group, items]) => (
|
||||
<div key={group} className="portal-search__group">
|
||||
<div className="portal-search__group-label">{group}</div>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="portal-search__item"
|
||||
onClick={closeSearch}
|
||||
>
|
||||
<span className="portal-search__item-label">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="portal-search__item-hint">
|
||||
{item.hint}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
.portal-sidebar {
|
||||
width: 15rem;
|
||||
height: 100vh;
|
||||
background: var(--color-sidebar-bg);
|
||||
border-right: 1px solid var(--color-sidebar-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Logo block */
|
||||
.portal-sidebar__logo {
|
||||
height: 3.1875rem; /* 51px */
|
||||
padding: 0 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-sidebar-divider);
|
||||
}
|
||||
|
||||
.portal-sidebar__wordmark {
|
||||
font-family: var(--font-brand);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-logo-text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.portal-sidebar__workspace {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.portal-sidebar__workspace:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
/* Nav body */
|
||||
.portal-sidebar__nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0.625rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-sidebar__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.portal-sidebar__divider {
|
||||
height: 1px;
|
||||
background: var(--color-sidebar-divider);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.portal-sidebar__footer {
|
||||
border-top: 1px solid var(--color-sidebar-divider);
|
||||
padding: 0.5rem 0.625rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-sidebar__usage {
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-usage-text);
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-label {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-value {
|
||||
color: var(--color-usage-value);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-track {
|
||||
margin-top: 0.5rem;
|
||||
height: 0.25rem;
|
||||
background: var(--color-usage-track);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-sidebar__usage-fill {
|
||||
height: 100%;
|
||||
background: var(--grad-blue-btn);
|
||||
transition: width var(--motion-slow);
|
||||
}
|
||||
|
||||
.portal-sidebar__plan {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portal-sidebar__plan-dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-green);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Sidebar } from "@portal/components/Sidebar";
|
||||
|
||||
const meta: Meta<typeof Sidebar> = {
|
||||
title: "Portal/Shell/Sidebar",
|
||||
component: Sidebar,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100vh",
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Sidebar>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const FreeTier: Story = { globals: { tier: "free" } };
|
||||
|
||||
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
|
||||
@@ -0,0 +1,174 @@
|
||||
import { NavItem } from "@shared/components";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import {
|
||||
HomeIcon,
|
||||
EditorIcon,
|
||||
SourcesIcon,
|
||||
PipelinesIcon,
|
||||
DocumentsIcon,
|
||||
InfrastructureIcon,
|
||||
UsageIcon,
|
||||
DocsIcon,
|
||||
SettingsIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@portal/components/icons";
|
||||
import "@portal/components/Sidebar.css";
|
||||
|
||||
interface NavEntry {
|
||||
id: ViewId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const GROUP_PRIMARY: NavEntry[] = [
|
||||
{ id: "home", label: "Home", icon: <HomeIcon /> },
|
||||
];
|
||||
|
||||
const GROUP_OPERATIONAL: NavEntry[] = [
|
||||
{ id: "editor", label: "Editor", icon: <EditorIcon /> },
|
||||
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
|
||||
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
|
||||
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
|
||||
];
|
||||
|
||||
const GROUP_PLATFORM: NavEntry[] = [
|
||||
{
|
||||
id: "infrastructure",
|
||||
label: "Infrastructure",
|
||||
icon: <InfrastructureIcon />,
|
||||
},
|
||||
{ id: "usage", label: "Usage & Billing", icon: <UsageIcon /> },
|
||||
{ id: "docs", label: "Developer Docs", icon: <DocsIcon /> },
|
||||
];
|
||||
|
||||
function StirlingMark() {
|
||||
// The Stirling brand mark — two stacked parallelograms in the brand blues.
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
aria-hidden
|
||||
role="presentation"
|
||||
>
|
||||
<path d="M5 4 L20 4 L17 11 L2 11 Z" fill="var(--color-blue)" />
|
||||
<path d="M5 11 L17 11 L14 18 L2 18 Z" fill="var(--color-blue-border)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageFooter() {
|
||||
const { tier } = useTier();
|
||||
// Read the same endpoint Home's KPI strip uses so the doc count here can't
|
||||
// drift from the headline figure. The first KPI is always the doc total.
|
||||
const { data: kpis, loading } = useAsync<KpiEntry[]>(
|
||||
() => fetchHomeKpis(tier),
|
||||
[tier],
|
||||
);
|
||||
const docs = loading ? undefined : kpis?.[0]?.value;
|
||||
|
||||
if (tier === "free") {
|
||||
// The free doc KPI is formatted "used / cap"; parse it for the meter.
|
||||
const [used, cap] =
|
||||
typeof docs === "string"
|
||||
? docs.split("/").map((s) => Number(s.replace(/[^\d]/g, "")))
|
||||
: [];
|
||||
const pct = used && cap ? (used / cap) * 100 : 0;
|
||||
return (
|
||||
<div className="portal-sidebar__usage portal-sidebar__usage--free">
|
||||
<div className="portal-sidebar__usage-line">
|
||||
<span className="portal-sidebar__usage-label">Docs processed</span>
|
||||
<span className="portal-sidebar__usage-value">{docs ?? "—"}</span>
|
||||
</div>
|
||||
<div
|
||||
className="portal-sidebar__usage-track"
|
||||
role="progressbar"
|
||||
aria-valuenow={used ?? 0}
|
||||
aria-valuemax={cap ?? 100}
|
||||
>
|
||||
<div
|
||||
className="portal-sidebar__usage-fill"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const planLabel = tier === "pro" ? "Pay-as-you-go" : "Enterprise Plan";
|
||||
|
||||
return (
|
||||
<div className="portal-sidebar__usage">
|
||||
<div className="portal-sidebar__usage-line">
|
||||
<span className="portal-sidebar__plan">
|
||||
<span className="portal-sidebar__plan-dot" aria-hidden />
|
||||
{planLabel}
|
||||
</span>
|
||||
<span className="portal-sidebar__usage-value">
|
||||
{docs != null ? `${docs} docs` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { activeView, setActiveView } = useView();
|
||||
|
||||
function renderGroup(entries: NavEntry[]) {
|
||||
return entries.map((entry) => (
|
||||
<NavItem
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
label={entry.label}
|
||||
icon={entry.icon}
|
||||
isActive={activeView === entry.id}
|
||||
onClick={(id) => setActiveView(id as ViewId)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="portal-sidebar" aria-label="Primary navigation">
|
||||
<div className="portal-sidebar__logo">
|
||||
<StirlingMark />
|
||||
<span className="portal-sidebar__wordmark">Stirling</span>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-sidebar__workspace"
|
||||
aria-label="Switch workspace"
|
||||
>
|
||||
<ChevronDownIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="portal-sidebar__nav">
|
||||
<div className="portal-sidebar__group">
|
||||
{renderGroup(GROUP_PRIMARY)}
|
||||
</div>
|
||||
<div className="portal-sidebar__divider" aria-hidden />
|
||||
<div className="portal-sidebar__group">
|
||||
{renderGroup(GROUP_OPERATIONAL)}
|
||||
</div>
|
||||
<div className="portal-sidebar__divider" aria-hidden />
|
||||
<div className="portal-sidebar__group">
|
||||
{renderGroup(GROUP_PLATFORM)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="portal-sidebar__footer">
|
||||
<NavItem
|
||||
id="settings"
|
||||
label="Settings"
|
||||
icon={<SettingsIcon />}
|
||||
isActive={activeView === "settings"}
|
||||
onClick={(id) => setActiveView(id as ViewId)}
|
||||
/>
|
||||
<UsageFooter />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
.portal-runner__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
gap: 1rem;
|
||||
min-height: 26rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.portal-runner__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-runner__left,
|
||||
.portal-runner__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Drop zone */
|
||||
.portal-runner__drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 1.25rem;
|
||||
border: 1.5px dashed var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-runner__drop.is-armed,
|
||||
.portal-runner__drop:hover {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-runner__drop.has-file {
|
||||
border-color: var(--color-green);
|
||||
background: var(--color-green-light);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-runner__drop-icon {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-runner__drop.is-armed .portal-runner__drop-icon,
|
||||
.portal-runner__drop.has-file .portal-runner__drop-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.portal-runner__drop-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-runner__drop-text strong {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-1);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-runner__drop-text span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-runner__sample-btn {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-runner__sample-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
/* Op picker */
|
||||
.portal-runner__section-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-runner__ops {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-runner__op {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-runner__op:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-runner__op.is-selected {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-runner__op[data-accent="purple"].is-selected {
|
||||
border-color: var(--color-purple);
|
||||
background: var(--color-purple-light);
|
||||
}
|
||||
.portal-runner__op[data-accent="green"].is-selected {
|
||||
border-color: var(--color-green);
|
||||
background: var(--color-green-light);
|
||||
}
|
||||
.portal-runner__op[data-accent="amber"].is-selected {
|
||||
border-color: var(--color-amber);
|
||||
background: var(--color-amber-light);
|
||||
}
|
||||
.portal-runner__op[data-accent="red"].is-selected {
|
||||
border-color: var(--color-red);
|
||||
background: var(--color-red-light);
|
||||
}
|
||||
|
||||
.portal-runner__op-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-runner__op-endpoint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-runner__op-blurb {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.portal-runner__op--skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
cursor: default;
|
||||
}
|
||||
.portal-runner__op--skeleton:hover {
|
||||
border-color: var(--color-border-light);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Right column states */
|
||||
.portal-runner__right {
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle);
|
||||
padding: 1.125rem;
|
||||
overflow: hidden;
|
||||
min-height: 22rem;
|
||||
}
|
||||
|
||||
.portal-runner__hint {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-runner__hint-eyebrow {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-blue);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-runner__hint h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-runner__hint h3 code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.portal-runner__hint p {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.portal-runner__hint p code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-2);
|
||||
background: var(--color-bg-muted);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.portal-runner__hint kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
background: var(--color-bg-muted);
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Running state */
|
||||
.portal-runner__running {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-blue-light);
|
||||
border: 1px solid var(--color-blue-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-runner__running-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.portal-runner__running code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-runner__spinner,
|
||||
.portal-runner__spinner-lg {
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-runner__spinner {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-runner__spinner-lg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* Done state */
|
||||
.portal-runner__result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.portal-runner__result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--code-bg-header);
|
||||
border-bottom: 1px solid var(--code-border);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
color: var(--code-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-runner__result-head code {
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
|
||||
.portal-runner__result-meta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--code-dim);
|
||||
}
|
||||
|
||||
.portal-runner__result-code {
|
||||
margin: 0;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
max-height: 22rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.portal-runner__footer-status {
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.portal-runner__footer-status code {
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
|
||||
|
||||
const meta: Meta<typeof SingleOpRunner> = {
|
||||
title: "Portal/Home/SingleOpRunner",
|
||||
component: SingleOpRunner,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { open: true, onClose: () => console.log("close") },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SingleOpRunner>;
|
||||
|
||||
export const Idle: Story = {};
|
||||
|
||||
export const PreSelectedOp: Story = {
|
||||
args: { initialOpId: "redact" },
|
||||
};
|
||||
|
||||
export const RunFailsWithUnknownOp: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post("/v1/ops/:opId/run", () =>
|
||||
HttpResponse.json({ error: "Unknown op" }, { status: 404 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SlowOp: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post("/v1/ops/:opId/run", async () => {
|
||||
await delay(4000);
|
||||
return HttpResponse.json({
|
||||
result: { schema: "demo", note: "took its time" },
|
||||
durationMs: 4000,
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Modal,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
} from "@shared/components";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchFeaturedOps,
|
||||
runSingleOp,
|
||||
type FeaturedOp,
|
||||
type OpResultMap,
|
||||
} from "@portal/api/ops";
|
||||
import "@portal/components/SingleOpRunner.css";
|
||||
|
||||
type Phase = "idle" | "running" | "done" | "error";
|
||||
|
||||
interface SingleOpRunnerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Optional pre-selected op id (e.g. carousel deep-link). */
|
||||
initialOpId?: string;
|
||||
}
|
||||
|
||||
const SAMPLE_DOCS = [
|
||||
"certificate-of-insurance.pdf",
|
||||
"loss-run-2025.pdf",
|
||||
"invoice-acme-corp-q1.pdf",
|
||||
"prior-auth-cigna-12471.pdf",
|
||||
"contract-acme-msa-v3.pdf",
|
||||
];
|
||||
|
||||
interface RunResult {
|
||||
result: OpResultMap;
|
||||
durationMs: number;
|
||||
opId: string;
|
||||
}
|
||||
|
||||
export function SingleOpRunner({
|
||||
open,
|
||||
onClose,
|
||||
initialOpId,
|
||||
}: SingleOpRunnerProps) {
|
||||
const { setActiveView } = useView();
|
||||
const opsState = useAsync<FeaturedOp[]>(() => fetchFeaturedOps(), []);
|
||||
const { data: ops } = opsState;
|
||||
const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } =
|
||||
useSectionFlags(opsState);
|
||||
|
||||
const [selectedOpId, setSelectedOpId] = useState<string | null>(
|
||||
initialOpId ?? null,
|
||||
);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [sample, setSample] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [runResult, setRunResult] = useState<RunResult | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// Default selection once the catalogue loads.
|
||||
useEffect(() => {
|
||||
if (!selectedOpId && ops && ops.length > 0) {
|
||||
setSelectedOpId(ops[0].id);
|
||||
}
|
||||
}, [ops, selectedOpId]);
|
||||
|
||||
const selectedOp = useMemo<FeaturedOp | null>(
|
||||
() => ops?.find((o) => o.id === selectedOpId) ?? null,
|
||||
[ops, selectedOpId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedOpId(initialOpId ?? ops?.[0]?.id ?? null);
|
||||
setPhase("idle");
|
||||
setSample(null);
|
||||
setRunResult(null);
|
||||
setErrorMsg(null);
|
||||
}
|
||||
// ops is intentionally not in deps — we don't want to reset state when
|
||||
// the catalogue arrives while the modal is open.
|
||||
}, [open, initialOpId]);
|
||||
|
||||
const pickSample = useCallback(() => {
|
||||
if (!ops) return;
|
||||
const idx = ops.findIndex((o) => o.id === selectedOpId);
|
||||
const safeIdx = idx < 0 ? 0 : idx;
|
||||
setSample(SAMPLE_DOCS[safeIdx % SAMPLE_DOCS.length]);
|
||||
}, [ops, selectedOpId]);
|
||||
|
||||
async function run() {
|
||||
if (!selectedOp) return;
|
||||
let activeSample = sample;
|
||||
if (!activeSample) {
|
||||
pickSample();
|
||||
// pickSample updates state; use the synchronously-derived value for the
|
||||
// call so we don't race.
|
||||
const idx = ops?.findIndex((o) => o.id === selectedOpId) ?? 0;
|
||||
activeSample = SAMPLE_DOCS[idx % SAMPLE_DOCS.length];
|
||||
}
|
||||
setPhase("running");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const res = await runSingleOp(selectedOp.id, activeSample);
|
||||
setRunResult({ ...res, opId: selectedOp.id });
|
||||
setPhase("done");
|
||||
} catch (err) {
|
||||
setPhase("error");
|
||||
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setPhase("idle");
|
||||
setRunResult(null);
|
||||
setErrorMsg(null);
|
||||
}
|
||||
|
||||
function buildPipelineWithOp() {
|
||||
onClose();
|
||||
setActiveView("pipelines");
|
||||
}
|
||||
|
||||
function onDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}
|
||||
function onDragLeave() {
|
||||
setDragOver(false);
|
||||
}
|
||||
function onDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) setSample(file.name);
|
||||
else pickSample();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
title="Try a PDF operation"
|
||||
subtitle="Drop a sample, pick an op, see what Stirling returns."
|
||||
footer={
|
||||
<>
|
||||
<div className="portal-runner__footer-status">
|
||||
{phase === "running" && selectedOp && (
|
||||
<>
|
||||
<span className="portal-runner__spinner" aria-hidden />
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
</>
|
||||
)}
|
||||
{phase === "done" && (
|
||||
<StatusBadge tone="success" size="sm">
|
||||
200 OK
|
||||
</StatusBadge>
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<StatusBadge tone="danger" size="sm">
|
||||
{errorMsg ?? "Failed"}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{phase === "done" ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={reset}>
|
||||
Run again
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={buildPipelineWithOp}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
Open the pipeline builder
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={run}
|
||||
disabled={phase === "running" || !selectedOp}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{phase === "running" ? "Running…" : "Run operation"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="portal-runner__layout">
|
||||
{/* Left column: file + op picker */}
|
||||
<section className="portal-runner__left">
|
||||
<div
|
||||
className={
|
||||
"portal-runner__drop" +
|
||||
(dragOver ? " is-armed" : "") +
|
||||
(sample ? " has-file" : "")
|
||||
}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div className="portal-runner__drop-icon" aria-hidden>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="portal-runner__drop-text">
|
||||
{sample ? (
|
||||
<>
|
||||
<strong>{sample}</strong>
|
||||
<span>Drop again or pick another sample to replace.</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Drop a PDF here</strong>
|
||||
<span>or use a sample document.</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-runner__sample-btn"
|
||||
onClick={pickSample}
|
||||
>
|
||||
{sample ? "Pick another sample" : "Use a sample"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="portal-runner__section-title">Featured ops</div>
|
||||
<div className="portal-runner__ops">
|
||||
{opsIsLoading &&
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={`op-skel-${i}`}
|
||||
className="portal-runner__op portal-runner__op--skeleton"
|
||||
aria-hidden
|
||||
>
|
||||
<Skeleton width="60%" />
|
||||
<Skeleton width="40%" height="0.625rem" />
|
||||
<Skeleton width="80%" height="0.625rem" />
|
||||
</div>
|
||||
))}
|
||||
{opsIsEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title="No featured ops yet"
|
||||
description="Once operations are published, they'll show up here."
|
||||
/>
|
||||
)}
|
||||
{ops?.map((op) => (
|
||||
<button
|
||||
key={op.id}
|
||||
type="button"
|
||||
className={
|
||||
"portal-runner__op" +
|
||||
(selectedOpId === op.id ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedOpId(op.id);
|
||||
if (phase === "done") {
|
||||
setPhase("idle");
|
||||
setRunResult(null);
|
||||
}
|
||||
}}
|
||||
data-accent={op.accent}
|
||||
>
|
||||
<span className="portal-runner__op-label">{op.label}</span>
|
||||
<span className="portal-runner__op-endpoint">
|
||||
{op.endpoint}
|
||||
</span>
|
||||
<span className="portal-runner__op-blurb">{op.blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right column: state-driven result panel */}
|
||||
<section className="portal-runner__right">
|
||||
{phase === "idle" && selectedOp && (
|
||||
<div className="portal-runner__hint">
|
||||
<div className="portal-runner__hint-eyebrow">Ready</div>
|
||||
<h3>
|
||||
Run <code>{selectedOp.label}</code> on{" "}
|
||||
<code>{sample ?? "a sample"}</code>
|
||||
</h3>
|
||||
<p>
|
||||
{selectedOp.blurb}. Press <kbd>Run operation</kbd> to invoke{" "}
|
||||
<code>POST {selectedOp.endpoint}</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "running" && selectedOp && (
|
||||
<div className="portal-runner__running">
|
||||
<div className="portal-runner__spinner-lg" aria-hidden />
|
||||
<div className="portal-runner__running-text">
|
||||
<div className="portal-runner__running-title">
|
||||
Running {selectedOp.label}…
|
||||
</div>
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{phase === "done" && selectedOp && runResult && (
|
||||
<div className="portal-runner__result">
|
||||
<header className="portal-runner__result-head">
|
||||
<StatusBadge tone="success" size="sm">
|
||||
Completed
|
||||
</StatusBadge>
|
||||
<code>POST {selectedOp.endpoint}</code>
|
||||
<span className="portal-runner__result-meta">
|
||||
{runResult.durationMs} ms
|
||||
</span>
|
||||
</header>
|
||||
<pre className="portal-runner__result-code">
|
||||
{JSON.stringify(runResult.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<div className="portal-runner__hint">
|
||||
<div
|
||||
className="portal-runner__hint-eyebrow"
|
||||
style={{ color: "var(--color-red)" }}
|
||||
>
|
||||
Failed
|
||||
</div>
|
||||
<h3>The operation didn’t complete</h3>
|
||||
<p>{errorMsg ?? "Unknown error"}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
.portal-chart {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.125rem 1.25rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-chart__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-chart__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
}
|
||||
|
||||
.portal-chart__value {
|
||||
margin-top: 0.125rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.portal-chart__delta {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.portal-chart__delta.is-up {
|
||||
color: var(--color-green-dark);
|
||||
background: var(--color-green-light);
|
||||
}
|
||||
|
||||
.portal-chart__delta.is-down {
|
||||
color: var(--color-red);
|
||||
background: var(--color-red-light);
|
||||
}
|
||||
|
||||
.portal-chart__svg {
|
||||
width: 100%;
|
||||
height: 15rem;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.portal-chart__gridline {
|
||||
stroke: var(--color-border-light);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
|
||||
.portal-chart__axis-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10.5px;
|
||||
fill: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-chart__line {
|
||||
fill: none;
|
||||
stroke: var(--color-blue);
|
||||
stroke-width: 1.75;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.portal-chart__scrub {
|
||||
stroke: var(--color-text-5);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 2 3;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.portal-chart__dot {
|
||||
fill: var(--color-blue);
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.portal-chart__tooltip {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
top: 4rem;
|
||||
padding: 0.4375rem 0.625rem;
|
||||
background: var(--color-tooltip-bg);
|
||||
color: var(--color-tooltip-text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.portal-chart__tooltip-date {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.portal-chart__tooltip-value {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { UsageAreaChart } from "@portal/components/UsageAreaChart";
|
||||
import { buildUsageSeries } from "@portal/mocks/home";
|
||||
|
||||
const meta: Meta<typeof UsageAreaChart> = {
|
||||
title: "Portal/Home/UsageAreaChart",
|
||||
component: UsageAreaChart,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "60rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UsageAreaChart>;
|
||||
|
||||
const series = buildUsageSeries();
|
||||
const total = series.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
export const Default: Story = {
|
||||
args: { data: series, totalValue: total.toLocaleString(), deltaPct: 0.12 },
|
||||
};
|
||||
|
||||
export const NegativeDelta: Story = {
|
||||
args: { data: series, totalValue: total.toLocaleString(), deltaPct: -0.08 },
|
||||
};
|
||||
|
||||
export const NoDelta: Story = {
|
||||
args: { data: series },
|
||||
};
|
||||
|
||||
export const Flat: Story = {
|
||||
args: {
|
||||
data: series.map((p) => ({ ...p, value: 1200 })),
|
||||
totalValue: "36,000",
|
||||
},
|
||||
};
|
||||
|
||||
export const HighVolatility: Story = {
|
||||
args: {
|
||||
data: series.map((p, i) => ({
|
||||
...p,
|
||||
value: Math.round(p.value * (1 + Math.sin(i) * 0.6)),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { data: [], totalValue: "—" },
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import type { UsagePoint } from "@portal/api/home";
|
||||
import "@portal/components/UsageAreaChart.css";
|
||||
|
||||
interface UsageAreaChartProps {
|
||||
data: UsagePoint[];
|
||||
/** Headline metric shown above the chart (e.g. total 30d, current value). */
|
||||
totalLabel?: string;
|
||||
totalValue?: string;
|
||||
deltaPct?: number;
|
||||
}
|
||||
|
||||
const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const;
|
||||
const VIEW = { width: 800, height: 240 } as const;
|
||||
|
||||
function formatTick(date: string): string {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export function UsageAreaChart({
|
||||
data,
|
||||
totalLabel = "Docs processed · last 30 days",
|
||||
totalValue,
|
||||
deltaPct,
|
||||
}: UsageAreaChartProps) {
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
const { points, areaPath, linePath, yMax, yTicks, xTickIndices } =
|
||||
useMemo(() => {
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
points: [] as Array<{ x: number; y: number; raw: UsagePoint }>,
|
||||
areaPath: "",
|
||||
linePath: "",
|
||||
yMax: 0,
|
||||
yTicks: [] as number[],
|
||||
xTickIndices: [] as number[],
|
||||
};
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
// Round yMax up to a "nice" number for ticks. Floor at 500 so an
|
||||
// all-zero series doesn't divide by zero (which would NaN every point).
|
||||
const niceMax = Math.max(Math.ceil(max / 500) * 500, 500);
|
||||
const yTicksCalc = [0, niceMax * 0.5, niceMax];
|
||||
|
||||
const innerW = VIEW.width - PADDING.left - PADDING.right;
|
||||
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
||||
const xStep = innerW / Math.max(data.length - 1, 1);
|
||||
|
||||
const pts = data.map((raw, i) => ({
|
||||
x: PADDING.left + i * xStep,
|
||||
y: PADDING.top + innerH - (raw.value / niceMax) * innerH,
|
||||
raw,
|
||||
}));
|
||||
|
||||
const linePathStr = pts
|
||||
.map(
|
||||
(p, i) =>
|
||||
`${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const baseY = PADDING.top + innerH;
|
||||
const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`;
|
||||
|
||||
// X ticks: 5 evenly spread.
|
||||
const xIdx: number[] = [];
|
||||
const tickCount = 5;
|
||||
for (let t = 0; t < tickCount; t++) {
|
||||
xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1)));
|
||||
}
|
||||
|
||||
return {
|
||||
points: pts,
|
||||
areaPath: areaPathStr,
|
||||
linePath: linePathStr,
|
||||
yMax: niceMax,
|
||||
yTicks: yTicksCalc,
|
||||
xTickIndices: xIdx,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
function onMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
||||
if (!svgRef.current || points.length === 0) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const xRatio = (e.clientX - rect.left) / rect.width;
|
||||
const svgX = xRatio * VIEW.width;
|
||||
// Find nearest point
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const d = Math.abs(points[i].x - svgX);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
setHoverIndex(nearestIdx);
|
||||
}
|
||||
|
||||
const hovered = hoverIndex !== null ? points[hoverIndex] : null;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<SVGSVGElement>) {
|
||||
if (points.length === 0) return;
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
setHoverIndex((idx) =>
|
||||
idx === null ? 0 : Math.min(points.length - 1, idx + 1),
|
||||
);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setHoverIndex((idx) =>
|
||||
idx === null ? points.length - 1 : Math.max(0, idx - 1),
|
||||
);
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setHoverIndex(0);
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setHoverIndex(points.length - 1);
|
||||
} else if (e.key === "Escape") {
|
||||
setHoverIndex(null);
|
||||
}
|
||||
}
|
||||
|
||||
const displayTotal =
|
||||
totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="portal-chart">
|
||||
<header className="portal-chart__head">
|
||||
<div>
|
||||
<div className="portal-chart__label">{totalLabel}</div>
|
||||
<div className="portal-chart__value">{displayTotal}</div>
|
||||
</div>
|
||||
{deltaPct !== undefined && (
|
||||
<div
|
||||
className={
|
||||
"portal-chart__delta " + (deltaPct >= 0 ? "is-up" : "is-down")
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
|
||||
{Math.abs(Math.round(deltaPct * 100))}% vs prior 30d
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={totalLabel}
|
||||
className="portal-chart__svg"
|
||||
tabIndex={points.length > 0 ? 0 : -1}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={() => setHoverIndex(null)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => setHoverIndex(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--color-blue)"
|
||||
stopOpacity="0.32"
|
||||
/>
|
||||
<stop offset="100%" stopColor="var(--color-blue)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Y gridlines + labels */}
|
||||
{yTicks.map((tick) => {
|
||||
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
|
||||
const y = PADDING.top + innerH - (tick / yMax) * innerH;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
x2={VIEW.width - PADDING.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
className="portal-chart__gridline"
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left - 6}
|
||||
y={y + 4}
|
||||
className="portal-chart__axis-label"
|
||||
textAnchor="end"
|
||||
>
|
||||
{formatNumber(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area + line */}
|
||||
<path d={areaPath} fill="url(#chart-fill)" />
|
||||
<path d={linePath} className="portal-chart__line" />
|
||||
|
||||
{/* X ticks */}
|
||||
{xTickIndices.map((idx) => {
|
||||
const p = points[idx];
|
||||
if (!p) return null;
|
||||
return (
|
||||
<text
|
||||
key={idx}
|
||||
x={p.x}
|
||||
y={VIEW.height - PADDING.bottom + 18}
|
||||
className="portal-chart__axis-label"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{formatTick(p.raw.date)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hover scrub + dot + tooltip */}
|
||||
{hovered && (
|
||||
<>
|
||||
<line
|
||||
x1={hovered.x}
|
||||
x2={hovered.x}
|
||||
y1={PADDING.top}
|
||||
y2={VIEW.height - PADDING.bottom}
|
||||
className="portal-chart__scrub"
|
||||
/>
|
||||
<circle
|
||||
cx={hovered.x}
|
||||
cy={hovered.y}
|
||||
r={4}
|
||||
className="portal-chart__dot"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{hovered && (
|
||||
<div
|
||||
className="portal-chart__tooltip"
|
||||
style={{
|
||||
left: `${(hovered.x / VIEW.width) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div className="portal-chart__tooltip-date">
|
||||
{new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div className="portal-chart__tooltip-value">
|
||||
{hovered.raw.value.toLocaleString()} docs
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{hovered
|
||||
? `${new Date(hovered.raw.date).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}: ${hovered.raw.value.toLocaleString()} docs`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
.portal-carousel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--grad-banner);
|
||||
border: 1px solid var(--color-border);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.portal-carousel__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
animation: fadeInUp var(--motion-enter) both;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.portal-carousel__inner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-carousel__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-carousel__eyebrow {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-blue);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-carousel__title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-carousel__sub {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-3);
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.portal-carousel__cta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Ornament container */
|
||||
.portal-carousel__ornament {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Doc card ornament (slide 0) */
|
||||
.portal-carousel__doc {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.portal-carousel__doc-title {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.portal-carousel__doc-sub {
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.portal-carousel__doc-meta {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Code ornament (slide 1) */
|
||||
.portal-carousel__code {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
border: 1px solid var(--code-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.portal-carousel__code-method {
|
||||
color: var(--code-fn);
|
||||
font-weight: 600;
|
||||
}
|
||||
.portal-carousel__code-path {
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
.portal-carousel__code-key {
|
||||
color: var(--code-property);
|
||||
}
|
||||
.portal-carousel__code-string {
|
||||
color: var(--code-string);
|
||||
}
|
||||
|
||||
/* Eval ornament (slide 2) */
|
||||
.portal-carousel__eval {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.portal-carousel__eval-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.portal-carousel__eval-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.portal-carousel__eval-score {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-green);
|
||||
}
|
||||
.portal-carousel__eval-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.portal-carousel__eval-bar {
|
||||
margin-top: 0.75rem;
|
||||
height: 0.375rem;
|
||||
background: var(--color-bg-muted);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
.portal-carousel__eval-fill {
|
||||
height: 100%;
|
||||
background: var(--grad-green-btn);
|
||||
}
|
||||
.portal-carousel__eval-meta {
|
||||
margin-top: 0.625rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* Decoration blobs */
|
||||
.portal-carousel__decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.portal-carousel__blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(2.5rem);
|
||||
}
|
||||
.portal-carousel__blob--1 {
|
||||
top: -3rem;
|
||||
right: -2rem;
|
||||
width: 16rem;
|
||||
height: 16rem;
|
||||
background: radial-gradient(circle, var(--color-purple), transparent 70%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.portal-carousel__blob--2 {
|
||||
bottom: -4rem;
|
||||
right: 8rem;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
background: radial-gradient(circle, var(--color-blue), transparent 70%);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Pagination dots */
|
||||
.portal-carousel__dots {
|
||||
position: absolute;
|
||||
bottom: 0.875rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
z-index: 2;
|
||||
}
|
||||
.portal-carousel__dot {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-5);
|
||||
opacity: 0.4;
|
||||
transition:
|
||||
opacity var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
width var(--motion-fast);
|
||||
}
|
||||
.portal-carousel__dot:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.portal-carousel__dot.is-active {
|
||||
width: 1rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-blue);
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
|
||||
|
||||
const meta: Meta<typeof WelcomeCarousel> = {
|
||||
title: "Portal/Home/WelcomeCarousel",
|
||||
component: WelcomeCarousel,
|
||||
args: { onTryOp: () => console.log("try op") },
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "64rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WelcomeCarousel>;
|
||||
|
||||
export const AutoRotating: Story = {};
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Button, StatusBadge } from "@shared/components";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import "@portal/components/WelcomeCarousel.css";
|
||||
|
||||
type SlideAction =
|
||||
| { label: string; target: ViewId }
|
||||
| { label: string; action: "try-op" };
|
||||
|
||||
interface Slide {
|
||||
id: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
sub: string;
|
||||
durationMs: number;
|
||||
primary: SlideAction;
|
||||
secondary: SlideAction;
|
||||
ornament: ReactNode;
|
||||
}
|
||||
|
||||
function EditorOrnament() {
|
||||
return (
|
||||
<div className="portal-carousel__doc">
|
||||
<StatusBadge tone="danger" size="sm" pulse>
|
||||
Critical
|
||||
</StatusBadge>
|
||||
<div className="portal-carousel__doc-title">
|
||||
Vulnerability Assessment Report
|
||||
</div>
|
||||
<div className="portal-carousel__doc-sub">CVE-2026-1847 · 12 pages</div>
|
||||
<div className="portal-carousel__doc-meta">
|
||||
<span>signed</span>
|
||||
<span>·</span>
|
||||
<span>OCR-clean</span>
|
||||
<span>·</span>
|
||||
<span>schema match 0.97</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformOrnament() {
|
||||
return (
|
||||
<pre className="portal-carousel__code">
|
||||
<span className="portal-carousel__code-method">POST</span>{" "}
|
||||
<span className="portal-carousel__code-path">/v1/secure</span>
|
||||
{"\n"}
|
||||
<span className="portal-carousel__code-key">Authorization</span>:{" "}
|
||||
<span className="portal-carousel__code-string">Bearer sk_live_a3f8…</span>
|
||||
{"\n"}
|
||||
<span className="portal-carousel__code-key">file</span>:{" "}
|
||||
<span className="portal-carousel__code-string">
|
||||
federal_CUI_contract.pdf
|
||||
</span>
|
||||
{"\n"}
|
||||
<span className="portal-carousel__code-key">pipeline</span>:{" "}
|
||||
<span className="portal-carousel__code-string">cui-redact-v2</span>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentOrnament() {
|
||||
return (
|
||||
<div className="portal-carousel__eval">
|
||||
<div className="portal-carousel__eval-head">
|
||||
<span className="portal-carousel__eval-title">KYC Processor</span>
|
||||
<span className="portal-carousel__eval-score">94%</span>
|
||||
</div>
|
||||
<div className="portal-carousel__eval-sub">
|
||||
Eval pass rate · 28 test cases
|
||||
</div>
|
||||
<div className="portal-carousel__eval-bar">
|
||||
<div
|
||||
className="portal-carousel__eval-fill"
|
||||
style={{ width: "94%" }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="portal-carousel__eval-meta">
|
||||
<span>26 passed</span>
|
||||
<span>2 review</span>
|
||||
<span>MCP · Claude</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SLIDES: Slide[] = [
|
||||
{
|
||||
id: "editor",
|
||||
eyebrow: "PDF Editor",
|
||||
title: "The #1 PDF Editor on GitHub",
|
||||
sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.",
|
||||
durationMs: 12000,
|
||||
primary: { label: "Install PDF Editor", target: "editor" },
|
||||
secondary: { label: "Connect an instance", target: "editor" },
|
||||
ornament: <EditorOrnament />,
|
||||
},
|
||||
{
|
||||
id: "platform",
|
||||
eyebrow: "Platform",
|
||||
title: "PDF Infrastructure for Developers",
|
||||
sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.",
|
||||
durationMs: 8000,
|
||||
primary: { label: "Try a PDF operation", action: "try-op" },
|
||||
secondary: { label: "Get an API key", target: "infrastructure" },
|
||||
ornament: <PlatformOrnament />,
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
eyebrow: "AI Agents",
|
||||
title: "PDF Processor for AI Agents",
|
||||
sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.",
|
||||
durationMs: 8000,
|
||||
primary: { label: "Try PDF Processor", target: "sources" },
|
||||
secondary: { label: "View MCP docs", target: "docs" },
|
||||
ornament: <AgentOrnament />,
|
||||
},
|
||||
];
|
||||
|
||||
interface WelcomeCarouselProps {
|
||||
/** Called when a slide CTA wants to invoke the single-op runner. */
|
||||
onTryOp: () => void;
|
||||
}
|
||||
|
||||
export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const { setActiveView } = useView();
|
||||
|
||||
function runAction(act: SlideAction) {
|
||||
if ("action" in act && act.action === "try-op") {
|
||||
onTryOp();
|
||||
} else if ("target" in act) {
|
||||
setActiveView(act.target);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const duration = SLIDES[index].durationMs;
|
||||
const t = window.setTimeout(() => {
|
||||
setIndex((i) => (i + 1) % SLIDES.length);
|
||||
}, duration);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [index, paused]);
|
||||
|
||||
const slide = SLIDES[index];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="portal-carousel"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocus={() => setPaused(true)}
|
||||
onBlur={(e) => {
|
||||
// onBlur bubbles from children; only unpause when focus actually
|
||||
// leaves the carousel, not when tabbing between the dot buttons.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setPaused(false);
|
||||
}
|
||||
}}
|
||||
aria-label="Stirling product highlights"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div className="portal-carousel__inner" key={slide.id}>
|
||||
<div className="portal-carousel__text">
|
||||
<div className="portal-carousel__eyebrow">{slide.eyebrow}</div>
|
||||
<h1 className="portal-carousel__title">{slide.title}</h1>
|
||||
<p className="portal-carousel__sub">{slide.sub}</p>
|
||||
<div className="portal-carousel__cta">
|
||||
<Button
|
||||
variant="gradient"
|
||||
onClick={() => runAction(slide.primary)}
|
||||
trailingIcon={<span aria-hidden>→</span>}
|
||||
>
|
||||
{slide.primary.label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => runAction(slide.secondary)}
|
||||
>
|
||||
{slide.secondary.label}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="portal-carousel__ornament">{slide.ornament}</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-carousel__decor" aria-hidden>
|
||||
<div className="portal-carousel__blob portal-carousel__blob--1" />
|
||||
<div className="portal-carousel__blob portal-carousel__blob--2" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="portal-carousel__dots"
|
||||
role="group"
|
||||
aria-label="Carousel pagination"
|
||||
>
|
||||
{SLIDES.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
aria-current={i === index ? "true" : undefined}
|
||||
aria-label={`Slide ${i + 1}: ${s.title}`}
|
||||
className={
|
||||
"portal-carousel__dot" + (i === index ? " is-active" : "")
|
||||
}
|
||||
onClick={() => setIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Svg({
|
||||
size = 18,
|
||||
className,
|
||||
children,
|
||||
}: IconProps & { children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="m10 13-2 2 2 2" />
|
||||
<path d="m14 17 2-2-2-2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourcesIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M9 2v6" />
|
||||
<path d="M15 2v6" />
|
||||
<path d="M6 8h12v3a6 6 0 0 1-12 0z" />
|
||||
<path d="M12 14v8" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PipelinesIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="15" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="15" width="6" height="6" rx="1" />
|
||||
<path d="M6 9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V9" />
|
||||
<path d="M12 14v1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentsIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" />
|
||||
<line x1="8" y1="17" x2="16" y2="17" />
|
||||
<line x1="8" y1="9" x2="10" y2="9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfrastructureIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="4" width="18" height="6" rx="1" />
|
||||
<rect x="3" y="14" width="18" height="6" rx="1" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
<line x1="7" y1="17" x2="7.01" y2="17" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<line x1="6" y1="20" x2="6" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="18" y1="20" x2="18" y2="14" />
|
||||
<line x1="3" y1="20" x2="21" y2="20" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M2 4.5A2.5 2.5 0 0 1 4.5 2H20v17H4.5A2.5 2.5 0 0 0 2 21.5z" />
|
||||
<path d="M2 19.5A2.5 2.5 0 0 1 4.5 17H20" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<line x1="12" y1="2" x2="12" y2="4" />
|
||||
<line x1="12" y1="20" x2="12" y2="22" />
|
||||
<line x1="4.93" y1="4.93" x2="6.34" y2="6.34" />
|
||||
<line x1="17.66" y1="17.66" x2="19.07" y2="19.07" />
|
||||
<line x1="2" y1="12" x2="4" y2="12" />
|
||||
<line x1="20" y1="12" x2="22" y2="12" />
|
||||
<line x1="4.93" y1="19.07" x2="6.34" y2="17.66" />
|
||||
<line x1="17.66" y1="6.34" x2="19.07" y2="4.93" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparklesIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" />
|
||||
<path d="M19 4v3" />
|
||||
<path d="M17.5 5.5h3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<line x1="22" y1="2" x2="11" y2="13" />
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user