mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
import { Dropdown, NavItem } from "@shared/components";
|
|
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
|
import { useTier } from "@portal/contexts/TierContext";
|
|
import { useTheme } from "@portal/contexts/ThemeContext";
|
|
import { useUI } from "@portal/contexts/UIContext";
|
|
import { useAsync } from "@portal/hooks/useAsync";
|
|
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
|
import wordmarkLight from "@shared/assets/stirling-pdf-logo-light.svg";
|
|
import wordmarkDark from "@shared/assets/stirling-pdf-logo-dark.svg";
|
|
import markLight from "@shared/assets/stirling-mark-light.svg";
|
|
import markDark from "@shared/assets/stirling-mark-dark.svg";
|
|
import {
|
|
HomeIcon,
|
|
SourcesIcon,
|
|
PipelinesIcon,
|
|
DocumentsIcon,
|
|
InfrastructureIcon,
|
|
UsageIcon,
|
|
DocsIcon,
|
|
SettingsIcon,
|
|
ChevronDownIcon,
|
|
} from "@portal/components/icons";
|
|
import "@portal/components/Sidebar.css";
|
|
|
|
// TEMP app switcher. The editor is a separate Vite app — no shared shell yet
|
|
// (see PORTAL_INTEGRATION_PLAN.md), so switching is a hard navigation: the
|
|
// editor's own dev server in dev, the site root in prod. A standalone portal
|
|
// deploy will later gate this behind a configured editor URL.
|
|
const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/";
|
|
|
|
interface NavEntry {
|
|
id: ViewId;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
}
|
|
|
|
const GROUP_PRIMARY: NavEntry[] = [
|
|
{ id: "home", label: "Home", icon: <HomeIcon /> },
|
|
];
|
|
|
|
const GROUP_OPERATIONAL: NavEntry[] = [
|
|
{ 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 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();
|
|
const { theme } = useTheme();
|
|
const { openSettings } = useUI();
|
|
|
|
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">
|
|
<span className="portal-sidebar__brand">
|
|
<img
|
|
className="portal-sidebar__brand-logo"
|
|
src={theme === "dark" ? wordmarkDark : wordmarkLight}
|
|
alt="Stirling PDF"
|
|
/>
|
|
<span className="portal-sidebar__logo-suffix">portal</span>
|
|
</span>
|
|
|
|
<Dropdown.Root align="end" className="portal-sidebar__app-switch">
|
|
<Dropdown.Trigger>
|
|
<button
|
|
type="button"
|
|
className="portal-sidebar__app-switch-btn"
|
|
aria-label="Switch app"
|
|
>
|
|
<ChevronDownIcon size={14} />
|
|
</button>
|
|
</Dropdown.Trigger>
|
|
<Dropdown.Menu width="11rem">
|
|
<Dropdown.Item
|
|
active
|
|
leading={
|
|
<img
|
|
className="portal-sidebar__app-icon"
|
|
src={theme === "dark" ? markDark : markLight}
|
|
alt=""
|
|
/>
|
|
}
|
|
>
|
|
Portal
|
|
</Dropdown.Item>
|
|
<Dropdown.Item
|
|
onSelect={() => {
|
|
window.location.href = EDITOR_URL;
|
|
}}
|
|
leading={
|
|
<img
|
|
className="portal-sidebar__app-icon"
|
|
src={theme === "dark" ? markDark : markLight}
|
|
alt=""
|
|
/>
|
|
}
|
|
>
|
|
Editor
|
|
</Dropdown.Item>
|
|
</Dropdown.Menu>
|
|
</Dropdown.Root>
|
|
</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 />}
|
|
onClick={() => openSettings()}
|
|
/>
|
|
<UsageFooter />
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|