diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 2cfc42475c..fe0b70a4eb 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7182,26 +7182,6 @@ sources = "Sources" usage = "Usage & Billing" users = "Users" -[portal.notifications] -markAllRead = "Mark all read" -title = "Notifications" -viewAll = "View all" - -[portal.notifications.ariaLabel] -none = "Notifications, no unread" -unread_one = "Notifications, {{count}} unread" -unread_other = "Notifications, {{count}} unread" - -[portal.notifications.count] -allRead = "all read" -loading = "loading" -new_one = "{{count}} new" -new_other = "{{count}} new" - -[portal.notifications.empty] -description = "No new notifications." -title = "You're all caught up" - [portal.pipelines] subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." title = "Pipelines" @@ -7882,26 +7862,11 @@ admin = "Admin" [portal.settings.sections] account-link = "Account link" -[portal.shell.header] -accountFallback = "Account" -accountMenu = "Account menu" -darkMode = "Dark mode" -lightMode = "Light mode" -search = "Search" -searchPlaceholder = "Search…" -signOut = "Sign out" -switchToDark = "Switch to dark theme" -switchToLight = "Switch to light theme" - [portal.shell.sidebar] appEditor = "Editor" appProcessor = "Processor" brandSuffix = "Stirling Processor" -docsCount = "{{docs}} docs" linkAccount = "Link Stirling account" -planEditor = "Editor plan" -planEnterprise = "Enterprise plan" -planProcessor = "Processor plan" primaryNav = "Primary navigation" switchApp = "Switch app" diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index 606f021c0b..b3744d36bf 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -1,19 +1,17 @@ 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. + * Two-column layout: fixed-width sidebar on the left, a scrolling main column on + * the right. The Sidebar reads its state from context, so this shell stays + * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { return (
-
{children}
diff --git a/frontend/editor/src/portal/components/Header.css b/frontend/editor/src/portal/components/Header.css deleted file mode 100644 index d1c4ede717..0000000000 --- a/frontend/editor/src/portal/components/Header.css +++ /dev/null @@ -1,191 +0,0 @@ -.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); -} - -/* The icon / placeholder / ⌘K live inside the shared Button's label, so the - label must fill the button for the placeholder's flex:1 to push ⌘K to the - far right (otherwise the label is content-width and everything left-packs). */ -.portal-header__search .mantine-Button-inner { - width: 100%; -} -.portal-header__search .mantine-Button-label { - flex: 1; - gap: 0.5rem; -} - -.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 { - --sui-btn-py: 0.3125rem; - 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; -} diff --git a/frontend/editor/src/portal/components/Header.stories.tsx b/frontend/editor/src/portal/components/Header.stories.tsx deleted file mode 100644 index 25f2b9c2b5..0000000000 --- a/frontend/editor/src/portal/components/Header.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Header } from "@portal/components/Header"; - -const meta: Meta = { - title: "Portal/Shell/Header", - component: Header, - parameters: { layout: "fullscreen" }, - decorators: [ - (S) => ( -
- -
- Page body would render below the header. -
-
- ), - ], -}; -export default meta; -type Story = StoryObj; - -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" } }; diff --git a/frontend/editor/src/portal/components/Header.tsx b/frontend/editor/src/portal/components/Header.tsx deleted file mode 100644 index 317ef6040c..0000000000 --- a/frontend/editor/src/portal/components/Header.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { ActionIcon, Avatar, Button, Dropdown } from "@app/ui"; -import { useAuth } from "@app/auth"; -import { useTheme } from "@portal/contexts/ThemeContext"; -import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext"; -import { useView } 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 "@portal/components/Header.css"; - -function ThemeToggle() { - const { theme, toggle } = useTheme(); - const { t } = useTranslation(); - return ( - - {theme === "light" ? : } - - ); -} - -function TierSwitcher() { - const { tier, setTier, isDerived } = useTier(); - const info = TIER_INFO[tier]; - // In the app the tier is derived from the real link/wallet state and can't - // be switched by hand; the dropdown only renders where the tier is pinned - // (Storybook / demo surfaces — see TierProvider's initialTier). - if (isDerived) return null; - return ( - - - - - - {(Object.keys(TIER_INFO) as Tier[]).map((id) => ( - setTier(id)} - leading={ - - } - > - {TIER_INFO[id].label} - - ))} - - - ); -} - -function UserMenu() { - const { t } = useTranslation(); - const { displayName, signOut } = useAuth(); - const name = - displayName ?? t("portal.shell.header.accountFallback", "Account"); - return ( - - - - - - - - {name} - void signOut()}> - {t("portal.shell.header.signOut", "Sign out")} - - - - ); -} - -export function Header() { - const { activeView } = useView(); - const { openSearch } = useUI(); - const { t } = useTranslation(); - return ( -
-
- - {t(`portal.nav.${activeView}`)} - -
- - - -
- - - - -
-
- ); -} diff --git a/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx b/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx deleted file mode 100644 index 1470f5e447..0000000000 --- a/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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 = { - title: "Portal/Header/NotificationsDropdown", - component: NotificationsDropdown, - parameters: { layout: "centered" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -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 }), - ), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/NotificationsDropdown.tsx b/frontend/editor/src/portal/components/NotificationsDropdown.tsx deleted file mode 100644 index 1401837e53..0000000000 --- a/frontend/editor/src/portal/components/NotificationsDropdown.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { ActionIcon, Button, Dropdown, EmptyState, Skeleton } from "@app/ui"; -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 = { - 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 { t } = useTranslation(); - const state = useAsync(() => 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 ( - - - - - {hasUnread && ( - - )} - - - -
- - {t("portal.notifications.title")} - - {hasUnread ? ( - - {t("portal.notifications.count.new", { count: visible.length })} - - ) : isLoading ? ( - - {t("portal.notifications.count.loading")} - - ) : ( - - {t("portal.notifications.count.allRead")} - - )} -
- {isLoading && ( -
- - - - -
- )} - {isEmpty && ( - - )} - {!isLoading && !isEmpty && ( -
    - {visible.map((item) => ( -
  • - -
    -
    {item.title}
    -
    {item.description}
    -
    {item.time}
    -
    -
  • - ))} -
- )} -
- - -
-
-
- ); -} diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx index 49172784fc..1262c84b8e 100644 --- a/frontend/editor/src/portal/components/PortalChrome.tsx +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -4,7 +4,6 @@ import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { useUI } from "@portal/contexts/UIContext"; import { AppShell } from "@portal/components/AppShell"; -import { AssistantMount } from "@portal/components/AssistantMount"; import { SearchModal } from "@portal/components/SearchModal"; import { PortalSettingsHost } from "@portal/components/PortalSettingsHost"; import { ViewRouter } from "@portal/ViewRouter"; @@ -66,7 +65,6 @@ export function PortalChrome() { - diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 62d6226151..256b0708dd 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -77,37 +77,3 @@ 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-value { - color: var(--color-usage-value); - font-weight: 500; -} - -.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); -} diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 9bf7c8c373..356acc5349 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -3,12 +3,9 @@ import { AppSwitch } from "@app/components/shared/AppSwitch"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; 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 { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; @@ -21,36 +18,6 @@ import { } from "@portal/components/sidebarGroups"; import "@portal/components/Sidebar.css"; -function UsageFooter() { - const { tier } = useTier(); - const { t } = useTranslation(); - // Real 30-day processed-PDF count from the fleet-usage endpoint (the same - // source as Home's status strip, so the two can't drift). null → "—". - const { data, loading } = useAsync(() => fetchFleetStats(), []); - const docs = loading ? undefined : (data?.pdfsProcessed ?? undefined); - - const planLabel = - tier === "free" - ? t("portal.shell.sidebar.planEditor", "Editor plan") - : tier === "pro" - ? t("portal.shell.sidebar.planProcessor", "Processor plan") - : t("portal.shell.sidebar.planEnterprise", "Enterprise plan"); - - return ( -
-
- - - {planLabel} - - - {docs != null ? t("portal.shell.sidebar.docsCount", { docs }) : "—"} - -
-
- ); -} - export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); @@ -135,7 +102,6 @@ export function Sidebar() { icon={} onClick={() => openSettings()} /> - );