mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5731f97c18 | ||
|
|
c33bb51b67 | ||
|
|
dac5036887 | ||
|
|
9271813a51 | ||
|
|
dfe0246d05 | ||
|
|
a217d0c75d | ||
|
|
c7c7e7c405 | ||
|
|
8af623304c | ||
|
|
cbf78a46e8 | ||
|
|
8c4cdfe6b9 | ||
|
|
d870fe82f1 | ||
|
|
2343ec0486 | ||
|
|
ddfc1f1712 | ||
|
|
ebb8d1112d | ||
|
|
5cf9d91854 | ||
|
|
f1a4026b79 | ||
|
|
60f054e0c5 | ||
|
|
e06ceb99b2 | ||
|
|
c489d73904 | ||
|
|
d798d7a468 | ||
|
|
3b44dbaea0 | ||
|
|
40d80d1beb | ||
|
|
5cd0580303 | ||
|
|
713eaba1c7 | ||
|
|
a58954e190 | ||
|
|
95c20054f1 | ||
|
|
c535a09fd2 | ||
|
|
61ecf26034 | ||
|
|
9139b5fa51 | ||
|
|
a64bf3843b | ||
|
|
1aa99babd8 | ||
|
|
f64c110017 | ||
|
|
97bd412a30 | ||
|
|
9fd20f731c | ||
|
|
2264ddc705 | ||
|
|
97dfebc2f6 | ||
|
|
0798457a39 | ||
|
|
b1bcf02c13 | ||
|
|
8dab6812bd | ||
|
|
09d68b4e85 | ||
|
|
2e275a02b0 | ||
|
|
1a9a7ee3e3 | ||
|
|
50e92add40 | ||
|
|
c0fddcd2bd | ||
|
|
733bf8306e | ||
|
|
9302402fb5 | ||
|
|
b8fbe05222 | ||
|
|
097cec9b2a | ||
|
|
c62a175174 | ||
|
|
12daf0760a | ||
|
|
a74ddac70b | ||
|
|
30b61749b9 | ||
|
|
df6344353b | ||
|
|
70932e2c46 | ||
|
|
f9c6671bb7 | ||
|
|
4aba81c10b | ||
|
|
f25989c59e | ||
|
|
7a74fadd2a | ||
|
|
498e248401 | ||
|
|
0b07537892 | ||
|
|
aec9faeef6 | ||
|
|
413005490c | ||
|
|
6ee2e3b232 | ||
|
|
bbf34088e6 | ||
|
|
50d6b049cf | ||
|
|
df00d61480 | ||
|
|
562166419b | ||
|
|
2b77fb345a | ||
|
|
bedf797f46 | ||
|
|
86378dbc64 | ||
|
|
d560335a6c | ||
|
|
73704feb10 | ||
|
|
b49b04bd68 | ||
|
|
873caf5172 | ||
|
|
193b096495 | ||
|
|
1cdecfaf58 | ||
|
|
c6f8ed43fd | ||
|
|
05de5d7e2d | ||
|
|
449353150d | ||
|
|
0f6f5b4e0b | ||
|
|
941dc35cda | ||
|
|
93bc796e2f | ||
|
|
544a1f02eb | ||
|
|
6800db8029 | ||
|
|
2748fe3157 | ||
|
|
b7bcbc9f45 | ||
|
|
d7f51d7f72 | ||
|
|
2c0728c1a7 | ||
|
|
cca8c700d3 | ||
|
|
17b0fae153 | ||
|
|
c5fbf85035 | ||
|
|
ee6684f6a4 | ||
|
|
925caf6d11 | ||
|
|
a70f80279a | ||
|
|
99be0ae3c6 | ||
|
|
d8599e2c6f | ||
|
|
7b43bb483e | ||
|
|
5f6b88005e | ||
|
|
41f507a9f0 | ||
|
|
ffc0755b37 | ||
|
|
91e0815f93 | ||
|
|
e3b4bc2412 | ||
|
|
e9785b6b2f | ||
|
|
31b7c0069d | ||
|
|
a8a5f3a88f |
@@ -3304,7 +3304,6 @@ description = "Model Context Protocol (MCP) lets AI assistants like Claude use y
|
||||
guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf."
|
||||
navLabel = "MCP Server"
|
||||
tip = "Every action your assistant runs is performed as your account and counts toward your usage, just like using Stirling PDF directly."
|
||||
title = "MCP Server"
|
||||
viewApiKeys = "View API keys"
|
||||
|
||||
[config.mcp.copy]
|
||||
@@ -3876,10 +3875,10 @@ customizeGroups = "Customize groups"
|
||||
dropHint = "Open files to get started"
|
||||
dropToAdd = "Drop files to add"
|
||||
expand = "Expand sidebar"
|
||||
files = "Files"
|
||||
googleDrive = "Google Drive"
|
||||
googleDriveDisabled = "Google Drive is not configured"
|
||||
leaveMyFiles = "Leave My Files"
|
||||
library = "PDF Library"
|
||||
myFiles = "My Files"
|
||||
noFiles = "No files yet"
|
||||
openFileManager = "Browse all files & folders"
|
||||
@@ -4829,8 +4828,7 @@ welcomeTitle = "You've been invited!"
|
||||
addFiles = "Add Files"
|
||||
mobileUpload = "Upload from Mobile"
|
||||
openFromComputer = "Open from computer"
|
||||
uploadFromComputer = "Upload from computer"
|
||||
workbenchEmptyStateHero = "Drop a PDF anywhere"
|
||||
uploadFromComputer = "Browse files"
|
||||
|
||||
[language]
|
||||
direction = "ltr"
|
||||
@@ -4895,7 +4893,6 @@ signInWith = "Sign in with"
|
||||
title = "Sign in"
|
||||
unexpectedError = "Unexpected error: {{message}}"
|
||||
updatePassword = "Update password"
|
||||
useEmailInstead = "Login with email"
|
||||
useMagicLink = "Use magic link instead"
|
||||
username = "Username"
|
||||
youAreLoggedIn = "You are logged in!"
|
||||
@@ -8649,7 +8646,6 @@ account-link = "Account link"
|
||||
[portal.shell.sidebar]
|
||||
appEditor = "Editor"
|
||||
appProcessor = "Processor"
|
||||
brandSuffix = "Stirling Processor"
|
||||
linkAccount = "Link Stirling account"
|
||||
primaryNav = "Primary navigation"
|
||||
switchApp = "Switch app"
|
||||
@@ -10634,8 +10630,10 @@ backToAllTools = "Back to all tools"
|
||||
collapse = "Collapse panel"
|
||||
expand = "Expand panel"
|
||||
goBack = "Go back"
|
||||
pdfTools = "PDF Tools"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
searchTools = "Search tools"
|
||||
toolsHeader = "Tools"
|
||||
viewAllTools = "View all tools"
|
||||
|
||||
@@ -11592,7 +11590,6 @@ noMembersFound = "No members found"
|
||||
role = "Role"
|
||||
searchMembers = "Search members..."
|
||||
team = "Team"
|
||||
title = "People"
|
||||
unlockAccount = "Unlock Account"
|
||||
unlockUserError = "Failed to unlock user account"
|
||||
unlockUserSuccess = "User account unlocked successfully"
|
||||
@@ -11746,7 +11743,6 @@ system = "System"
|
||||
teamActions = "Team actions"
|
||||
teamName = "Team Name"
|
||||
teamNotFound = "Team not found"
|
||||
title = "Teams"
|
||||
totalMembers = "Total Members"
|
||||
viewTeam = "View Team"
|
||||
|
||||
|
||||
@@ -740,7 +740,6 @@ const PRIMITIVE_LAYER = [
|
||||
/^editor\/src\/core\/theme\//,
|
||||
/^editor\/src\/core\/styles\/theme\.css$/,
|
||||
/^editor\/src\/core\/tokens\/tokens\.css$/,
|
||||
/^editor\/src\/saas\/styles\/saas-theme\.css$/,
|
||||
/^editor\/src\/proprietary\/auth\/ui\/auth-theme\.css$/,
|
||||
/^editor\/src\/core\/ui\/accents\.css$/,
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
TextInput,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Stack,
|
||||
Alert,
|
||||
@@ -216,8 +217,9 @@ const TeamSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<div>
|
||||
{/* A personal team's name ("My Team") only restates the nav label, and it
|
||||
has no rename/leave actions - so the header row is dropped there. */}
|
||||
{!isPersonalTeam && (
|
||||
<Group justify="space-between" align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
{isEditingName ? (
|
||||
@@ -299,7 +301,7 @@ const TeamSection: React.FC = () => {
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{error && (
|
||||
@@ -316,8 +318,8 @@ const TeamSection: React.FC = () => {
|
||||
|
||||
{/* Invite Members */}
|
||||
{isTeamLeader && (
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t("team.invite.title", "Invite Team Member")}
|
||||
</Text>
|
||||
<form onSubmit={handleInvite}>
|
||||
@@ -347,12 +349,11 @@ const TeamSection: React.FC = () => {
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</div>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Team Members Table */}
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t("team.members.title", "Team Members")}
|
||||
</Text>
|
||||
<Table
|
||||
@@ -539,7 +540,7 @@ const TeamSection: React.FC = () => {
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="71" height="79" viewBox="0 0 71 79" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 39L46.5 0V35.5L0 74.5V39Z" fill="#AD7373"/>
|
||||
<path d="M24 43L70.5 4V39.5L24 78.5V43Z" fill="#8E3131"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.2 KiB |
@@ -181,11 +181,11 @@ const AddFileCard = ({
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--c-primary)]"
|
||||
style={{
|
||||
fontSize: ".8rem",
|
||||
textAlign: "center",
|
||||
marginTop: "0.5rem",
|
||||
color: "var(--c-text-muted)",
|
||||
}}
|
||||
>
|
||||
{terminology.dropFilesHere}
|
||||
|
||||
@@ -325,13 +325,19 @@
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
width: 260px;
|
||||
max-width: 260px;
|
||||
height: calc(310px - 0.5rem);
|
||||
margin: 0.5rem auto 0;
|
||||
background: var(--c-bg);
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: 12px;
|
||||
/* Fill the same slot a portrait page thumbnail does. Height: the 310px
|
||||
.thumbWrap minus the always-reserved 26px toolchain bar (22px + 4px) that
|
||||
sits above the page. Width: the file card's 260px minus its 10px side
|
||||
padding. Offset 36px down (that padding-top + the bar) so the two line up.
|
||||
A fixed size rather than an aspect-ratio, because each thumbnail derives
|
||||
--thumb-aspect from its own PDF's page dimensions. */
|
||||
height: calc(310px - 26px);
|
||||
width: calc(260px - 20px);
|
||||
max-width: 100%;
|
||||
margin: 36px auto auto;
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the
|
||||
/* Tree rows - mirror the shared .sui-navitem (core/ui/NavItem.css) so the
|
||||
slide-out folder navigator reads as a continuation of the main sidebar's
|
||||
design language. Same row height, padding, font weight, muted icon
|
||||
treatment. The hover/active state uses the same --hover-bg pill that
|
||||
@@ -116,11 +116,11 @@
|
||||
}
|
||||
|
||||
.files-page-tree-node:hover {
|
||||
background: var(--c-hover);
|
||||
background: var(--c-active);
|
||||
}
|
||||
|
||||
.files-page-tree-node.is-active {
|
||||
background: var(--c-hover);
|
||||
background: var(--c-active);
|
||||
color: var(--c-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/* AppConfigModal styles */
|
||||
|
||||
/* Nav rail is a surface, like the app's side panels; content is the canvas. */
|
||||
.mantine-Modal-content:has(.modal-container) {
|
||||
background: var(--c-bg);
|
||||
}
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 45rem; /* 720px */
|
||||
max-height: 90dvh; /* matches Mantine's default modal max-height so the outer wrapper never has to scroll */
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
@@ -13,6 +19,8 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--c-surface);
|
||||
border-right: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
/* Mobile: compact icon-only navigation */
|
||||
@@ -121,6 +129,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
.modal-content-scroll {
|
||||
@@ -166,6 +175,8 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--c-bg);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -173,6 +184,21 @@
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
/* Normalise every section's Paper/Card here; exclusions are inline overlays. */
|
||||
.modal-body
|
||||
:is(.mantine-Paper-root, .mantine-Card-root):not(
|
||||
.mantine-Menu-dropdown,
|
||||
.mantine-Popover-dropdown,
|
||||
.mantine-HoverCard-dropdown,
|
||||
.mantine-Modal-content,
|
||||
.mantine-Tooltip-tooltip
|
||||
) {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-nav);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-search-select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
@@ -214,7 +240,7 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--c-surface);
|
||||
background: var(--c-bg);
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
padding: 1rem 2rem;
|
||||
margin: 0 -2rem;
|
||||
|
||||
@@ -47,6 +47,8 @@ interface AppConfigModalProps {
|
||||
initialSection?: NavKey | null;
|
||||
/** Host-specific sections appended after the build's registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
/** Registry section keys to drop, for hosts a section can't run in. */
|
||||
hiddenSectionKeys?: NavKey[];
|
||||
}
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
@@ -65,6 +67,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
urlSync = true,
|
||||
initialSection,
|
||||
extraSections,
|
||||
hiddenSectionKeys,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Initialize from the URL so a deep link (`/settings/people`) lands on the
|
||||
@@ -165,13 +168,10 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
navBg: "var(--c-bg-raised)",
|
||||
sectionTitle: "var(--c-text-subtle)",
|
||||
navItem: "var(--modal-nav-item)",
|
||||
navItemActive: "var(--c-accent-fg)",
|
||||
navItemActiveBg: "var(--c-primary-subtle)",
|
||||
contentBg: "var(--c-surface)",
|
||||
headerBorder: "var(--c-border-subtle)",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -218,13 +218,17 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
handleCloseSync,
|
||||
config?.showSettingsWhenNoLogin ?? true,
|
||||
);
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
extraSections?.length
|
||||
? [...registrySections, ...extraSections]
|
||||
: registrySections,
|
||||
[registrySections, extraSections],
|
||||
);
|
||||
const configNavSections = useMemo(() => {
|
||||
const base = hiddenSectionKeys?.length
|
||||
? registrySections
|
||||
.map((s) => ({
|
||||
...s,
|
||||
items: s.items.filter((i) => !hiddenSectionKeys.includes(i.key)),
|
||||
}))
|
||||
.filter((s) => s.items.length > 0)
|
||||
: registrySections;
|
||||
return extraSections?.length ? [...base, ...extraSections] : base;
|
||||
}, [registrySections, extraSections, hiddenSectionKeys]);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
@@ -269,13 +273,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
>
|
||||
<div className="modal-container" data-tour="settings-modal">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className={`modal-nav ${isMobile ? "mobile" : ""}`}>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map((section) => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
@@ -394,13 +392,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
<div className="modal-content" data-tour="settings-content-area">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<Text fw={700} size="lg">
|
||||
{activeLabel}
|
||||
</Text>
|
||||
|
||||
@@ -20,6 +20,8 @@ interface AppConfigModalLazyProps {
|
||||
initialSection?: NavKey | null;
|
||||
/** Host-specific sections appended after the build's registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
/** Registry section keys to drop, for hosts a section can't run in. */
|
||||
hiddenSectionKeys?: NavKey[];
|
||||
}
|
||||
|
||||
export default function AppConfigModalLazy({
|
||||
@@ -28,6 +30,7 @@ export default function AppConfigModalLazy({
|
||||
urlSync,
|
||||
initialSection,
|
||||
extraSections,
|
||||
hiddenSectionKeys,
|
||||
}: AppConfigModalLazyProps) {
|
||||
const [shouldMount, setShouldMount] = useState(false);
|
||||
|
||||
@@ -44,6 +47,7 @@ export default function AppConfigModalLazy({
|
||||
urlSync={urlSync}
|
||||
initialSection={initialSection}
|
||||
extraSections={extraSections}
|
||||
hiddenSectionKeys={hiddenSectionKeys}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* Trigger: bare square icon button that blends into either sidebar's header. */
|
||||
.app-switch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--c-text-subtle);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.app-switch-btn:hover {
|
||||
background: var(--c-hover);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.app-switch-icon {
|
||||
width: 1rem;
|
||||
height: 1.0625rem;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppSwitch } from "@app/components/shared/AppSwitch";
|
||||
|
||||
/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */
|
||||
const meta: Meta<typeof AppSwitch> = {
|
||||
title: "Shared/AppSwitch",
|
||||
component: AppSwitch,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Editor: Story = {
|
||||
args: {
|
||||
current: "editor",
|
||||
theme: "light",
|
||||
onSwitch: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Processor: Story = {
|
||||
args: {
|
||||
current: "processor",
|
||||
theme: "light",
|
||||
onSwitch: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const DarkTheme: Story = {
|
||||
args: {
|
||||
current: "editor",
|
||||
theme: "dark",
|
||||
onSwitch: () => {},
|
||||
},
|
||||
};
|
||||
@@ -1,52 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import "@app/components/shared/AppSwitch.css";
|
||||
import { Dropdown } from "@app/ui";
|
||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||
|
||||
export type AppSwitchTarget = "editor" | "processor";
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSwitchProps {
|
||||
interface AppSwitchMenuItemsProps {
|
||||
/** The app this switcher is rendered in (shown as active in the menu). */
|
||||
current: AppSwitchTarget;
|
||||
/** Resolved color scheme; picks the brand mark for the menu items. */
|
||||
theme: "light" | "dark";
|
||||
/** Invoked with the selected app; only called for apps other than `current`. */
|
||||
onSwitch: (app: AppSwitchTarget) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor ⇄ processor app switcher (chevron button → app menu). The editor
|
||||
* and portal sidebars render this same element so the two apps present one
|
||||
* identical switcher; each host supplies its own theme source and navigation.
|
||||
* The editor / processor items for the app-switch menu. Rendered inside the
|
||||
* BrandSwitcher's logo dropdown, which both apps use as their switcher. The
|
||||
* mark is the shared <BrandMark>, which recolours itself from the theme
|
||||
* tokens, so no colour-scheme prop needs threading down here.
|
||||
*/
|
||||
export function AppSwitch({
|
||||
export function AppSwitchMenuItems({
|
||||
current,
|
||||
theme,
|
||||
onSwitch,
|
||||
className,
|
||||
}: AppSwitchProps) {
|
||||
}: AppSwitchMenuItemsProps) {
|
||||
const { t } = useTranslation();
|
||||
const mark = theme === "dark" ? markDark : markLight;
|
||||
const apps: Array<{ id: AppSwitchTarget; label: string }> = [
|
||||
{
|
||||
id: "processor",
|
||||
@@ -55,28 +30,17 @@ export function AppSwitch({
|
||||
{ id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
|
||||
];
|
||||
return (
|
||||
<Dropdown.Root align="end" className={className}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="app-switch-btn"
|
||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
||||
<>
|
||||
{apps.map((app) => (
|
||||
<Dropdown.Item
|
||||
key={app.id}
|
||||
active={current === app.id}
|
||||
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
|
||||
leading={<BrandMark height="1.125rem" />}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
{apps.map((app) => (
|
||||
<Dropdown.Item
|
||||
key={app.id}
|
||||
active={current === app.id}
|
||||
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
|
||||
leading={<img className="app-switch-icon" src={mark} alt="" />}
|
||||
>
|
||||
{app.label}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
{app.label}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
|
||||
export interface AppSwitcherProps {
|
||||
/** Icon-only brand mark for the collapsed rail. */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core stub for the sidebar app switcher. Builds that bundle the admin portal
|
||||
* (proprietary/saas) shadow this with a real switcher; core has no portal, so
|
||||
* there is nothing to switch to.
|
||||
* Sidebar brand header. Core has no admin portal to switch to, so it just
|
||||
* shows the Stirling logo. Builds that bundle the portal (proprietary/saas)
|
||||
* shadow this with a version whose logo doubles as the editor⇄processor
|
||||
* switcher.
|
||||
*/
|
||||
export function AppSwitcher() {
|
||||
return null;
|
||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
return (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Morphing Stirling mark. At rest: the two-tone red brand parallelograms
|
||||
(from the `d` attributes in the markup). When an ancestor marked
|
||||
[data-brandmark-morph] is hovered / focused / open, each parallelogram is
|
||||
transformed into one arm of a smaller, symmetric downward chevron in the
|
||||
primary text colour.
|
||||
|
||||
The morph uses a CSS `transform` (not the `d` property) so it works in every
|
||||
browser: both the logo shape and its target chevron arm are parallelograms,
|
||||
and an affine matrix maps one exactly onto the other. The matrices below were
|
||||
solved to send each path's 4 corners onto the chevron-arm corners:
|
||||
arm A (left): M13 27 L35.5 41 L35.5 53 L13 39 Z
|
||||
arm B (right): M35.5 41 L58 27 L58 39 L35.5 53 Z
|
||||
(mirror images — equal area + dimensions). */
|
||||
.sui-brandmark {
|
||||
display: block;
|
||||
width: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sui-brandmark__a,
|
||||
.sui-brandmark__b {
|
||||
transform-box: view-box;
|
||||
transform-origin: 0 0;
|
||||
transition:
|
||||
transform var(--motion-slow),
|
||||
fill var(--motion-slow);
|
||||
}
|
||||
|
||||
/* Rest state — brand mark. */
|
||||
.sui-brandmark__a {
|
||||
fill: var(--c-brand-mark-soft);
|
||||
}
|
||||
.sui-brandmark__b {
|
||||
fill: var(--c-brand-mark);
|
||||
}
|
||||
|
||||
/* Morphed state — the two chevron arms, both in the primary text colour. */
|
||||
[data-brandmark-morph]:hover .sui-brandmark__a,
|
||||
[data-brandmark-morph]:focus-visible .sui-brandmark__a,
|
||||
[data-brandmark-morph].is-open .sui-brandmark__a {
|
||||
fill: var(--c-text);
|
||||
transform: matrix(0.483871, 0.584583, 0, 0.338028, 13, 13.8169);
|
||||
}
|
||||
[data-brandmark-morph]:hover .sui-brandmark__b,
|
||||
[data-brandmark-morph]:focus-visible .sui-brandmark__b,
|
||||
[data-brandmark-morph].is-open .sui-brandmark__b {
|
||||
fill: var(--c-text);
|
||||
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sui-brandmark__a,
|
||||
.sui-brandmark__b {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import "@app/components/shared/BrandMark.css";
|
||||
|
||||
interface BrandMarkProps {
|
||||
/** Height of the mark (CSS length). */
|
||||
height?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Stirling logo mark as inline SVG so it can morph. At rest it is the
|
||||
* two-tone red brand mark; when an ancestor marked `[data-brandmark-morph]` is
|
||||
* hovered / focused / open (`.is-open`), the two parallelograms slide into a
|
||||
* smaller downward chevron in the primary text colour — a self-explaining
|
||||
* "this opens a menu" affordance. See BrandMark.css for the morph geometry.
|
||||
*/
|
||||
export function BrandMark({ height = "1.6rem", className }: BrandMarkProps) {
|
||||
return (
|
||||
<svg
|
||||
className={`sui-brandmark${className ? ` ${className}` : ""}`}
|
||||
viewBox="0 0 71 79"
|
||||
style={{ height }}
|
||||
role="img"
|
||||
aria-label="Stirling"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
className="sui-brandmark__a"
|
||||
d="M0 39 L46.5 0 L46.5 35.5 L0 74.5 Z"
|
||||
/>
|
||||
<path
|
||||
className="sui-brandmark__b"
|
||||
d="M24 43 L70.5 4 L70.5 39.5 L24 78.5 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Logo + app-switch dropdown, shared between the editor and the processor.
|
||||
The logo itself is the trigger (its mark morphs into a chevron on hover). */
|
||||
.sui-brand-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Tighten the ghost-button padding so the lockup sits flush like a plain logo,
|
||||
and negative-margin it back so the hover surface still extends past the text. */
|
||||
.sui-brand-switcher__trigger.sui-btn {
|
||||
--button-padding-x: 0.375rem;
|
||||
margin-inline: -0.375rem;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
|
||||
const meta: Meta<typeof BrandSwitcher> = {
|
||||
title: "Brand/BrandSwitcher",
|
||||
component: BrandSwitcher,
|
||||
parameters: { layout: "centered" },
|
||||
args: { current: "processor", onSwitch: () => {} },
|
||||
argTypes: {
|
||||
current: { control: "inline-radio", options: ["editor", "processor"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BrandSwitcher>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||
import {
|
||||
AppSwitchMenuItems,
|
||||
type AppSwitchTarget,
|
||||
} from "@app/components/shared/AppSwitch";
|
||||
import "@app/components/shared/BrandSwitcher.css";
|
||||
|
||||
interface BrandSwitcherProps {
|
||||
/** The app this is rendered in (shown active in the menu). */
|
||||
current: AppSwitchTarget;
|
||||
/** Called with the selected app (only for the non-current one). */
|
||||
onSwitch: (app: AppSwitchTarget) => void;
|
||||
/** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */
|
||||
collapsed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand lockup that doubles as the editor⇄processor switcher. The whole logo
|
||||
* is the dropdown trigger: on hover / focus / open the mark morphs into a
|
||||
* downward chevron (see BrandMark), so no separate chevron button is needed.
|
||||
* Shared so the editor and the processor present one identical header.
|
||||
*/
|
||||
export function BrandSwitcher({
|
||||
current,
|
||||
onSwitch,
|
||||
collapsed = false,
|
||||
className,
|
||||
}: BrandSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`sui-brand-switcher${className ? ` ${className}` : ""}`}>
|
||||
<Dropdown.Root align="start" open={open} onOpenChange={setOpen}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="quiet"
|
||||
data-brandmark-morph
|
||||
className={`sui-brand-switcher__trigger${open ? " is-open" : ""}`}
|
||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
||||
leftSection={<BrandMark height="1.6rem" />}
|
||||
>
|
||||
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
<AppSwitchMenuItems current={current} onSwitch={onSwitch} />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
/* ========== FILE SIDEBAR ========== */
|
||||
|
||||
.file-sidebar {
|
||||
background-color: var(--c-bg-raised);
|
||||
border-right: 1px solid var(--c-border-subtle);
|
||||
background-color: var(--c-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
/* Animating width + min-width + max-width together can leave the flex layout
|
||||
stuck on the pre-animation size in some browsers. Snap instead and rely
|
||||
on the inner content fade for visual smoothness. */
|
||||
/* Above the workbench column (also z-10, but later in the DOM, so it would
|
||||
otherwise paint over us). The brand switcher's menu is wider than the
|
||||
collapsed rail and has to spill across that boundary intact. */
|
||||
z-index: var(--z-dropdown);
|
||||
flex-shrink: 0;
|
||||
/* Slide the rail between collapsed/expanded. The delayed content-fade
|
||||
(sidebar-content-in, 0.18s) is timed against this 0.22s so labels resolve
|
||||
only after the width has settled — no squashed text mid-animation. */
|
||||
transition:
|
||||
width var(--motion-spring),
|
||||
min-width var(--motion-spring),
|
||||
max-width var(--motion-spring);
|
||||
/* Gap around the floating boxes. */
|
||||
padding: var(--nav-gutter);
|
||||
gap: var(--nav-gutter);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.file-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.file-sidebar-inner {
|
||||
@@ -19,8 +34,64 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- Brand header (logo / editor⇄processor switcher) ---- */
|
||||
.file-sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 0 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-collapse-toggle {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-brand {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
}
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* ---- Three floating nav-surface boxes (controls / files / footer) ---- */
|
||||
/* Horizontal padding is 0 so row highlights bleed to the surface edges; each
|
||||
row's own inner padding keeps its text/icon indented. */
|
||||
.file-sidebar-controls {
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-sidebar-files-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-sidebar-footer-box {
|
||||
padding: 0.25rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
|
||||
let the boxes stack at the top — controls, then the settings footer right
|
||||
after — instead of the files box stretching to fill. */
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
|
||||
display: none;
|
||||
}
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-inner {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ---- Native file drag-and-drop ---- */
|
||||
.file-sidebar[data-file-drag-over] {
|
||||
@@ -58,86 +129,16 @@
|
||||
color: var(--mantine-color-blue-6, var(--c-primary));
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
.file-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 4px 4px 0 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */
|
||||
|
||||
.file-sidebar-header:hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-menu-icon {
|
||||
color: var(--c-text-subtle) !important;
|
||||
font-size: 18px !important;
|
||||
.file-sidebar .sui-navitem {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Inherits font-size so swap-in icons render at 18px like the original. */
|
||||
.file-sidebar-menu-icon > svg {
|
||||
font-size: inherit;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
/* Flip directional toggle icons in RTL (skipped for the symmetric burger). */
|
||||
[dir="rtl"] .file-sidebar-menu-icon[data-toggle-flip-rtl="true"] > svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.file-sidebar-brand-text {
|
||||
height: 22px;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* App switcher (portal builds only) sits at the far end of the header row.
|
||||
The content-fade animation makes this span a stacking context, which would
|
||||
trap the menu's z-index below later sidebar rows — elevate the span so the
|
||||
open menu paints above them. */
|
||||
.file-sidebar-app-switch {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
/* ---- Search row ---- */
|
||||
.file-sidebar-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
gap: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.file-sidebar-search-row:not(.active):hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-search-icon {
|
||||
color: var(--c-text-subtle) !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
.file-sidebar .sui-navitem__label {
|
||||
animation: sidebar-content-in 0.12s ease 0.18s both;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Search row (open state) ---- */
|
||||
.file-sidebar-search-close {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -147,9 +148,8 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-size: inherit;
|
||||
color: var(--c-text);
|
||||
margin-left: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -157,12 +157,6 @@
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.file-sidebar-search-label {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
/* ---- Scrollable content ---- */
|
||||
/* This is a flex column - action rows are fixed, only the file list scrolls */
|
||||
.file-sidebar-scroll {
|
||||
@@ -170,7 +164,8 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.file-sidebar-scroll::-webkit-scrollbar {
|
||||
@@ -184,112 +179,6 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ---- Action rows (Open from Computer, etc.) ---- */
|
||||
.file-sidebar-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
gap: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-action-row:hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-action-row.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.file-sidebar-action-row.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.file-sidebar-action-icon {
|
||||
color: var(--c-text-subtle) !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* When the icon is a wrapper around an SVG (extraAction case), force
|
||||
* the SVG to inherit the wrapper's 18px sizing instead of MUI's default
|
||||
* 1.5rem so it matches the other rail icons. */
|
||||
.file-sidebar-action-icon > svg {
|
||||
font-size: inherit;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.file-sidebar-action-label {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--c-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Cloud storage rows ---- */
|
||||
.file-sidebar-cloud-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
gap: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row:not(.disabled):hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-icon-wrapper {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-icon-gray {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-icon-color {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-gray {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-color {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Files section ---- */
|
||||
.file-sidebar-files-section {
|
||||
flex: 1;
|
||||
@@ -452,18 +341,16 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px 6px 14px;
|
||||
margin: 4px 0 0 0;
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
padding: 0 6px 2px 6px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-section-label {
|
||||
font-size: 13px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--c-text-subtle);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Slim "Adding files… X/Y" progress row shown during a bulk drop's pre-scan,
|
||||
@@ -570,10 +457,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
padding: 4px 6px;
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Bottom bar settings icon tracks the right edge during collapse animation */
|
||||
|
||||
@@ -8,7 +8,9 @@ import React, {
|
||||
} from "react";
|
||||
import { Loader, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { NavSurface } from "@app/ui/NavSurface";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { NavItem } from "@app/ui/NavItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
|
||||
@@ -29,10 +31,9 @@ import {
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
@@ -152,12 +153,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
onOpenSettings,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
onUploadFiles,
|
||||
onPickGoogleDriveFiles,
|
||||
onSearchClick,
|
||||
extraAction,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -833,89 +834,43 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
)}
|
||||
<div className="file-sidebar-inner">
|
||||
{/* Header: hamburger + branding */}
|
||||
<Tooltip
|
||||
label={toggleAriaLabel ?? t("fileSidebar.expand", "Expand sidebar")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-header"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onToggleCollapse?.();
|
||||
<div className="file-sidebar-brand">
|
||||
<AppSwitcher collapsed={collapsed} />
|
||||
{onToggleCollapse && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
className="file-sidebar-collapse-toggle"
|
||||
onClick={() => onToggleCollapse()}
|
||||
aria-label={
|
||||
toggleAriaLabel ??
|
||||
(collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar"))
|
||||
}
|
||||
}}
|
||||
aria-label={
|
||||
toggleAriaLabel ??
|
||||
(collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar"))
|
||||
}
|
||||
>
|
||||
{/* Wrapper carries sizing; data-toggle-flip-rtl flips icon in RTL. */}
|
||||
<span
|
||||
className="file-sidebar-menu-icon"
|
||||
data-toggle-flip-rtl={toggleIcon ? "true" : undefined}
|
||||
>
|
||||
{toggleIcon ?? <MenuIcon />}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<Wordmark
|
||||
alt="Stirling PDF"
|
||||
className="file-sidebar-brand-text sidebar-content-fade"
|
||||
/>
|
||||
)}
|
||||
{!collapsed && (
|
||||
// The header row itself toggles collapse; stop the switcher's
|
||||
// clicks and key presses from reaching it.
|
||||
<span
|
||||
className="file-sidebar-app-switch sidebar-content-fade"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AppSwitcher />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{toggleIcon ?? <SidebarToggleIcon size={18} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search row */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.search", "Search")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
|
||||
onClick={!searchActive ? handleSearchClick : undefined}
|
||||
role={!searchActive ? "button" : undefined}
|
||||
tabIndex={!searchActive ? 0 : undefined}
|
||||
onKeyDown={
|
||||
!searchActive
|
||||
? (e) => e.key === "Enter" && handleSearchClick()
|
||||
: undefined
|
||||
}
|
||||
{/* Box 1 — top controls (search + open / my files / cloud). No title. */}
|
||||
<NavSurface className="file-sidebar-controls">
|
||||
{/* Search row */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.search", "Search")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
{searchActive && !collapsed ? (
|
||||
<CloseIcon
|
||||
className="file-sidebar-search-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSearchClose();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SearchIcon className="file-sidebar-search-icon" />
|
||||
)}
|
||||
{!collapsed &&
|
||||
(searchActive ? (
|
||||
<div className="sui-navitem sui-navitem--field file-sidebar-search-row">
|
||||
<span className="sui-navitem__icon">
|
||||
<CloseIcon
|
||||
className="file-sidebar-search-close"
|
||||
onClick={handleSearchClose}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="file-sidebar-search-input sidebar-content-fade"
|
||||
@@ -925,18 +880,20 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
"fileSidebar.searchPlaceholder",
|
||||
"Search files...",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="file-sidebar-search-label sidebar-content-fade">
|
||||
{t("fileSidebar.search", "Search")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<NavItem
|
||||
id="search"
|
||||
className="file-sidebar-search-row"
|
||||
label={t("fileSidebar.search", "Search")}
|
||||
icon={<SearchIcon />}
|
||||
iconOnly={collapsed}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="file-sidebar-scroll">
|
||||
{/* Hidden native file input - kept outside the !collapsed gate so
|
||||
the "Open from computer" row below (always rendered) can fire
|
||||
it in either sidebar state without a silent no-op. */}
|
||||
@@ -963,8 +920,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
<NavItem
|
||||
id="openFromComputer"
|
||||
label={t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
icon={<UploadFileIcon />}
|
||||
iconOnly={collapsed}
|
||||
// `files-button` is the long-standing upload entry-point
|
||||
// testid: click + setInputFiles on `file-input` above. Tour
|
||||
// anchor lives here too - the tour now spotlights the native
|
||||
@@ -977,26 +937,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// is reachable via "My Files" below.
|
||||
nativeFileInputRef.current?.click();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t(
|
||||
"fileSidebar.openFromComputer",
|
||||
"Open from computer",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
nativeFileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UploadFileIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{extraAction && (
|
||||
@@ -1019,34 +960,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
!(extraAction.disabled && extraAction.disabledTooltip)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-action-row${extraAction.disabled ? " disabled" : ""}`}
|
||||
<NavItem
|
||||
id="extraAction"
|
||||
label={extraAction.label}
|
||||
icon={extraAction.icon}
|
||||
iconOnly={collapsed}
|
||||
disabled={extraAction.disabled}
|
||||
data-testid={extraAction.testId}
|
||||
onClick={() => {
|
||||
if (extraAction.disabled) return;
|
||||
extraAction.onClick();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={extraAction.disabled ? -1 : 0}
|
||||
aria-disabled={extraAction.disabled}
|
||||
aria-label={extraAction.label}
|
||||
onKeyDown={(e) => {
|
||||
if (extraAction.disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
extraAction.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="file-sidebar-action-icon">
|
||||
{extraAction.icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{extraAction.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
onClick={extraAction.onClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1056,30 +978,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
<NavItem
|
||||
id="myFiles"
|
||||
label={t("fileSidebar.myFiles", "My Files")}
|
||||
icon={<FolderOpenIcon />}
|
||||
iconOnly={collapsed}
|
||||
data-testid="my-files-button"
|
||||
onClick={() => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
navigate("/files");
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("fileSidebar.myFiles", "My Files")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate("/files");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.myFiles", "My Files")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{!shouldHideGoogleDrive && (
|
||||
@@ -1096,206 +1005,187 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
|
||||
onClick={handleGoogleDriveClick}
|
||||
role="button"
|
||||
tabIndex={isGoogleDriveEnabled ? 0 : -1}
|
||||
aria-disabled={!isGoogleDriveEnabled}
|
||||
aria-label={
|
||||
!isGoogleDriveEnabled
|
||||
? t(
|
||||
"fileSidebar.googleDriveDisabled",
|
||||
"Google Drive is not configured",
|
||||
)
|
||||
: t("fileSidebar.googleDrive", "Open from Google Drive")
|
||||
<NavItem
|
||||
id="googleDrive"
|
||||
label={t("fileSidebar.googleDrive", "Google Drive")}
|
||||
icon={<GoogleDriveIcon />}
|
||||
// Only light up the brand mark when Drive is configured.
|
||||
hoverIcon={
|
||||
isGoogleDriveEnabled ? (
|
||||
<GoogleDriveIcon colored />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="file-sidebar-cloud-icon-wrapper">
|
||||
<GoogleDriveIcon
|
||||
className="file-sidebar-cloud-icon-gray"
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
/>
|
||||
{isGoogleDriveEnabled && (
|
||||
<GoogleDriveIcon
|
||||
colored
|
||||
className="file-sidebar-cloud-icon-color"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.googleDrive", "Google Drive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
iconOnly={collapsed}
|
||||
disabled={!isGoogleDriveEnabled}
|
||||
onClick={handleGoogleDriveClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Watched Folders entry */}
|
||||
{WATCHED_FOLDERS_ENABLED && (
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
<NavItem
|
||||
id="watchedFolders"
|
||||
label={t("watchedFolders.sidebarTitle", "Watched Folders")}
|
||||
icon={<FolderSpecialIcon />}
|
||||
iconOnly={collapsed}
|
||||
isActive={isWatchedFoldersActive}
|
||||
data-testid="watchedFolders-button"
|
||||
data-active={isWatchedFoldersActive}
|
||||
onClick={openWatchedFolders}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && openWatchedFolders()}
|
||||
aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")}
|
||||
style={
|
||||
isWatchedFoldersActive
|
||||
? { backgroundColor: "var(--c-active)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FolderSpecialIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("watchedFolders.sidebarTitle", "Watched Folders")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</NavSurface>
|
||||
|
||||
{/* Files section - always visible when expanded */}
|
||||
{!collapsed && (
|
||||
<div className="file-sidebar-files-section sidebar-content-fade">
|
||||
<div className="file-sidebar-section-header">
|
||||
<span className="file-sidebar-section-label">
|
||||
{t("fileSidebar.files", "Files")}
|
||||
</span>
|
||||
<FileSidebarGroupControls stubs={filteredFileStubs} />
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-external"
|
||||
onClick={() => navigate("/files")}
|
||||
title={t(
|
||||
"fileSidebar.openFileManager",
|
||||
"Browse all files & folders",
|
||||
)}
|
||||
aria-label={t(
|
||||
"fileSidebar.openFileManager",
|
||||
"Browse all files & folders",
|
||||
)}
|
||||
data-testid="open-files-page"
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-add"
|
||||
onClick={() => nativeFileInputRef.current?.click()}
|
||||
title={t("fileSidebar.addFiles", "Add files")}
|
||||
aria-label={t("fileSidebar.addFiles", "Add files")}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
<BulkAddProgressRow />
|
||||
|
||||
{!stubsLoaded ? (
|
||||
<div className="file-sidebar-loading">
|
||||
<Loader size="sm" color="var(--c-text-subtle)" />
|
||||
{/* Box 2 — the file tree (this box scrolls). */}
|
||||
<NavSurface className="file-sidebar-files-box">
|
||||
<div className="file-sidebar-scroll">
|
||||
{/* Files section - always visible when expanded */}
|
||||
{!collapsed && (
|
||||
<div className="file-sidebar-files-section sidebar-content-fade">
|
||||
<div className="file-sidebar-section-header">
|
||||
<span className="file-sidebar-section-label">
|
||||
{t("fileSidebar.library", "PDF Library")}
|
||||
</span>
|
||||
<FileSidebarGroupControls stubs={filteredFileStubs} />
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-external"
|
||||
onClick={() => navigate("/files")}
|
||||
title={t(
|
||||
"fileSidebar.openFileManager",
|
||||
"Browse all files & folders",
|
||||
)}
|
||||
aria-label={t(
|
||||
"fileSidebar.openFileManager",
|
||||
"Browse all files & folders",
|
||||
)}
|
||||
data-testid="open-files-page"
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-add"
|
||||
onClick={() => nativeFileInputRef.current?.click()}
|
||||
title={t("fileSidebar.addFiles", "Add files")}
|
||||
aria-label={t("fileSidebar.addFiles", "Add files")}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
) : filteredFileStubs.length > 0 ? (
|
||||
<div className="file-sidebar-file-list">
|
||||
{fileGroups ? (
|
||||
<>
|
||||
{fileGroups.map((group) => {
|
||||
const isOpen =
|
||||
groupOpen[group.id] ?? group.defaultExpanded;
|
||||
return (
|
||||
<div className="file-sidebar-group" key={group.id}>
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="file-sidebar-group-header"
|
||||
onClick={() =>
|
||||
setGroupOpenState(group.id, !isOpen)
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
leftSection={
|
||||
<>
|
||||
{isOpen ? (
|
||||
<KeyboardArrowDownIcon
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
) : (
|
||||
<KeyboardArrowRightIcon
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
)}
|
||||
{group.icon && (
|
||||
<LocalIcon
|
||||
icon={group.icon}
|
||||
width="1.05rem"
|
||||
className="file-sidebar-group-icon"
|
||||
style={
|
||||
group.color
|
||||
? { color: group.color }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
rightSection={
|
||||
<span className="file-sidebar-group-count">
|
||||
{group.stubs.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="file-sidebar-group-label">
|
||||
{group.label}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="file-sidebar-group-items">
|
||||
{isOpen && group.stubs.map(renderFileRow)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="file-sidebar-view-all"
|
||||
onClick={() => navigate("/files")}
|
||||
rightSection={
|
||||
<KeyboardArrowRightIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"fileSidebar.viewAll",
|
||||
"View all {{count}} files",
|
||||
{
|
||||
count: filteredFileStubs.length,
|
||||
},
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
filteredFileStubs.map(renderFileRow)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!searchActive && (
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t("fileSidebar.dropHint", "Open files to get started")}
|
||||
</p>
|
||||
|
||||
<BulkAddProgressRow />
|
||||
|
||||
{!stubsLoaded ? (
|
||||
<div className="file-sidebar-loading">
|
||||
<Loader size="sm" color="var(--c-text-subtle)" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : filteredFileStubs.length > 0 ? (
|
||||
<div className="file-sidebar-file-list">
|
||||
{fileGroups ? (
|
||||
<>
|
||||
{fileGroups.map((group) => {
|
||||
const isOpen =
|
||||
groupOpen[group.id] ?? group.defaultExpanded;
|
||||
return (
|
||||
<div
|
||||
className="file-sidebar-group"
|
||||
key={group.id}
|
||||
>
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="file-sidebar-group-header"
|
||||
onClick={() =>
|
||||
setGroupOpenState(group.id, !isOpen)
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
leftSection={
|
||||
<>
|
||||
{isOpen ? (
|
||||
<KeyboardArrowDownIcon
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
) : (
|
||||
<KeyboardArrowRightIcon
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
)}
|
||||
{group.icon && (
|
||||
<LocalIcon
|
||||
icon={group.icon}
|
||||
width="1.05rem"
|
||||
className="file-sidebar-group-icon"
|
||||
style={
|
||||
group.color
|
||||
? { color: group.color }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
rightSection={
|
||||
<span className="file-sidebar-group-count">
|
||||
{group.stubs.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="file-sidebar-group-label">
|
||||
{group.label}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="file-sidebar-group-items">
|
||||
{isOpen && group.stubs.map(renderFileRow)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="file-sidebar-view-all"
|
||||
onClick={() => navigate("/files")}
|
||||
rightSection={
|
||||
<KeyboardArrowRightIcon
|
||||
sx={{ fontSize: "1rem" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"fileSidebar.viewAll",
|
||||
"View all {{count}} files",
|
||||
{
|
||||
count: filteredFileStubs.length,
|
||||
},
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
filteredFileStubs.map(renderFileRow)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!searchActive && (
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t(
|
||||
"fileSidebar.dropHint",
|
||||
"Open files to get started",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NavSurface>
|
||||
</div>
|
||||
|
||||
{/* Kebab "Save to cloud" upload modal (one file at a time). */}
|
||||
@@ -1325,65 +1215,70 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
||||
<SidebarChecklistSlot collapsed={collapsed} />
|
||||
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<Tooltip
|
||||
label={
|
||||
onOpenSettings
|
||||
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
|
||||
: displayName
|
||||
}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
{/* Box 3 — account footer (avatar + name + settings). */}
|
||||
<NavSurface className="file-sidebar-footer-box">
|
||||
{/* Bottom bar: user name + settings */}
|
||||
<Tooltip
|
||||
label={
|
||||
onOpenSettings
|
||||
? (e) => e.key === "Enter" && onOpenSettings()
|
||||
: undefined
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
|
||||
: displayName
|
||||
}
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-bottom-avatar${
|
||||
showProfilePicture ? " file-sidebar-bottom-avatar--picture" : ""
|
||||
}`}
|
||||
aria-label={displayName}
|
||||
className="file-sidebar-bottom-bar"
|
||||
onClick={onOpenSettings}
|
||||
role={onOpenSettings ? "button" : undefined}
|
||||
tabIndex={onOpenSettings ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onOpenSettings
|
||||
? (e) => e.key === "Enter" && onOpenSettings()
|
||||
: undefined
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
: displayName
|
||||
}
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
className="file-sidebar-bottom-avatar-img"
|
||||
onError={() => setPictureFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
<div
|
||||
className={`file-sidebar-bottom-avatar${
|
||||
showProfilePicture
|
||||
? " file-sidebar-bottom-avatar--picture"
|
||||
: ""
|
||||
}`}
|
||||
aria-label={displayName}
|
||||
>
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
className="file-sidebar-bottom-avatar-img"
|
||||
onError={() => setPictureFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{onOpenSettings && !collapsed && (
|
||||
<div className="file-sidebar-bottom-settings">
|
||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</NavSurface>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ export function LandingActions({
|
||||
<Group gap="sm" justify="center" wrap="wrap" mb="xs">
|
||||
<Button
|
||||
className="landing-btn-primary"
|
||||
px="xl"
|
||||
leftSection={
|
||||
<LocalIcon icon={icons.uploadIconName} width="1rem" height="1rem" />
|
||||
}
|
||||
@@ -47,6 +48,7 @@ export function LandingActions({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="landing-btn-secondary"
|
||||
px="xl"
|
||||
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -3,6 +3,32 @@
|
||||
All custom properties are defined in theme.css.
|
||||
============================================================ */
|
||||
|
||||
/* ── Entrance animation (editor empty state) ─────────────────
|
||||
Logo fades/slides up first (0.2s), then the actions after it finishes
|
||||
(0.2s delay + 0.2s) — 0.4s total. */
|
||||
@keyframes landing-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.75rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.landing-logo-enter {
|
||||
animation: landing-fade-up 0.2s ease-out both;
|
||||
}
|
||||
.landing-actions-enter {
|
||||
animation: landing-fade-up 0.2s ease-out 0.2s both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-logo-enter,
|
||||
.landing-actions-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hero text ───────────────────────────────────────────── */
|
||||
.landing-title {
|
||||
margin: 1.75rem 0 1.5rem;
|
||||
@@ -110,31 +136,21 @@
|
||||
}
|
||||
|
||||
/* ── Action buttons ──────────────────────────────────────── */
|
||||
.landing-btn-primary {
|
||||
background: var(--landing-hero-gradient) !important;
|
||||
color: var(--c-text-on-primary) !important;
|
||||
border: none !important;
|
||||
border-radius: 0.75rem !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.landing-btn-primary,
|
||||
.landing-btn-secondary {
|
||||
border-radius: 0.75rem !important;
|
||||
font-weight: 600 !important;
|
||||
border-color: var(--landing-button-border, var(--c-border)) !important;
|
||||
background-color: var(--landing-button-bg, var(--c-surface)) !important;
|
||||
color: var(--c-accent-fg, var(--c-text)) !important;
|
||||
}
|
||||
.landing-btn-secondary:hover {
|
||||
background-color: var(
|
||||
--landing-button-hover-bg,
|
||||
var(--landing-button-bg, var(--c-surface))
|
||||
) !important;
|
||||
}
|
||||
|
||||
/* Icon-only variant: accent colour instead of button text colour */
|
||||
.landing-btn-primary,
|
||||
.landing-btn-secondary,
|
||||
.landing-btn-icon {
|
||||
color: var(--c-primary) !important;
|
||||
min-height: 3rem !important;
|
||||
}
|
||||
.landing-btn-icon {
|
||||
width: 3rem !important;
|
||||
min-width: 3rem !important;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Dropzone accept/reject outlines. Mantine 8 no longer supports nested
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import React, { useState } from "react";
|
||||
import { Container } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
||||
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { LandingActions } from "@app/components/shared/LandingActions";
|
||||
import "@app/components/shared/LandingPage.css";
|
||||
|
||||
const LandingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { addFiles } = useFileHandler();
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const terminology = useFileActionTerminology();
|
||||
@@ -77,17 +75,24 @@ const LandingPage = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LandingDocumentStack />
|
||||
|
||||
<h3 className="landing-title">
|
||||
{t("landing.workbenchEmptyStateHero", "Drop a PDF anywhere")}
|
||||
</h3>
|
||||
<LandingActions
|
||||
fileInputRef={fileInputRef}
|
||||
onUploadClick={() => void handleNativeUploadClick()}
|
||||
onMobileUploadClick={() => setMobileUploadModalOpen(true)}
|
||||
onFileSelect={handleFileSelect}
|
||||
<Logo
|
||||
variant="iconAndText"
|
||||
orientation="vertical"
|
||||
iconHeight="5rem"
|
||||
textHeight="2.5rem"
|
||||
gap="1rem"
|
||||
className="landing-logo-enter"
|
||||
style={{ marginBottom: "2.5rem" }}
|
||||
/>
|
||||
|
||||
<div className="landing-actions-enter">
|
||||
<LandingActions
|
||||
fileInputRef={fileInputRef}
|
||||
onUploadClick={() => void handleNativeUploadClick()}
|
||||
onMobileUploadClick={() => setMobileUploadModalOpen(true)}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</Dropzone>
|
||||
|
||||
<MobileUploadModal
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
interface SidebarToggleIconProps {
|
||||
/** Square size in px. */
|
||||
size?: number;
|
||||
/** Put the divided-off rail on the right, for a right-hand panel. */
|
||||
mirrored?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Toggle sidebar" glyph — a rounded panel with a divided-off left rail —
|
||||
* shared by the editor FileSidebar and the processor Sidebar so their
|
||||
* collapse/expand controls read identically. Inherits colour via currentColor.
|
||||
*/
|
||||
export function SidebarToggleIcon({
|
||||
size = 18,
|
||||
mirrored = false,
|
||||
className,
|
||||
}: SidebarToggleIconProps) {
|
||||
// Mirror by moving the divider, not by flipping the whole glyph, so the
|
||||
// rounded corners and stroke widths stay identical between the two.
|
||||
const dividerX = mirrored ? 14.5 : 9.5;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2.5" />
|
||||
<line x1={dividerX} y1="4" x2={dividerX} y2="20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,13 @@
|
||||
align-content: flex-start;
|
||||
min-height: 40px;
|
||||
padding: 0 8px;
|
||||
background-color: var(--c-bg-raised);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
/* No left margin: the file sidebar's own 0.5rem padding already provides the
|
||||
gutter on that side, so adding one here would double it and leave the bar
|
||||
further from the sidebar than it is from the tool panel. */
|
||||
margin: var(--nav-gutter) var(--nav-gutter) 0 0;
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-nav);
|
||||
flex-shrink: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
+6
-15
@@ -57,7 +57,6 @@ export interface DesktopUpdateModeControl {
|
||||
}
|
||||
|
||||
interface GeneralSectionProps {
|
||||
hideTitle?: boolean;
|
||||
hideUpdateSection?: boolean;
|
||||
hideAdminBanner?: boolean;
|
||||
/** Desktop-only: Tauri updater install state, passed from the desktop override. */
|
||||
@@ -76,7 +75,6 @@ interface GeneralSectionProps {
|
||||
}
|
||||
|
||||
const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
hideTitle = false,
|
||||
hideUpdateSection = false,
|
||||
hideAdminBanner = false,
|
||||
desktopInstall,
|
||||
@@ -170,19 +168,12 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{!hideTitle && (
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("settings.general.title", "General")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"settings.general.description",
|
||||
"Configure general application preferences.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"settings.general.description",
|
||||
"Configure general application preferences.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{!hideAdminBanner && loginDisabled && !bannerDismissed && (
|
||||
<Paper
|
||||
|
||||
@@ -147,9 +147,6 @@ const HotkeysSection: React.FC = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"settings.hotkeys.description",
|
||||
|
||||
@@ -15,9 +15,9 @@ import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
@@ -79,6 +79,7 @@ export default function RightSidebar() {
|
||||
};
|
||||
|
||||
const [allToolsView, setAllToolsView] = useState(false);
|
||||
const [headerSearchOpen, setHeaderSearchOpen] = useState(false);
|
||||
|
||||
const handleShowAllTools = () => {
|
||||
withViewTransition(() => setAllToolsView(true));
|
||||
@@ -96,7 +97,7 @@ export default function RightSidebar() {
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker";
|
||||
const showHeaderSearch = showCloseButton || headerSearchOpen;
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
@@ -168,7 +169,7 @@ export default function RightSidebar() {
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--c-bg-raised)] border-l border-[var(--c-border-subtle)] transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} ${isMobile || fullscreenExpanded ? "border-l border-[var(--c-border-subtle)]" : "tool-panel--floating"} transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : fullscreenExpanded ? "h-screen" : ""} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
width: computedWidth(),
|
||||
padding: "0",
|
||||
@@ -188,7 +189,7 @@ export default function RightSidebar() {
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
onClick={handleExpand}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
<SidebarToggleIcon size={18} mirrored />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
@@ -263,37 +264,62 @@ export default function RightSidebar() {
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<span className="tool-panel__compact-title">
|
||||
{t("toolPanel.pdfTools", "PDF Tools")}
|
||||
</span>
|
||||
)}
|
||||
<div className="tool-panel__compact-header-actions">
|
||||
{!showCloseButton && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={() => {
|
||||
if (headerSearchOpen) handleHeaderSearchChange("");
|
||||
setHeaderSearchOpen((open) => !open);
|
||||
}}
|
||||
aria-label={t("toolPanel.searchTools", "Search tools")}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
{headerSearchOpen ? (
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<SearchIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<SidebarToggleIcon size={18} mirrored />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
.tool-panel {
|
||||
position: relative;
|
||||
background: var(--c-surface);
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
max-width 0.3s ease;
|
||||
@@ -18,6 +19,14 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-panel--floating {
|
||||
margin: var(--nav-gutter) var(--nav-gutter) var(--nav-gutter) 0;
|
||||
height: calc(100vh - (var(--nav-gutter) * 2));
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-nav);
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -151,8 +160,21 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header .tool-panel__expand-btn {
|
||||
.tool-panel__compact-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-search {
|
||||
|
||||
@@ -50,7 +50,7 @@ const SCROLLABLE_STYLE: React.CSSProperties = {
|
||||
const CONTAINER_STYLE: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--c-bg-raised)",
|
||||
background: "var(--c-surface)",
|
||||
};
|
||||
const toTitleCase = (s: string) =>
|
||||
s.replace(
|
||||
|
||||
@@ -1364,9 +1364,12 @@ const EmbedPdfViewerContent = ({
|
||||
{/* Bottom Toolbar Overlay */}
|
||||
{effectiveFile && (
|
||||
<div
|
||||
className="pdf-viewer-toolbar-dock"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
// Gutter matching the workbench rails, so the bar reads as a
|
||||
// floating card rather than one welded to the viewport edge.
|
||||
bottom: "0.5rem",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 50,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Floating viewer toolbar: a nav-surface card like the workbench rails rather
|
||||
than a slab welded to the viewport edge. Shared by both consumers (the
|
||||
viewer's bottom dock and the file-preview modal, which puts it up top). */
|
||||
.pdf-viewer-toolbar {
|
||||
border-radius: var(--radius-nav);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
background: var(--c-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* The viewer's fixed bottom dock. The reveal lives here, not on the toolbar
|
||||
itself, so it only plays where the bar actually enters from off-screen —
|
||||
mirroring the workbench top bar's 280ms ease, upwards instead of downwards. */
|
||||
.pdf-viewer-toolbar-dock {
|
||||
animation: pdf-viewer-toolbar-in 280ms ease both;
|
||||
}
|
||||
|
||||
@keyframes pdf-viewer-toolbar-in {
|
||||
from {
|
||||
/* The bar's own height plus its gutter, so it starts fully off-screen
|
||||
however tall the controls wrap. */
|
||||
transform: translateY(calc(100% + var(--nav-gutter)));
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pdf-viewer-toolbar-dock {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useIsPhone } from "@app/hooks/useIsMobile";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import "@app/components/viewer/PdfViewerToolbar.css";
|
||||
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
||||
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
|
||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||
@@ -148,8 +149,7 @@ export function PdfViewerToolbar({
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="xl xl 0 0"
|
||||
shadow="sm"
|
||||
className="pdf-viewer-toolbar"
|
||||
p={12}
|
||||
pb={12}
|
||||
style={{
|
||||
@@ -159,11 +159,6 @@ export function PdfViewerToolbar({
|
||||
rowGap: 8,
|
||||
gap: 10,
|
||||
justifyContent: "center",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** View-toggle modes; tuple keeps the union and iterator in sync. */
|
||||
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
|
||||
export type FilesPageViewMode = (typeof FILES_PAGE_VIEW_MODES)[number];
|
||||
@@ -30,10 +30,13 @@ import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { type FilesPageViewMode } from "@app/constants/filesPageView";
|
||||
|
||||
/** View-toggle modes; tuple keeps the union and iterator in sync. */
|
||||
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
|
||||
export type FilesPageViewMode = (typeof FILES_PAGE_VIEW_MODES)[number];
|
||||
export {
|
||||
FILES_PAGE_VIEW_MODES,
|
||||
type FilesPageViewMode,
|
||||
} from "@app/constants/filesPageView";
|
||||
export type FilesPageSortMode =
|
||||
| "name-asc"
|
||||
| "name-desc"
|
||||
@@ -153,6 +156,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const { isAnonymous } = useAuth();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -230,7 +234,12 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [folders.currentFolderId, clearSelection]);
|
||||
|
||||
// View + sort + search + filters ----------------------------------------
|
||||
const [viewMode, setViewMode] = useState<FilesPageViewMode>("grid");
|
||||
// Persisted so the choice survives a reload (preferences are localStorage-backed).
|
||||
const viewMode = preferences.filesPageViewMode;
|
||||
const setViewMode = useCallback(
|
||||
(mode: FilesPageViewMode) => updatePreference("filesPageViewMode", mode),
|
||||
[updatePreference],
|
||||
);
|
||||
const [sortMode, setSortMode] = useState<FilesPageSortMode>("modified-desc");
|
||||
const [search, setSearch] = useState("");
|
||||
const [originFilter, setOriginFilter] =
|
||||
@@ -415,6 +424,18 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
.map((id) => fileMap.get(id))
|
||||
.filter((s): s is StirlingFileStub => Boolean(s));
|
||||
|
||||
// Drop the rows now rather than after the cloud round-trip and the IDB
|
||||
// rewrite - deleting a large selection otherwise leaves the grid frozen
|
||||
// on the old contents for seconds. The refresh that follows the write is
|
||||
// the source of truth; anything that failed to delete reappears.
|
||||
const removedIds = new Set(fileIds);
|
||||
setAllFiles((prev) => prev.filter((f) => !removedIds.has(f.id)));
|
||||
setSelectedFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of removedIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Cloud delete (owner-only). Dedup by remoteStorageId since a history
|
||||
// chain shares a single server file.
|
||||
if (scope === "cloud" || scope === "everywhere") {
|
||||
@@ -459,16 +480,12 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const removedIds = new Set(fileIds);
|
||||
setSelectedFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of removedIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
// reconcile picks up the cloud deletions and strips stale remote pointers.
|
||||
await refresh();
|
||||
// The storage write notifies IndexedDBContext, whose revision bump
|
||||
// re-runs refresh() - which reconciles the cloud deletions and strips
|
||||
// stale remote pointers. No explicit refresh here: it would be a second
|
||||
// full pass over every stub for the same delete.
|
||||
},
|
||||
[fileMap, fileActions, folders, refresh, t],
|
||||
[fileMap, fileActions, folders, t],
|
||||
);
|
||||
|
||||
const removeFiles = useCallback(
|
||||
@@ -501,9 +518,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const confirmRemoveFiles = useCallback(
|
||||
async (scope: DeleteScope) => {
|
||||
await performDelete(deleteDialogFileIds, scope);
|
||||
const fileIds = deleteDialogFileIds;
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteDialogFileIds([]);
|
||||
await performDelete(fileIds, scope);
|
||||
},
|
||||
[deleteDialogFileIds, performDelete],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -80,6 +81,27 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const [revision, setRevision] = useState(0);
|
||||
const bumpRevision = useCallback(() => setRevision((r) => r + 1), []);
|
||||
|
||||
// One bump per burst: a 50-file delete or a folder drop fires a write per
|
||||
// file, and each bump re-reads every stub downstream. Coalescing on a
|
||||
// microtask turns that storm into a single refresh.
|
||||
const pendingBump = useRef(false);
|
||||
const scheduleBump = useCallback(() => {
|
||||
if (pendingBump.current) return;
|
||||
pendingBump.current = true;
|
||||
queueMicrotask(() => {
|
||||
pendingBump.current = false;
|
||||
bumpRevision();
|
||||
});
|
||||
}, [bumpRevision]);
|
||||
|
||||
// Writes that bypass this context (policy runs, share-link imports, watched
|
||||
// folders) reach the same store, so subscribe at the storage layer instead of
|
||||
// trusting every call site to announce itself.
|
||||
useEffect(() => {
|
||||
const unsubscribe = fileStorage.subscribeToChanges(scheduleBump);
|
||||
return unsubscribe;
|
||||
}, [scheduleBump]);
|
||||
|
||||
// LRU File cache to avoid repeated ArrayBuffer→File conversions
|
||||
const fileCache = useRef(
|
||||
new Map<FileId, { file: File; lastAccessed: number }>(),
|
||||
@@ -148,10 +170,9 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
bumpRevision();
|
||||
return storedFile;
|
||||
},
|
||||
[bumpRevision],
|
||||
[evictLRUEntries],
|
||||
);
|
||||
|
||||
const loadFile = useCallback(
|
||||
@@ -188,17 +209,13 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteFile = useCallback(
|
||||
async (fileId: FileId): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileCache.current.delete(fileId);
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteStirlingFile(fileId);
|
||||
bumpRevision();
|
||||
},
|
||||
[bumpRevision],
|
||||
);
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteStirlingFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadLeafMetadata = useCallback(async (): Promise<
|
||||
StirlingFileStub[]
|
||||
@@ -223,9 +240,8 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
|
||||
// Delete all in a single IDB transaction
|
||||
await fileStorage.deleteMultipleStirlingFiles(fileIds);
|
||||
bumpRevision();
|
||||
},
|
||||
[bumpRevision],
|
||||
[],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
@@ -234,8 +250,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
|
||||
// Clear IndexedDB
|
||||
await fileStorage.clearAll();
|
||||
bumpRevision();
|
||||
}, [bumpRevision]);
|
||||
}, []);
|
||||
|
||||
const getStorageStats = useCallback(async () => {
|
||||
return await fileStorage.getStorageStats();
|
||||
@@ -244,37 +259,33 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const updateThumbnail = useCallback(
|
||||
async (fileId: FileId, thumbnail: string): Promise<boolean> => {
|
||||
const result = await fileStorage.updateThumbnail(fileId, thumbnail);
|
||||
if (result) bumpRevision();
|
||||
if (result) scheduleBump();
|
||||
return result;
|
||||
},
|
||||
[bumpRevision],
|
||||
[scheduleBump],
|
||||
);
|
||||
|
||||
const markFileAsProcessed = useCallback(
|
||||
async (fileId: FileId): Promise<boolean> => {
|
||||
const result = await fileStorage.markFileAsProcessed(fileId);
|
||||
if (result) bumpRevision();
|
||||
if (result) scheduleBump();
|
||||
return result;
|
||||
},
|
||||
[bumpRevision],
|
||||
[scheduleBump],
|
||||
);
|
||||
|
||||
const moveFilesToFolder = useCallback(
|
||||
async (fileIds: FileId[], folderId: FolderId | null): Promise<FileId[]> => {
|
||||
const updated = await fileStorage.moveFilesToFolder(fileIds, folderId);
|
||||
if (updated.length > 0) bumpRevision();
|
||||
return updated;
|
||||
return await fileStorage.moveFilesToFolder(fileIds, folderId);
|
||||
},
|
||||
[bumpRevision],
|
||||
[],
|
||||
);
|
||||
|
||||
const clearFolderForFiles = useCallback(
|
||||
async (folderIds: FolderId[]): Promise<number> => {
|
||||
const cleared = await fileStorage.clearFolderForFiles(folderIds);
|
||||
if (cleared > 0) bumpRevision();
|
||||
return cleared;
|
||||
return await fileStorage.clearFolderForFiles(folderIds);
|
||||
},
|
||||
[bumpRevision],
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoize the context value so consumers' useIndexedDB() reference stays
|
||||
|
||||
@@ -42,18 +42,23 @@ export function useLazyThumbnail(
|
||||
thumbnailUrl?: string,
|
||||
): string | undefined {
|
||||
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
|
||||
const attempted = useRef(false);
|
||||
// Latched only once a generation actually finishes (or is ruled out), so a
|
||||
// re-render that cancels queued work doesn't leave the row permanently blank.
|
||||
const generated = useRef(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
const { updateStirlingFileStub } = useFileManagement();
|
||||
// Held in a ref so their identity can't re-run the effect: a re-run cancels
|
||||
// the queued task, and the row would then be skipped forever.
|
||||
const depsRef = useRef({ indexedDB, updateStirlingFileStub });
|
||||
depsRef.current = { indexedDB, updateStirlingFileStub };
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl) setThumb(thumbnailUrl);
|
||||
}, [thumbnailUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
|
||||
if (thumbnailUrl || generated.current || size >= THUMBNAIL_SIZE_LIMIT)
|
||||
return;
|
||||
attempted.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
scheduleLazyThumb(async () => {
|
||||
@@ -61,13 +66,22 @@ export function useLazyThumbnail(
|
||||
// in the queue — skip the expensive byte load entirely.
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const file = await indexedDB.loadFile(fileId);
|
||||
if (!file || cancelled) return;
|
||||
const file = await depsRef.current.indexedDB.loadFile(fileId);
|
||||
if (cancelled) return;
|
||||
// No cached bytes to render from: don't queue this row again.
|
||||
if (!file) {
|
||||
generated.current = true;
|
||||
return;
|
||||
}
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (cancelled || !thumbnail) return;
|
||||
if (cancelled) return;
|
||||
generated.current = true;
|
||||
if (!thumbnail) return;
|
||||
setThumb(thumbnail);
|
||||
void indexedDB.updateThumbnail(fileId, thumbnail);
|
||||
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
|
||||
void depsRef.current.indexedDB.updateThumbnail(fileId, thumbnail);
|
||||
depsRef.current.updateStirlingFileStub(fileId, {
|
||||
thumbnailUrl: thumbnail,
|
||||
});
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
@@ -76,7 +90,7 @@ export function useLazyThumbnail(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
|
||||
}, [fileId, size, thumbnailUrl]);
|
||||
|
||||
return thumb;
|
||||
}
|
||||
|
||||
@@ -499,6 +499,7 @@ export default function HomePage() {
|
||||
gap={0}
|
||||
h="100%"
|
||||
className="flex-nowrap flex"
|
||||
bg="var(--c-bg)"
|
||||
>
|
||||
<MyFilesAwareFileSidebar
|
||||
ref={quickAccessRef}
|
||||
|
||||
@@ -64,6 +64,37 @@ export function legacyDerivedFromTool(
|
||||
class FileStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "files";
|
||||
private readonly changeListeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Notified when the SET of stored files changes - added, deleted, moved
|
||||
* between folders. Views subscribe via IndexedDBContext rather than relying
|
||||
* on each call site to announce itself; plenty of them (share-link imports,
|
||||
* watched folders, FileManagerContext) write here directly and used to leave
|
||||
* the file lists stale.
|
||||
*
|
||||
* Deliberately NOT fired for in-place edits to an existing row (thumbnail,
|
||||
* metadata, leaf flag): those run per file inside tool and policy batches,
|
||||
* where a global refresh per write is both wasteful and, for callers that
|
||||
* react by re-reading the workspace, unsafe. Callers that need those visible
|
||||
* bump the revision themselves.
|
||||
*/
|
||||
subscribeToChanges(listener: () => void): () => void {
|
||||
this.changeListeners.add(listener);
|
||||
return () => {
|
||||
this.changeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyChanged(): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.error("[fileStorage] change listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database connection using centralized manager
|
||||
@@ -176,6 +207,7 @@ class FileStorageService {
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
this.notifyChanged();
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -486,6 +518,7 @@ class FileStorageService {
|
||||
});
|
||||
});
|
||||
|
||||
if (updated.length > 0) this.notifyChanged();
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -534,6 +567,7 @@ class FileStorageService {
|
||||
}
|
||||
});
|
||||
|
||||
if (cleared > 0) this.notifyChanged();
|
||||
return cleared;
|
||||
}
|
||||
|
||||
@@ -549,7 +583,10 @@ class FileStorageService {
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onsuccess = () => {
|
||||
this.notifyChanged();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -563,7 +600,10 @@ class FileStorageService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.oncomplete = () => {
|
||||
this.notifyChanged();
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("Transaction aborted"));
|
||||
@@ -628,7 +668,10 @@ class FileStorageService {
|
||||
const request = store.clear();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onsuccess = () => {
|
||||
this.notifyChanged();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_TOOL_PANEL_MODE,
|
||||
} from "@app/constants/toolPanel";
|
||||
import { type ThemeMode } from "@app/constants/theme";
|
||||
import { type FilesPageViewMode } from "@app/constants/filesPageView";
|
||||
|
||||
export type LogoVariant = "modern" | "classic";
|
||||
|
||||
@@ -44,6 +45,8 @@ export interface UserPreferences {
|
||||
hideUnavailableConversions: boolean;
|
||||
logoVariant: LogoVariant | null;
|
||||
pdfRenderMode: PdfRenderMode;
|
||||
/** Last grid/list choice on the files page. */
|
||||
filesPageViewMode: FilesPageViewMode;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -64,6 +67,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
hideUnavailableConversions: false,
|
||||
logoVariant: null,
|
||||
pdfRenderMode: "normal",
|
||||
filesPageViewMode: "grid",
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "stirlingpdf_preferences";
|
||||
|
||||
@@ -241,8 +241,36 @@
|
||||
/* Config Modal colors (light mode) */
|
||||
--modal-nav-item: var(--p-gray-700);
|
||||
|
||||
/* API Keys section colors (light mode) */
|
||||
--api-keys-card-shadow: rgba(0, 0, 0, 0.06);
|
||||
--modal-nav-bg: var(--p-gray-100);
|
||||
--modal-nav-section-title: var(--p-gray-500);
|
||||
--modal-nav-item-active: var(--p-blue-500);
|
||||
--modal-nav-item-active-bg: color-mix(
|
||||
in srgb,
|
||||
var(--p-blue-500) 8%,
|
||||
transparent
|
||||
);
|
||||
--modal-content-bg: var(--p-white);
|
||||
--modal-header-border: rgba(0, 0, 0, 0.06);
|
||||
--api-keys-card-bg: var(--p-white);
|
||||
--api-keys-card-border: var(--p-gray-200);
|
||||
--api-keys-input-bg: var(--p-gray-50);
|
||||
--api-keys-input-border: var(--p-gray-200);
|
||||
--usage-inactive: var(--p-gray-200);
|
||||
--color-orange-50: var(--p-amber-400);
|
||||
--color-orange-100: var(--p-amber-400);
|
||||
--color-orange-200: var(--p-red-400);
|
||||
--color-orange-300: var(--p-red-400);
|
||||
--color-orange-400: var(--p-red-600);
|
||||
--color-amber-50: var(--p-amber-400);
|
||||
--color-amber-100: var(--p-amber-400);
|
||||
--color-amber-200: var(--p-amber-400);
|
||||
--color-amber-300: var(--p-amber-400);
|
||||
--color-amber-400: var(--p-amber-400);
|
||||
--color-amber-500: var(--p-amber-500);
|
||||
--color-amber-600: var(--p-amber-600);
|
||||
--color-amber-700: var(--p-amber-600);
|
||||
--color-amber-800: var(--p-amber-600);
|
||||
--color-amber-900: var(--p-amber-600);
|
||||
|
||||
/* PDF Report Colors (always light) */
|
||||
--pdf-light-text-muted: 100 116 139;
|
||||
@@ -469,8 +497,27 @@
|
||||
/* Onboarding (dark mode) */
|
||||
--onboarding-step-active: var(--p-gray-250);
|
||||
--onboarding-step-inactive: var(--p-zinc-500);
|
||||
/* API Keys section colors (dark mode) */
|
||||
--api-keys-card-shadow: none;
|
||||
/* Migrated from the former saas-theme.css (dark) — see the light block. */
|
||||
--modal-nav-bg: var(--p-zinc-850);
|
||||
--modal-nav-section-title: var(--p-zinc-300);
|
||||
--modal-nav-item-active: var(--p-blue-500);
|
||||
--modal-nav-item-active-bg: color-mix(
|
||||
in srgb,
|
||||
var(--p-blue-500) 15%,
|
||||
transparent
|
||||
);
|
||||
--modal-content-bg: var(--p-zinc-800);
|
||||
--modal-header-border: rgba(255, 255, 255, 0.05);
|
||||
--api-keys-card-bg: var(--p-zinc-800);
|
||||
--api-keys-card-border: var(--p-zinc-650);
|
||||
--api-keys-input-bg: var(--p-zinc-850);
|
||||
--api-keys-input-border: var(--p-zinc-650);
|
||||
--usage-inactive: var(--p-zinc-650);
|
||||
--color-orange-50: var(--p-amber-400);
|
||||
--color-orange-100: var(--p-amber-400);
|
||||
--color-orange-200: var(--p-red-400);
|
||||
--color-orange-300: var(--p-red-400);
|
||||
--color-orange-400: var(--p-red-600);
|
||||
|
||||
/* Code token colors (dark mode - Cursor-like) */
|
||||
--code-kw-color: var(--p-blue-400); /* purple */
|
||||
|
||||
@@ -70,6 +70,8 @@ const mockedApiClient = vi.mocked(apiClient);
|
||||
vi.mock("../../services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
// IndexedDBContext subscribes to storage changes on mount.
|
||||
subscribeToChanges: vi.fn().mockReturnValue(() => {}),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
return Promise.resolve({
|
||||
id: `mock-id-${file.name}`,
|
||||
|
||||
@@ -68,6 +68,8 @@ const mockedApiClient = vi.mocked(apiClient);
|
||||
vi.mock("../../services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
// IndexedDBContext subscribes to storage changes on mount.
|
||||
subscribeToChanges: vi.fn().mockReturnValue(() => {}),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
return Promise.resolve({
|
||||
id: `mock-id-${file.name}`,
|
||||
|
||||
@@ -12,7 +12,9 @@ test.describe("20. Edge Cases and Security", () => {
|
||||
test("should prevent XSS via search input", async ({ page }) => {
|
||||
await loginAndSetup(page);
|
||||
|
||||
// Step 1: Enter XSS payload in the search box
|
||||
// Step 1: Open the search box (the tool panel header shows a search
|
||||
// toggle; the field only mounts once it's pressed) and enter the payload
|
||||
await page.getByRole("button", { name: /search tools/i }).click();
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.fill('"><img src=x onerror=alert(1)>');
|
||||
|
||||
|
||||
@@ -876,4 +876,81 @@ test.describe("Files page", () => {
|
||||
expect(after).toBeGreaterThanOrEqual(before + 24);
|
||||
});
|
||||
});
|
||||
test.describe("Persistence and bulk actions", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
});
|
||||
|
||||
test("grid/list choice survives a reload", async ({ page }) => {
|
||||
await seedFiles(page, [
|
||||
{ id: "f1", name: "one.pdf", remoteStorageId: null },
|
||||
{ id: "f2", name: "two.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
|
||||
await page.locator('label[for$="-list"]').first().click();
|
||||
await expect(page.locator(".files-page-list-row").first()).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-list-row").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const stored = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem("stirlingpdf_preferences") ?? "{}"),
|
||||
);
|
||||
expect(stored.filesPageViewMode).toBe("list");
|
||||
});
|
||||
|
||||
test("deleting a large selection clears the grid without waiting on storage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedFiles(
|
||||
page,
|
||||
Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `bulk-${i}`,
|
||||
name: `bulk_${i}.pdf`,
|
||||
remoteStorageId: null,
|
||||
})),
|
||||
);
|
||||
await gotoFilesPage(page);
|
||||
|
||||
const cards = page.locator(
|
||||
".files-page-card:not(.files-page-skeleton-card)",
|
||||
);
|
||||
await expect(cards).toHaveCount(50, { timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: /select all/i }).click();
|
||||
await page.keyboard.press("Delete");
|
||||
|
||||
// Rows go immediately - the IDB write and the refresh that follows it
|
||||
// must not hold the grid on stale contents.
|
||||
await expect(cards).toHaveCount(0, { timeout: 2_000 });
|
||||
|
||||
// ...and the delete really reached storage. Checked directly because the
|
||||
// seed init-script would re-populate IDB on a reload.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const open = window.indexedDB.open("stirling-pdf-files");
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
const req = db
|
||||
.transaction("files", "readonly")
|
||||
.objectStore("files")
|
||||
.count();
|
||||
req.onsuccess = () => {
|
||||
resolve(req.result);
|
||||
db.close();
|
||||
};
|
||||
};
|
||||
}),
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,10 +54,12 @@ test.describe("13. Language / Localization", () => {
|
||||
// Step 5: Wait for page reload (language change triggers window.location.reload())
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Step 6: Verify the UI text is in English
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// Step 6: Verify the UI text is in English. The tool search is a
|
||||
// header toggle, so assert its English label rather than the field,
|
||||
// which only mounts once the toggle is pressed.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /search tools/i }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,14 @@ test.describe("2. Main Dashboard / Home Page", () => {
|
||||
page.locator('[data-testid="config-button"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Tool search sits behind a header toggle now, so assert the affordance
|
||||
// AND that pressing it actually mounts a usable search field — dropping
|
||||
// the second half would stop covering the input entirely.
|
||||
const searchToggle = page
|
||||
.getByRole("button", { name: /search tools/i })
|
||||
.first();
|
||||
await expect(searchToggle).toBeVisible();
|
||||
await searchToggle.click();
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||
|
||||
await expect(
|
||||
@@ -74,7 +82,10 @@ test.describe("2. Main Dashboard / Home Page", () => {
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||
// Tool search is a header toggle; the field mounts only once pressed.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /search tools/i }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
/**
|
||||
* The tool panel header shows a search *toggle*; the field only mounts once
|
||||
* it's pressed. Open it and hand back the focused input.
|
||||
*/
|
||||
async function openToolSearch(page: Page) {
|
||||
await page.getByRole("button", { name: /search tools/i }).click();
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await expect(searchBox).toBeVisible({ timeout: 5000 });
|
||||
return searchBox;
|
||||
}
|
||||
|
||||
test.describe("3. Tool Search", () => {
|
||||
test.describe("3.1 Search - Happy Path", () => {
|
||||
test("should filter tools in real time based on search input", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Click on the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.click();
|
||||
// Step 1: Open the search box from the header toggle
|
||||
const searchBox = await openToolSearch(page);
|
||||
|
||||
// Step 2: Type "merge"
|
||||
await searchBox.fill("merge");
|
||||
@@ -31,9 +42,8 @@ test.describe("3. Tool Search", () => {
|
||||
test("should handle queries with no matching tools gracefully", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Click on the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await searchBox.click();
|
||||
// Step 1: Open the search box from the header toggle
|
||||
const searchBox = await openToolSearch(page);
|
||||
|
||||
// Step 2: Type xyznonexistent123
|
||||
await searchBox.fill("xyznonexistent123");
|
||||
@@ -61,7 +71,7 @@ test.describe("3. Tool Search", () => {
|
||||
test.describe("3.3 Search - Special Characters", () => {
|
||||
test("should sanitize search input against XSS", async ({ page }) => {
|
||||
// Step 1: Type XSS payload into the search box
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
const searchBox = await openToolSearch(page);
|
||||
await searchBox.fill("<script>alert(1)</script>");
|
||||
|
||||
// Step 2: Verify no script execution occurs (no alert dialog)
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
:root,
|
||||
[data-theme="light"],
|
||||
html[data-app-theme="light"] {
|
||||
--c-bg: var(--p-gray-50);
|
||||
--c-bg: var(--p-paper);
|
||||
--c-bg-raised: var(--p-white);
|
||||
--c-surface: var(--p-white);
|
||||
--c-surface: var(--p-snow);
|
||||
--c-surface-raised: var(--p-white);
|
||||
--c-surface-sunken: var(--p-gray-100);
|
||||
--c-input-bg: var(--p-white);
|
||||
@@ -15,12 +15,17 @@ html[data-app-theme="light"] {
|
||||
--c-active: var(--p-gray-100);
|
||||
--c-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
--c-text: var(--p-gray-900);
|
||||
--c-text: var(--p-ink);
|
||||
--c-text-muted: var(--p-gray-600);
|
||||
--c-text-subtle: var(--p-gray-500);
|
||||
--c-text-subtle: var(--p-gray-550);
|
||||
--c-text-on-primary: var(--p-white);
|
||||
|
||||
--c-border: var(--p-gray-250);
|
||||
--c-btn-solid: var(--c-text);
|
||||
--c-btn-inverse: var(--p-snow);
|
||||
--c-btn-secondary: var(--c-btn-inverse);
|
||||
--c-btn-secondary-border: var(--c-border);
|
||||
|
||||
--c-border: var(--p-c-f0f0f0);
|
||||
--c-border-subtle: var(--p-gray-200);
|
||||
--c-border-strong: var(--p-gray-400);
|
||||
|
||||
@@ -55,7 +60,8 @@ html[data-app-theme="light"] {
|
||||
marks, vendor colours, categorical avatar dots, static illustrations,
|
||||
and the multi-hue gradients on feature/upgrade/onboarding surfaces.
|
||||
Named here so components reference a --c-* token, never a raw --p-*. */
|
||||
--c-brand-mark: var(--p-brand-red-650); /* Stirling logo mark fill */
|
||||
--c-brand-mark: var(--p-brand-red-650);
|
||||
--c-brand-mark-soft: var(--p-brand-red-400); /* Stirling logo mark fill */
|
||||
--c-accent-stripe: var(--p-periwinkle-500); /* Stripe "connect" CTA */
|
||||
|
||||
/* Feature-accent hues (fixed) used as stops in multi-hue gradients. */
|
||||
@@ -110,9 +116,9 @@ html[data-app-theme="light"] {
|
||||
/* ── MIDNIGHT (original navy) — also the default portal/Storybook dark ────── */
|
||||
[data-theme="dark"],
|
||||
html[data-app-theme="midnight"] {
|
||||
--c-bg: var(--p-zinc-900);
|
||||
--c-bg: var(--p-c-141416);
|
||||
--c-bg-raised: var(--p-zinc-850);
|
||||
--c-surface: var(--p-zinc-800);
|
||||
--c-surface: var(--p-c-1a1a1d);
|
||||
--c-surface-raised: var(--p-zinc-650);
|
||||
--c-surface-sunken: var(--p-zinc-850);
|
||||
--c-input-bg: var(--p-zinc-650);
|
||||
@@ -120,12 +126,16 @@ html[data-app-theme="midnight"] {
|
||||
--c-active: var(--p-gray-800);
|
||||
--c-overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--c-text: var(--p-zinc-100);
|
||||
--c-text: var(--p-snow);
|
||||
--c-text-muted: var(--p-zinc-200);
|
||||
--c-text-subtle: var(--p-zinc-300);
|
||||
--c-text-on-primary: var(--p-white);
|
||||
--c-btn-solid: var(--c-text);
|
||||
--c-btn-inverse: var(--p-ink);
|
||||
--c-btn-secondary: var(--p-c-1a1a1d);
|
||||
--c-btn-secondary-border: var(--p-c-343439);
|
||||
|
||||
--c-border: var(--p-zinc-650);
|
||||
--c-border: var(--p-c-28282d);
|
||||
--c-border-subtle: rgba(255, 255, 255, 0.05);
|
||||
--c-border-strong: var(--p-zinc-500);
|
||||
|
||||
@@ -157,9 +167,9 @@ html[data-app-theme="custom"] {
|
||||
--c-accent-fg: var(--c-primary);
|
||||
|
||||
/* Primary-tinted surfaces (light base). Neutralised by the default override. */
|
||||
--c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-gray-50));
|
||||
--c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-paper));
|
||||
--c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
|
||||
--c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-white));
|
||||
--c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-snow));
|
||||
--c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
|
||||
--c-surface-sunken: color-mix(
|
||||
in srgb,
|
||||
@@ -169,7 +179,7 @@ html[data-app-theme="custom"] {
|
||||
--c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white));
|
||||
--c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50));
|
||||
--c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100));
|
||||
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-gray-250));
|
||||
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-f0f0f0));
|
||||
--c-border-subtle: color-mix(
|
||||
in srgb,
|
||||
var(--c-primary) 10%,
|
||||
@@ -270,16 +280,20 @@ html[data-app-theme="custom"] {
|
||||
/* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */
|
||||
html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
|
||||
/* Neutral text / borders / overlay (not accent-tinted). */
|
||||
--c-text: var(--p-zinc-100);
|
||||
--c-text: var(--p-snow);
|
||||
--c-text-muted: var(--p-zinc-200);
|
||||
--c-text-subtle: var(--p-zinc-300);
|
||||
--c-btn-solid: var(--c-text);
|
||||
--c-btn-inverse: var(--p-ink);
|
||||
--c-btn-secondary: var(--p-c-1a1a1d);
|
||||
--c-btn-secondary-border: var(--p-c-343439);
|
||||
--c-border-strong: var(--p-zinc-500);
|
||||
--c-overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* Accent-tinted surfaces (dark base). Neutralised by the default override. */
|
||||
--c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-950));
|
||||
--c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-141416));
|
||||
--c-bg-raised: color-mix(in srgb, var(--c-primary) 9%, var(--p-zinc-850));
|
||||
--c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-800));
|
||||
--c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-1a1a1d));
|
||||
--c-surface-raised: color-mix(
|
||||
in srgb,
|
||||
var(--c-primary) 9%,
|
||||
@@ -293,7 +307,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
|
||||
--c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900));
|
||||
--c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750));
|
||||
--c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700));
|
||||
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-zinc-650));
|
||||
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-28282d));
|
||||
--c-border-subtle: color-mix(
|
||||
in srgb,
|
||||
var(--c-primary) 10%,
|
||||
@@ -335,27 +349,27 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
|
||||
|
||||
/* ── DEFAULT (no tint) — surfaces opt out of the accent tint (neutral white/grey light, zinc dark); --c-primary stays for buttons. Extra [data-accent="default"] beats the tinted blocks. ── */
|
||||
html[data-app-theme="custom"][data-accent="default"] {
|
||||
--c-bg: var(--p-gray-50);
|
||||
--c-bg: var(--p-paper);
|
||||
--c-bg-raised: var(--p-white);
|
||||
--c-surface: var(--p-white);
|
||||
--c-surface: var(--p-snow);
|
||||
--c-surface-raised: var(--p-white);
|
||||
--c-surface-sunken: var(--p-gray-100);
|
||||
--c-input-bg: var(--p-white);
|
||||
--c-hover: var(--p-gray-50);
|
||||
--c-active: var(--p-gray-100);
|
||||
--c-border: var(--p-gray-250);
|
||||
--c-border: var(--p-c-f0f0f0);
|
||||
--c-border-subtle: var(--p-gray-200);
|
||||
}
|
||||
|
||||
html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="dark"] {
|
||||
--c-bg: var(--p-zinc-950);
|
||||
--c-bg: var(--p-c-141416);
|
||||
--c-bg-raised: var(--p-zinc-850);
|
||||
--c-surface: var(--p-zinc-800);
|
||||
--c-surface: var(--p-c-1a1a1d);
|
||||
--c-surface-raised: var(--p-zinc-775);
|
||||
--c-surface-sunken: var(--p-zinc-900);
|
||||
--c-input-bg: var(--p-zinc-900);
|
||||
--c-hover: var(--p-zinc-750);
|
||||
--c-active: var(--p-zinc-700);
|
||||
--c-border: var(--p-zinc-650);
|
||||
--c-border: var(--p-c-28282d);
|
||||
--c-border-subtle: var(--p-zinc-700);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
--radius-xl: 16px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
--radius-nav: 0.625rem;
|
||||
--nav-gutter: 0.5rem;
|
||||
--nav-rail-w: 3.5rem;
|
||||
|
||||
/* ── Layout sizing ── */
|
||||
--footer-height: 2rem;
|
||||
--landing-stack-w: 224px;
|
||||
@@ -51,6 +55,7 @@
|
||||
--motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--motion-slow: 0.3s ease;
|
||||
--motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--motion-spring: 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--fullscreen-anim-duration-in: 0.28s;
|
||||
--fullscreen-anim-duration-out: 0.22s;
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
--p-gray-300: #d1d5db;
|
||||
--p-gray-400: #9ca3af;
|
||||
--p-gray-500: #6b7280;
|
||||
/* Subtle body text in light mode. gray-500 clears 4.5:1 on pure white but
|
||||
only reaches 4.39:1 on the --p-paper canvas; this is the same hue nudged
|
||||
dark enough to pass (4.87:1) while staying lighter than gray-600. */
|
||||
--p-gray-550: #646b76;
|
||||
--p-gray-600: #4b5563;
|
||||
--p-gray-700: #374151;
|
||||
--p-gray-800: #1f2937;
|
||||
@@ -29,6 +33,10 @@
|
||||
--p-zinc-300: #71717a;
|
||||
--p-zinc-200: #a1a1aa;
|
||||
--p-zinc-100: #f4f4f5;
|
||||
--p-c-141416: #141416;
|
||||
--p-c-1a1a1d: #1a1a1d;
|
||||
--p-c-28282d: #28282d;
|
||||
--p-c-343439: #343439;
|
||||
--p-blue-400: #60a5fa;
|
||||
--p-blue-500: #3b82f6;
|
||||
--p-blue-600: #2563eb;
|
||||
@@ -46,6 +54,7 @@
|
||||
/* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */
|
||||
--p-brand-red-200: #d9a8a8;
|
||||
--p-brand-red-300: #d98a8a;
|
||||
--p-brand-red-400: #ad7373;
|
||||
--p-brand-red-650: #8e3131;
|
||||
--p-brand-red-700: #7a2929;
|
||||
--p-brand-red-900: #5a2424;
|
||||
@@ -84,6 +93,10 @@
|
||||
--p-tint-blue: #eef1fb;
|
||||
--p-tint-violet: #f6f4fc;
|
||||
--p-tint-pink: #fbf4f7;
|
||||
--p-paper: #f5f4f1;
|
||||
--p-ink: #373530;
|
||||
--p-snow: #fafafa;
|
||||
--p-c-f0f0f0: #f0f0f0;
|
||||
|
||||
/* Notion-style procurement view palette. */
|
||||
--p-notion-blue: #2383e2;
|
||||
@@ -92,7 +105,6 @@
|
||||
--p-notion-ink: #37352f;
|
||||
--p-notion-gray: #9b9a97;
|
||||
--p-notion-gray-strong: #787774;
|
||||
--p-notion-paper: #f5f4f1;
|
||||
--p-notion-paper-2: #f0eee9;
|
||||
--p-notion-border: #e3e1dc;
|
||||
--p-notion-border-2: #eae8e3;
|
||||
|
||||
@@ -326,16 +326,6 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@keyframes pulseRing {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -103,15 +103,22 @@ export const ActionIcon = forwardRef<HTMLButtonElement, ActionIconProps>(
|
||||
"--ai-hover-color": "var(--c-text)",
|
||||
"--ai-bd": "1px solid transparent",
|
||||
}
|
||||
: {
|
||||
"--ai-bg": "transparent",
|
||||
"--ai-hover": "var(--_tint)",
|
||||
"--ai-color": "var(--_text)",
|
||||
"--ai-bd":
|
||||
variant === "secondary"
|
||||
? "1px solid var(--_bd)"
|
||||
: "1px solid transparent",
|
||||
};
|
||||
: variant === "secondary"
|
||||
? {
|
||||
// Filled when the accent defines --_solid-2 (default = inverse
|
||||
// ink/snow); otherwise falls back to the outlined look.
|
||||
"--ai-bg": "var(--_solid-2, transparent)",
|
||||
"--ai-hover": "var(--_solid-2-hover, var(--_tint))",
|
||||
"--ai-color": "var(--_on-2, var(--_text))",
|
||||
"--ai-bd": "1px solid var(--_bd-2, var(--_bd))",
|
||||
}
|
||||
: {
|
||||
// tertiary (ghost) — neutral text + hover for the default accent.
|
||||
"--ai-bg": "transparent",
|
||||
"--ai-hover": "var(--_tert-tint, var(--_tint))",
|
||||
"--ai-color": "var(--_tert-text, var(--_text))",
|
||||
"--ai-bd": "1px solid transparent",
|
||||
};
|
||||
|
||||
// Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing.
|
||||
const Comp = MantineActionIcon as ElementType;
|
||||
|
||||
@@ -193,15 +193,23 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
"--button-hover-color": "var(--c-text)",
|
||||
"--button-bd": "1px solid transparent",
|
||||
}
|
||||
: {
|
||||
"--button-bg": "transparent",
|
||||
"--button-hover": "var(--_tint)",
|
||||
"--button-color": "var(--_text)",
|
||||
"--button-bd":
|
||||
variant === "secondary"
|
||||
? "1px solid var(--_bd)"
|
||||
: "1px solid transparent",
|
||||
};
|
||||
: variant === "secondary"
|
||||
? {
|
||||
// Filled when the accent defines --_solid-2 (default = inverse
|
||||
// ink/snow); otherwise falls back to the outlined look.
|
||||
"--button-bg": "var(--_solid-2, transparent)",
|
||||
"--button-hover": "var(--_solid-2-hover, var(--_tint))",
|
||||
"--button-color": "var(--_on-2, var(--_text))",
|
||||
"--button-bd": "1px solid var(--_bd-2, var(--_bd))",
|
||||
}
|
||||
: {
|
||||
// tertiary (ghost) — neutral text + hover when the accent
|
||||
// defines --_tert-* (default); otherwise the accent link colour.
|
||||
"--button-bg": "transparent",
|
||||
"--button-hover": "var(--_tert-tint, var(--_tint))",
|
||||
"--button-color": "var(--_tert-text, var(--_text))",
|
||||
"--button-bd": "1px solid transparent",
|
||||
};
|
||||
|
||||
// Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing.
|
||||
const Comp = MantineButton as ElementType;
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
background: var(--c-primary);
|
||||
color: var(--c-text-on-primary);
|
||||
border: 1px solid var(--c-btn-secondary-border);
|
||||
background: var(--c-btn-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--c-primary) 40%, transparent);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition:
|
||||
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
box-shadow 180ms ease;
|
||||
@@ -20,7 +19,7 @@
|
||||
|
||||
.chat-fab-btn:hover {
|
||||
transform: scale(1.09);
|
||||
box-shadow: 0 6px 22px color-mix(in srgb, var(--c-primary) 52%, transparent);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.chat-fab-btn:active {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||
import "@app/ui/ChatFABButton.css";
|
||||
|
||||
export interface ChatFABButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
@@ -25,20 +26,10 @@ export function ChatFABButton({
|
||||
|
||||
return (
|
||||
<button type="button" className={classes} {...rest}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 192 192"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
|
||||
</svg>
|
||||
{/* Decorative: the button itself carries the accessible name. */}
|
||||
<span aria-hidden="true" style={{ display: "inline-flex" }}>
|
||||
<BrandMark height="1.875rem" />
|
||||
</span>
|
||||
{loading && !showTick && (
|
||||
<span className="chat-fab-btn__pulse" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
@@ -34,9 +34,7 @@ export const SpaceBetween: Story = {
|
||||
}}
|
||||
>
|
||||
<span>Pipeline name</span>
|
||||
<StatusBadge tone="success" pulse>
|
||||
healthy
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="success">healthy</StatusBadge>
|
||||
</Inline>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Shared brand lockup (mark + "Stirling" wordmark). The wordmark toggles by
|
||||
colour scheme so one component works in the editor and the portal. */
|
||||
.sui-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sui-logo--vertical {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sui-logo__mark,
|
||||
.sui-logo__wordmark {
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Light wordmark by default; dark wordmark only under a dark scheme. */
|
||||
.sui-logo__wordmark--dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .sui-logo__wordmark--light,
|
||||
[data-theme="dark"] .sui-logo__wordmark--light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .sui-logo__wordmark--dark,
|
||||
[data-theme="dark"] .sui-logo__wordmark--dark {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
|
||||
const meta: Meta<typeof Logo> = {
|
||||
title: "Brand/Logo",
|
||||
component: Logo,
|
||||
parameters: { layout: "centered" },
|
||||
args: { variant: "iconAndText", orientation: "horizontal" },
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "inline-radio",
|
||||
options: ["iconOnly", "iconAndText", "textOnly"],
|
||||
},
|
||||
orientation: {
|
||||
control: "inline-radio",
|
||||
options: ["horizontal", "vertical"],
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Logo>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
/** The three variants side by side (toggle Storybook's theme to see the
|
||||
* wordmark track light/dark). */
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 48, alignItems: "center" }}>
|
||||
<Logo variant="iconOnly" />
|
||||
<Logo variant="textOnly" />
|
||||
<Logo variant="iconAndText" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** iconAndText stacked — as used in the workbench empty state. */
|
||||
export const Stacked: Story = {
|
||||
render: () => (
|
||||
<Logo
|
||||
variant="iconAndText"
|
||||
orientation="vertical"
|
||||
iconHeight="3.5rem"
|
||||
textHeight="1.5rem"
|
||||
gap="0.75rem"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import markUrl from "@app/assets/brand/branding-logo/logo-mark.svg";
|
||||
import wordmarkLightUrl from "@app/assets/brand/branding-logo/wordmark-light.svg";
|
||||
import wordmarkDarkUrl from "@app/assets/brand/branding-logo/wordmark-dark.svg";
|
||||
import "@app/ui/Logo.css";
|
||||
|
||||
/** iconOnly = mark; textOnly = "Stirling" wordmark; iconAndText = both. */
|
||||
export type LogoVariant = "iconOnly" | "iconAndText" | "textOnly";
|
||||
|
||||
interface LogoProps {
|
||||
variant?: LogoVariant;
|
||||
/** Layout for iconAndText: mark left of text, or stacked above it. */
|
||||
orientation?: "horizontal" | "vertical";
|
||||
/** Height of the mark (CSS length). */
|
||||
iconHeight?: string;
|
||||
/** Height of the wordmark (CSS length). */
|
||||
textHeight?: string;
|
||||
/** Gap between mark and wordmark. */
|
||||
gap?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared brand lockup used across editor + processor. The mark is theme-
|
||||
* agnostic; the wordmark swaps light/dark via CSS so it tracks the active
|
||||
* colour scheme in both the editor (data-mantine-color-scheme) and the portal
|
||||
* (data-theme).
|
||||
*/
|
||||
export function Logo({
|
||||
variant = "iconAndText",
|
||||
orientation = "horizontal",
|
||||
iconHeight = "1.75rem",
|
||||
textHeight = "1rem",
|
||||
gap = "0.5rem",
|
||||
className,
|
||||
style,
|
||||
alt = "Stirling",
|
||||
}: LogoProps) {
|
||||
const showIcon = variant === "iconOnly" || variant === "iconAndText";
|
||||
const showText = variant === "textOnly" || variant === "iconAndText";
|
||||
|
||||
const cls = [
|
||||
"sui-logo",
|
||||
orientation === "vertical" ? "sui-logo--vertical" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Layout set inline so a consumer's className can't restack the lockup.
|
||||
const layoutStyle: CSSProperties = {
|
||||
display: orientation === "vertical" ? "flex" : "inline-flex",
|
||||
flexDirection: orientation === "vertical" ? "column" : "row",
|
||||
alignItems: "center",
|
||||
gap,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cls} style={{ ...layoutStyle, ...style }}>
|
||||
{showIcon && (
|
||||
<img
|
||||
className="sui-logo__mark"
|
||||
src={markUrl}
|
||||
alt={showText ? "" : alt}
|
||||
aria-hidden={showText ? true : undefined}
|
||||
style={{ height: iconHeight }}
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<>
|
||||
<img
|
||||
className="sui-logo__wordmark sui-logo__wordmark--light"
|
||||
src={wordmarkLightUrl}
|
||||
alt={alt}
|
||||
style={{ height: textHeight }}
|
||||
/>
|
||||
<img
|
||||
className="sui-logo__wordmark sui-logo__wordmark--dark"
|
||||
src={wordmarkDarkUrl}
|
||||
alt={alt}
|
||||
style={{ height: textHeight }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,79 @@
|
||||
.sui-metric-strip {
|
||||
.sui-metric-strip--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.sui-metric-strip {
|
||||
.sui-metric-strip--grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.sui-metric-strip {
|
||||
.sui-metric-strip--grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.sui-metric-strip--row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.sui-metric-strip__leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
padding-right: 1.25rem;
|
||||
border-right: 1px solid var(--c-border-subtle);
|
||||
align-self: stretch;
|
||||
/* Neutral (ink) tint for a currentColor icon in the leading slot. */
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.sui-metric-strip--row .sui-metric {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.sui-metric-strip--row .sui-metric__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-metric-strip--row .sui-metric__value {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sui-metric-strip--row .sui-metric__footer {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.sui-metric-strip--row {
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem 2rem;
|
||||
}
|
||||
.sui-metric-strip__leading {
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.sui-metric-strip--row .sui-metric {
|
||||
flex-basis: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,25 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MetricStrip } from "@app/ui/MetricStrip";
|
||||
import { MetricCard } from "@app/ui/MetricCard";
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={22}
|
||||
height={22}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof MetricStrip> = {
|
||||
title: "Layout/MetricStrip",
|
||||
component: MetricStrip,
|
||||
@@ -22,3 +41,30 @@ export const Default: Story = {
|
||||
</MetricStrip>
|
||||
),
|
||||
};
|
||||
|
||||
export const Row: Story = {
|
||||
render: () => (
|
||||
<MetricStrip layout="row" leading={<ShieldIcon />}>
|
||||
<MetricCard
|
||||
label="Active policies"
|
||||
value="2"
|
||||
description="Enforcing on upload/export"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Paused"
|
||||
value="0"
|
||||
description="Configured but not firing"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Categories"
|
||||
value="6"
|
||||
description="Available to configure"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Docs enforced"
|
||||
value="0"
|
||||
description="Across active policies"
|
||||
/>
|
||||
</MetricStrip>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -3,21 +3,35 @@ import "@app/ui/MetricStrip.css";
|
||||
|
||||
export interface MetricStripProps {
|
||||
children: ReactNode;
|
||||
layout?: "grid" | "row";
|
||||
leading?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive grid wrapper for a row of {@link MetricCard}s — the prototype's
|
||||
* "metric strip" (Home, Sources, Usage, Infrastructure all use it). Four-up on
|
||||
* wide screens, two-up below 50rem.
|
||||
* Wrapper for a set of {@link MetricCard}s. In the default `grid` layout it's
|
||||
* the prototype's four-up "metric strip" (Home, Sources, Usage,
|
||||
* Infrastructure). In `row` layout the cards render as inline columns inside a
|
||||
* single bordered strip with an optional leading logo/title section.
|
||||
*/
|
||||
export function MetricStrip({ children, className }: MetricStripProps) {
|
||||
export function MetricStrip({
|
||||
children,
|
||||
layout = "grid",
|
||||
leading,
|
||||
className,
|
||||
}: MetricStripProps) {
|
||||
const classes = [
|
||||
"sui-metric-strip",
|
||||
`sui-metric-strip--${layout}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div
|
||||
className={["sui-metric-strip", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div className={classes}>
|
||||
{layout === "row" && leading != null && (
|
||||
<div className="sui-metric-strip__leading">{leading}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
/* Shared sidebar nav row; surface-specific spacing lives with each surface. */
|
||||
.sui-navitem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
margin: 0.0625rem 0.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--c-text-subtle);
|
||||
padding: 0.5rem 0.75rem;
|
||||
/* Full-bleed: the highlight spans its surface edge to edge, with the
|
||||
surface's own overflow clipping it at the rounded corners. */
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
/* Own the button chrome; the editor and portal reset buttons differently. */
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
text-align: left;
|
||||
}
|
||||
/* --c-hover alone is near-invisible on the surfaces these rows sit on. */
|
||||
.sui-navitem:hover {
|
||||
background: var(--c-hover);
|
||||
color: var(--c-text-muted);
|
||||
background: var(--c-active);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.sui-navitem.is-active {
|
||||
background: var(--c-primary-subtle);
|
||||
@@ -25,15 +35,31 @@
|
||||
}
|
||||
.sui-navitem.is-active:hover {
|
||||
background: var(--c-primary-subtle);
|
||||
color: var(--c-accent-fg);
|
||||
}
|
||||
.sui-navitem.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.sui-navitem.is-disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
.sui-navitem__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
font-size: 1.125rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Icons arrive from several sources; pin them all to the row's icon box. */
|
||||
.sui-navitem__icon > svg {
|
||||
font-size: inherit;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
.sui-navitem__label {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -44,7 +70,44 @@
|
||||
}
|
||||
.sui-navitem:focus-visible {
|
||||
outline: 0.125rem solid var(--c-primary);
|
||||
outline-offset: 0.125rem;
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
/* ---- Collapsed rail (icon only) ---- */
|
||||
.sui-navitem--icon-only {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
/* ---- Hover icon swap ---- */
|
||||
.sui-navitem__icon--swap {
|
||||
position: relative;
|
||||
}
|
||||
.sui-navitem__icon--swap > span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
.sui-navitem__icon-hover {
|
||||
opacity: 0;
|
||||
}
|
||||
.sui-navitem:hover:not(.is-disabled) .sui-navitem__icon-rest {
|
||||
opacity: 0;
|
||||
}
|
||||
.sui-navitem:hover:not(.is-disabled) .sui-navitem__icon-hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Field row: a row hosting an inline control, so not a <button> ---- */
|
||||
.sui-navitem--field {
|
||||
cursor: default;
|
||||
}
|
||||
.sui-navitem--field:hover {
|
||||
background: transparent;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
/* ---- Status accent (optional) ---- */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { NavItem } from "@app/ui/NavItem";
|
||||
import { SectionDivider } from "@app/ui/SectionDivider";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
|
||||
function Dot({ color = "var(--c-primary)" }: { color?: string }) {
|
||||
return (
|
||||
@@ -67,6 +68,100 @@ export const WithTrailingBadge: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Every state side by side; hover a row to see the hover treatment. */
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<div>
|
||||
<NavItem id="rest" label="Rest" icon={<Dot />} />
|
||||
<NavItem id="active" label="Active" icon={<Dot />} isActive />
|
||||
<NavItem id="disabled" label="Disabled" icon={<Dot />} disabled />
|
||||
<NavItem
|
||||
id="accent"
|
||||
label="Accent (status edge)"
|
||||
icon={<Dot color="var(--color-green)" />}
|
||||
accent="green"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Collapsed rail: `iconOnly` centres the icon and drops the label. */
|
||||
export const IconOnlyRail: Story = {
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div
|
||||
style={{
|
||||
width: "3rem",
|
||||
background: "var(--c-bg-raised)",
|
||||
padding: "6px 0",
|
||||
border: "1px solid var(--c-border)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<div>
|
||||
<NavItem id="search" label="Search" icon={<Dot />} iconOnly />
|
||||
<NavItem
|
||||
id="files"
|
||||
label="My Files"
|
||||
icon={<Dot color="var(--color-purple)" />}
|
||||
iconOnly
|
||||
/>
|
||||
<NavItem id="home" label="Home" icon={<Dot />} iconOnly isActive />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** `hoverIcon` cross-fades in on hover, e.g. a vendor mark gaining colour. */
|
||||
export const HoverIconSwap: Story = {
|
||||
render: () => (
|
||||
<div>
|
||||
<NavItem
|
||||
id="drive"
|
||||
label="Google Drive"
|
||||
icon={<GoogleDriveIcon />}
|
||||
hoverIcon={<GoogleDriveIcon colored />}
|
||||
/>
|
||||
<NavItem
|
||||
id="drive-off"
|
||||
label="Google Drive (not configured)"
|
||||
icon={<GoogleDriveIcon />}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** The field variant: a row hosting an inline control, so not a `<button>`. */
|
||||
export const FieldRow: Story = {
|
||||
render: () => (
|
||||
<div>
|
||||
<div className="sui-navitem sui-navitem--field">
|
||||
<span className="sui-navitem__icon">
|
||||
<Dot />
|
||||
</span>
|
||||
<input
|
||||
placeholder="Search files..."
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
font: "inherit",
|
||||
color: "var(--c-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<NavItem id="search" label="Search" icon={<Dot />} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InContext_SidebarGroup: Story = {
|
||||
render: () => {
|
||||
function Bound() {
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import "@app/ui/NavItem.css";
|
||||
|
||||
export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red";
|
||||
|
||||
export interface NavItemProps {
|
||||
type NativeButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"onClick" | "disabled" | "className" | "id" | "aria-label" | "children"
|
||||
>;
|
||||
|
||||
export interface NavItemProps extends NativeButtonProps {
|
||||
/** Stable view id passed to the click handler. */
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
/** Show the active highlight (navActive background, navActiveText colour). */
|
||||
/** Second icon cross-faded in on hover (e.g. a vendor mark gaining colour). */
|
||||
hoverIcon?: ReactNode;
|
||||
/** Show the active highlight (primary-subtle background, accent text). */
|
||||
isActive?: boolean;
|
||||
/** Dimmed and inert, but still hoverable so a tooltip can explain why. */
|
||||
disabled?: boolean;
|
||||
/** Collapsed rail: icon only; the label stays the accessible name. */
|
||||
iconOnly?: boolean;
|
||||
/**
|
||||
* Optional status accent: draws a left edge bar in the tone colour and tints
|
||||
* the leading icon to match. For listing live/paused/etc. entities.
|
||||
@@ -21,45 +32,66 @@ export interface NavItemProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar navigation row matching the prototype's hover + active styling.
|
||||
*
|
||||
* Active styling: navActive background, navActiveText colour, weight 500.
|
||||
* Hover styling: navHover background, navHoverText colour (only when not
|
||||
* already active). An optional `accent` adds a status edge bar + icon tint.
|
||||
*/
|
||||
export function NavItem({
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
accent,
|
||||
trailing,
|
||||
onClick,
|
||||
className,
|
||||
}: NavItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(id)}
|
||||
className={[
|
||||
"sui-navitem",
|
||||
isActive ? "is-active" : "",
|
||||
accent ? "sui-navitem--accent" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
data-accent={accent}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{icon && (
|
||||
<span className="sui-navitem__icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="sui-navitem__label">{label}</span>
|
||||
{trailing && <span className="sui-navitem__trailing">{trailing}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
/** The shared sidebar navigation row (portal nav, editor file sidebar). */
|
||||
export const NavItem = forwardRef<HTMLButtonElement, NavItemProps>(
|
||||
function NavItem(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
hoverIcon,
|
||||
isActive,
|
||||
disabled,
|
||||
iconOnly,
|
||||
accent,
|
||||
trailing,
|
||||
onClick,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={disabled ? undefined : () => onClick?.(id)}
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
className={[
|
||||
"sui-navitem",
|
||||
isActive ? "is-active" : "",
|
||||
disabled ? "is-disabled" : "",
|
||||
iconOnly ? "sui-navitem--icon-only" : "",
|
||||
accent ? "sui-navitem--accent" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
data-accent={accent}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-label={label}
|
||||
{...rest}
|
||||
>
|
||||
{icon &&
|
||||
(hoverIcon ? (
|
||||
<span
|
||||
className="sui-navitem__icon sui-navitem__icon--swap"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="sui-navitem__icon-rest">{icon}</span>
|
||||
<span className="sui-navitem__icon-hover">{hoverIcon}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="sui-navitem__icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
))}
|
||||
{!iconOnly && <span className="sui-navitem__label">{label}</span>}
|
||||
{!iconOnly && trailing && (
|
||||
<span className="sui-navitem__trailing">{trailing}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.sui-nav-surface {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-nav);
|
||||
/* Clips full-bleed nav rows to the rounded corners. */
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { NavSurface } from "@app/ui/NavSurface";
|
||||
import { NavItem } from "@app/ui/NavItem";
|
||||
|
||||
function Dot() {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
background: "var(--c-primary)",
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof NavSurface> = {
|
||||
title: "Primitives/NavSurface",
|
||||
component: NavSurface,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ width: "15rem", background: "var(--c-bg)", padding: 12 }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NavSurface>;
|
||||
|
||||
/** The box a sidebar's rows sit in. NavItem's own states are covered by its
|
||||
* own stories - the active row's accent-on-tint contrast is a known
|
||||
* NavItem issue, and duplicating it here would just baseline it twice. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<div style={{ padding: "0.5rem 0" }}>
|
||||
<NavItem id="home" label="Home" icon={<Dot />} />
|
||||
<NavItem id="sources" label="Sources" icon={<Dot />} />
|
||||
<NavItem id="documents" label="Documents" icon={<Dot />} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** `as` renders a landmark element instead of a div. */
|
||||
export const AsSection: Story = {
|
||||
args: {
|
||||
as: "section",
|
||||
"aria-label": "Processor",
|
||||
children: (
|
||||
<div style={{ padding: "0.75rem" }}>
|
||||
<span style={{ fontSize: 13, color: "var(--c-text-muted)" }}>
|
||||
Any content, not just nav rows.
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { forwardRef, type HTMLAttributes } from "react";
|
||||
import "@app/ui/NavSurface.css";
|
||||
|
||||
export interface NavSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** Element to render; `section`/`aside` when the box is a landmark. */
|
||||
as?: "div" | "section" | "aside";
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating box a sidebar's contents sit in: nav sections, the editor's
|
||||
* file rail, the account footer. Surface fill, hairline border, nav radius.
|
||||
*/
|
||||
export const NavSurface = forwardRef<HTMLDivElement, NavSurfaceProps>(
|
||||
function NavSurface(
|
||||
{ as: Component = "div", className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={["sui-nav-surface", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -50,9 +50,7 @@ export const WithActions: Story = {
|
||||
accent: "purple",
|
||||
actions: (
|
||||
<>
|
||||
<StatusBadge tone="success" pulse>
|
||||
Healthy
|
||||
</StatusBadge>
|
||||
<StatusBadge tone="success">Healthy</StatusBadge>
|
||||
<Button size="sm" variant="secondary">
|
||||
Edit composition
|
||||
</Button>
|
||||
|
||||
@@ -2,35 +2,21 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
border: 1px solid transparent;
|
||||
line-height: 1;
|
||||
color: var(--sui-status-c, var(--c-text-subtle));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--sui-status-c, var(--c-text-subtle)) 12%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--sui-status-c, var(--c-text-subtle)) 28%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.sui-status--sm {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
.sui-status--md {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1875rem 0.625rem;
|
||||
}
|
||||
.sui-status--lg {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.3125rem 0.75rem;
|
||||
}
|
||||
|
||||
.sui-status__dot {
|
||||
@@ -38,25 +24,40 @@
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
position: relative;
|
||||
}
|
||||
.sui-status__dot--pulse::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -0.125rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
animation: pulseRing 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Neutral keeps the plain muted surface rather than an accent tint. */
|
||||
.sui-status--pill {
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--sui-status-c, var(--c-text-subtle)) 28%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--sui-status-c, var(--c-text-subtle)) 12%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.sui-status--pill.sui-status--sm {
|
||||
padding: 0.125rem 0.5rem;
|
||||
}
|
||||
.sui-status--pill.sui-status--md {
|
||||
padding: 0.1875rem 0.625rem;
|
||||
}
|
||||
.sui-status--pill.sui-status--lg {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
}
|
||||
|
||||
.sui-status--neutral {
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.sui-status--pill.sui-status--neutral {
|
||||
background: var(--c-surface-sunken);
|
||||
border-color: var(--c-border-subtle);
|
||||
}
|
||||
/* Tones only pick the accent; the base rule builds the fill + border. `-dark`
|
||||
is theme-adaptive, so text stays legible on the pale fill in both themes. */
|
||||
|
||||
.sui-status--success {
|
||||
--sui-status-c: var(--color-green-dark);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export const AllTones: Story = {
|
||||
};
|
||||
|
||||
export const Live: Story = {
|
||||
args: { tone: "success", pulse: true, children: "Live" },
|
||||
args: { tone: "success", children: "Live" },
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
|
||||
@@ -14,28 +14,21 @@ export type StatusSize = "sm" | "md" | "lg";
|
||||
export interface StatusBadgeProps {
|
||||
tone?: StatusTone;
|
||||
size?: StatusSize;
|
||||
/** Show a leading coloured dot. */
|
||||
showDot?: boolean;
|
||||
/** Render the dot with a pulse animation (active / live indicator). */
|
||||
pulse?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline status pill used across surfaces — pipeline rows, document status,
|
||||
* deployments, audit logs. Tone maps to semantic meaning, not raw colour.
|
||||
*/
|
||||
export function StatusBadge({
|
||||
tone = "neutral",
|
||||
size = "md",
|
||||
showDot = true,
|
||||
pulse = false,
|
||||
children,
|
||||
className,
|
||||
}: StatusBadgeProps) {
|
||||
const cls = [
|
||||
"sui-status",
|
||||
showDot ? "" : "sui-status--pill",
|
||||
`sui-status--${tone}`,
|
||||
`sui-status--${size}`,
|
||||
className ?? "",
|
||||
@@ -44,12 +37,7 @@ export function StatusBadge({
|
||||
.join(" ");
|
||||
return (
|
||||
<span className={cls}>
|
||||
{showDot && (
|
||||
<span
|
||||
className={`sui-status__dot${pulse ? " sui-status__dot--pulse" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{showDot && <span className="sui-status__dot" aria-hidden />}
|
||||
<span className="sui-status__label">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,26 @@
|
||||
* accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */
|
||||
|
||||
.sui-acc-default {
|
||||
--_solid: var(--c-primary);
|
||||
--_solid-hover: var(--c-primary-hover);
|
||||
--_on: #ffffff;
|
||||
--_solid: var(--c-btn-solid);
|
||||
--_solid-hover: color-mix(
|
||||
in srgb,
|
||||
var(--c-btn-solid) 85%,
|
||||
var(--c-btn-inverse)
|
||||
);
|
||||
--_on: var(--c-btn-inverse);
|
||||
--_text: var(--c-primary-hover);
|
||||
--_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface));
|
||||
--_tint: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
html[data-app-theme="custom"] .sui-acc-default {
|
||||
--_on: var(--c-text-on-primary);
|
||||
--_solid-2: var(--c-btn-secondary);
|
||||
--_solid-2-hover: color-mix(
|
||||
in srgb,
|
||||
var(--c-btn-secondary) 92%,
|
||||
var(--c-btn-solid)
|
||||
);
|
||||
--_on-2: var(--c-btn-solid);
|
||||
--_bd-2: var(--c-btn-secondary-border);
|
||||
--_tert-text: var(--c-text);
|
||||
--_tert-tint: var(--c-hover);
|
||||
}
|
||||
/* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the
|
||||
fill and the outline/text are the SAME red in both light and dark. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "@app/ui/Button";
|
||||
export * from "@app/ui/ActionIcon";
|
||||
export * from "@app/ui/Logo";
|
||||
export * from "@app/ui/FilePicker";
|
||||
export * from "@app/ui/SegmentedControl";
|
||||
export * from "@app/ui/StatusBadge";
|
||||
@@ -8,6 +9,7 @@ export * from "@app/ui/ToggleSwitch";
|
||||
export * from "@app/ui/ProgressBar";
|
||||
export * from "@app/ui/MetricCard";
|
||||
export * from "@app/ui/NavItem";
|
||||
export * from "@app/ui/NavSurface";
|
||||
export * from "@app/ui/PanelHeader";
|
||||
export * from "@app/ui/CodeBlock";
|
||||
export * from "@app/ui/SectionDivider";
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
|
||||
|
||||
/**
|
||||
* Desktop inherits proprietary's layers but does not ship the portal (see
|
||||
* desktop/routes/adminRouteExtensions), so shadow the switcher back to empty —
|
||||
* otherwise the desktop bundle would reference @portal via the proprietary
|
||||
* switcher's imports.
|
||||
* desktop/routes/adminRouteExtensions), so there's nothing to switch to —
|
||||
* shadow the brand header back to a plain logo. (Also avoids the desktop
|
||||
* bundle referencing @portal via the proprietary switcher's imports.)
|
||||
*/
|
||||
export function AppSwitcher() {
|
||||
return null;
|
||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
||||
return (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
}
|
||||
|
||||
.portal-shell__topbar-wordmark {
|
||||
height: 1.375rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ActionIcon } from "@app/ui";
|
||||
import { Sidebar } from "@portal/components/Sidebar";
|
||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { MenuIcon, SearchIcon } from "@portal/components/icons";
|
||||
import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg";
|
||||
import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
import "@portal/components/AppShell.css";
|
||||
|
||||
/**
|
||||
@@ -17,7 +15,6 @@ import "@portal/components/AppShell.css";
|
||||
*/
|
||||
function MobileTopbar() {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const { mobileNavOpen, toggleMobileNav, openSearch } = useUI();
|
||||
return (
|
||||
<header className="portal-shell__topbar">
|
||||
@@ -30,10 +27,11 @@ function MobileTopbar() {
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</ActionIcon>
|
||||
<img
|
||||
<Logo
|
||||
variant="iconAndText"
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
className="portal-shell__topbar-wordmark"
|
||||
src={theme === "dark" ? wordmarkDark : wordmarkLight}
|
||||
alt={t("portal.shell.sidebar.brandSuffix")}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
|
||||
@@ -75,6 +75,10 @@ export function PortalSettingsHost() {
|
||||
urlSync={false}
|
||||
initialSection={initialSection}
|
||||
extraSections={extraSections}
|
||||
// TODO: opening Keyboard Shortcuts in the portal white-screens
|
||||
// the app (the section expects editor-only context). Hidden here
|
||||
// as a stopgap; fix the section properly and drop this.
|
||||
hiddenSectionKeys={["hotkeys"]}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</PreferencesProvider>
|
||||
|
||||
@@ -2,13 +2,32 @@
|
||||
width: 15rem;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* track mobile browser chrome */
|
||||
background: var(--c-bg-raised);
|
||||
border-right: 1px solid var(--c-border);
|
||||
background: var(--c-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Slide between the full rail and the collapsed icon rail. Overridden by the
|
||||
drawer's transform transition under the mobile breakpoint below. */
|
||||
transition: width var(--motion-spring);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.portal-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Nav labels stay on one line and are clipped by the narrowing rail so they
|
||||
reveal/hide cleanly as the width animates rather than wrapping. */
|
||||
.portal-sidebar__nav,
|
||||
.portal-sidebar__footer {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.portal-sidebar .sui-navitem__label,
|
||||
.portal-sidebar__section-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mobile close button (shared ActionIcon) — only shown inside the drawer. */
|
||||
@@ -17,6 +36,11 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-sidebar__collapse {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Off-canvas drawer under the shell breakpoint (keep in sync with
|
||||
AppShell.css and Sidebar.tsx). Slides from the inline-start edge so RTL
|
||||
locales get the mirrored behavior for free. */
|
||||
@@ -46,58 +70,68 @@
|
||||
.portal-sidebar__close {
|
||||
display: inline-flex;
|
||||
}
|
||||
/* Collapse is a desktop affordance; the drawer is full-width on mobile. */
|
||||
.portal-sidebar__collapse {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Collapsed icon rail (desktop only) ---- */
|
||||
.portal-sidebar[data-collapsed] {
|
||||
width: var(--nav-rail-w);
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__logo {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__collapse {
|
||||
margin-left: 0;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__nav {
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__section {
|
||||
padding-inline: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__section-label {
|
||||
display: none;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .sui-navitem__label,
|
||||
.portal-sidebar[data-collapsed] .sui-navitem__trailing {
|
||||
display: none;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__navtip {
|
||||
display: flex;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .sui-navitem {
|
||||
justify-content: center;
|
||||
margin-inline: 0;
|
||||
padding-inline: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .sui-navitem.is-active {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
|
||||
margin-inline: 0.375rem;
|
||||
padding-inline: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Logo block */
|
||||
.portal-sidebar__logo {
|
||||
height: 3.1875rem; /* 51px */
|
||||
padding: 0 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Brand mark (parallelogram icon) leading the wordmark; same in both themes. */
|
||||
.portal-sidebar__mark {
|
||||
height: 1.5rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Stirling wordmark (theme-switched in Sidebar.tsx); matches the editor's
|
||||
22px wordmark so the two apps read as one brand. */
|
||||
.portal-sidebar__wordmark {
|
||||
height: 1.375rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Show the wordmark that matches the rendered scheme (black text on light,
|
||||
white text on dark). Keyed on data-mantine-color-scheme so it follows the
|
||||
actual theme, not the portal's separate (and sometimes stale) theme state. */
|
||||
.portal-sidebar__wordmark--dark {
|
||||
display: none;
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--light {
|
||||
display: none;
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--dark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* App switcher (down-arrow → Portal / Editor); button and menu styling live
|
||||
with the shared AppSwitch element. */
|
||||
.portal-sidebar__app-switch {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Nav body */
|
||||
.portal-sidebar__nav {
|
||||
flex: 1 1 auto;
|
||||
flex: 0 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0.625rem;
|
||||
display: flex;
|
||||
@@ -105,12 +139,17 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Each section is a labelled card: a small header above its nav items. */
|
||||
.portal-sidebar .sui-navitem {
|
||||
padding-inline: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-sidebar .sui-navitem.is-active {
|
||||
border-left: 3px solid var(--c-primary);
|
||||
padding-left: calc(0.875rem - 3px);
|
||||
}
|
||||
|
||||
.portal-sidebar__section {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: 0.625rem;
|
||||
padding: 0.5rem 0.375rem 0.375rem;
|
||||
padding: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
@@ -118,8 +157,8 @@
|
||||
|
||||
.portal-sidebar__section-label {
|
||||
margin: 0;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--c-text-subtle);
|
||||
@@ -128,14 +167,11 @@
|
||||
.portal-sidebar__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.portal-sidebar__footer {
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
padding: 0.5rem 0.625rem 0.75rem;
|
||||
margin: 0 0.625rem 0.75rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { ActionIcon, NavItem } from "@app/ui";
|
||||
import { AppSwitch } from "@app/components/shared/AppSwitch";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
||||
import mark from "@app/assets/brand/modern-logo/StirlingProcessorLogoNoText.svg";
|
||||
import wordmarkLight from "@app/assets/brand/modern-logo/StirlingLogoBlackText.svg";
|
||||
import wordmarkDark from "@app/assets/brand/modern-logo/StirlingLogoWhiteText.svg";
|
||||
import { CloseIcon, SettingsIcon } from "@portal/components/icons";
|
||||
import {
|
||||
GROUP_PROCESSOR,
|
||||
@@ -30,14 +28,23 @@ const MOBILE_QUERY = "(max-width: 48rem)";
|
||||
|
||||
export function Sidebar() {
|
||||
const { activeView, setActiveView } = useView();
|
||||
const { theme } = useTheme();
|
||||
const { openSettings, mobileNavOpen, closeMobileNav } = useUI();
|
||||
const {
|
||||
openSettings,
|
||||
mobileNavOpen,
|
||||
closeMobileNav,
|
||||
sidebarCollapsed,
|
||||
toggleSidebarCollapsed,
|
||||
} = useUI();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
|
||||
// Collapse is a desktop-only affordance: on mobile the sidebar is an
|
||||
// off-canvas drawer, so the icon-rail state never applies there.
|
||||
const collapsed = sidebarCollapsed && !isMobile;
|
||||
|
||||
// Editor and portal are one SPA when the editor serves this origin's root, so
|
||||
// the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup)
|
||||
// needs a full page load.
|
||||
@@ -50,25 +57,35 @@ export function Sidebar() {
|
||||
// a takeover modal (matching the marketing prototype).
|
||||
|
||||
function renderGroup(entries: NavEntry[]) {
|
||||
return entries.map((entry) => (
|
||||
<NavItem
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
label={t(`portal.nav.${entry.id}`)}
|
||||
icon={entry.icon}
|
||||
isActive={activeView === entry.id}
|
||||
onClick={(id) => {
|
||||
// Route changes also close the drawer (AppShell), but re-selecting the
|
||||
// active view or opening an external tab changes no route — close here.
|
||||
closeMobileNav();
|
||||
if (entry.externalUrl) {
|
||||
window.open(entry.externalUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
setActiveView(id as ViewId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
));
|
||||
return entries.map((entry) => {
|
||||
const label = t(`portal.nav.${entry.id}`);
|
||||
const item = (
|
||||
<NavItem
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
label={label}
|
||||
icon={entry.icon}
|
||||
isActive={activeView === entry.id}
|
||||
onClick={(id) => {
|
||||
// Route changes also close the drawer (AppShell), but re-selecting the
|
||||
// active view or opening an external tab changes no route — close here.
|
||||
closeMobileNav();
|
||||
if (entry.externalUrl) {
|
||||
window.open(entry.externalUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
setActiveView(id as ViewId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return collapsed ? (
|
||||
<Tooltip key={entry.id} label={label} position="right" withinPortal>
|
||||
<div className="portal-sidebar__navtip">{item}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
item
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -76,37 +93,30 @@ export function Sidebar() {
|
||||
className={
|
||||
mobileNavOpen ? "portal-sidebar portal-sidebar--open" : "portal-sidebar"
|
||||
}
|
||||
data-collapsed={collapsed || undefined}
|
||||
aria-label={t("portal.shell.sidebar.primaryNav")}
|
||||
// Off-canvas on mobile: remove from the tab order and accessibility tree.
|
||||
inert={isMobile && !mobileNavOpen}
|
||||
>
|
||||
<div className="portal-sidebar__logo">
|
||||
<img
|
||||
className="portal-sidebar__mark"
|
||||
src={mark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Both wordmarks render; CSS shows the right one per the actual color
|
||||
scheme (data-mantine-color-scheme), so it tracks the rendered theme
|
||||
rather than the portal's separate theme state. */}
|
||||
<img
|
||||
className="portal-sidebar__wordmark portal-sidebar__wordmark--light"
|
||||
src={wordmarkLight}
|
||||
alt="Stirling"
|
||||
/>
|
||||
<img
|
||||
className="portal-sidebar__wordmark portal-sidebar__wordmark--dark"
|
||||
src={wordmarkDark}
|
||||
alt="Stirling"
|
||||
<BrandSwitcher
|
||||
current="processor"
|
||||
onSwitch={goToEditor}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
|
||||
<AppSwitch
|
||||
className="portal-sidebar__app-switch"
|
||||
current="processor"
|
||||
theme={theme}
|
||||
onSwitch={goToEditor}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
className="portal-sidebar__collapse"
|
||||
aria-label={
|
||||
collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar")
|
||||
}
|
||||
onClick={toggleSidebarCollapsed}
|
||||
>
|
||||
<SidebarToggleIcon size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
@@ -120,18 +130,22 @@ export function Sidebar() {
|
||||
|
||||
<nav className="portal-sidebar__nav">
|
||||
{NAV_SECTIONS.map((section) => (
|
||||
<section key={section.labelKey} className="portal-sidebar__section">
|
||||
<NavSurface
|
||||
key={section.labelKey}
|
||||
as="section"
|
||||
className="portal-sidebar__section"
|
||||
>
|
||||
<h2 className="portal-sidebar__section-label">
|
||||
{t(section.labelKey)}
|
||||
</h2>
|
||||
<div className="portal-sidebar__group">
|
||||
{renderGroup(section.entries)}
|
||||
</div>
|
||||
</section>
|
||||
</NavSurface>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="portal-sidebar__footer">
|
||||
<NavSurface className="portal-sidebar__footer">
|
||||
<LinkAccountFooterItem />
|
||||
<NavItem
|
||||
id="settings"
|
||||
@@ -139,7 +153,7 @@ export function Sidebar() {
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
</div>
|
||||
</NavSurface>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function LinkedInstancesTable({
|
||||
{t("portal.accountLink.instances.revoked", "Revoked")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success" size="sm" pulse>
|
||||
<StatusBadge tone="success" size="sm">
|
||||
{t("portal.accountLink.instances.active", "Active")}
|
||||
</StatusBadge>
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MetricCard, MetricStrip, Skeleton } from "@app/ui";
|
||||
import { EditorIcon } from "@portal/components/icons";
|
||||
import type { DeploymentSummary } from "@portal/api/editorDeploy";
|
||||
|
||||
interface Props {
|
||||
@@ -10,16 +11,16 @@ interface Props {
|
||||
export function DeploymentSummaryStrip({ summary, loading }: Props) {
|
||||
if (loading || !summary) {
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricStrip layout="row" leading={<EditorIcon size={22} />}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height="5.5rem" />
|
||||
<Skeleton key={i} width="5rem" height="2rem" />
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricStrip layout="row" leading={<EditorIcon size={22} />}>
|
||||
{summary.metrics.map((m) => (
|
||||
<MetricCard
|
||||
key={m.label}
|
||||
|
||||
@@ -70,11 +70,7 @@ export function DeploymentTargets({ targets, onUpgrade }: Props) {
|
||||
{target.tagline}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={STATE_BADGE_TONE[target.state]}
|
||||
size="sm"
|
||||
pulse={target.state === "running"}
|
||||
>
|
||||
<StatusBadge tone={STATE_BADGE_TONE[target.state]} size="sm">
|
||||
{t(`portal.editorAdmin.targets.state.${target.state}`)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
@@ -69,11 +69,7 @@ export function InstanceHealthTable({ instances }: Props) {
|
||||
key: "status",
|
||||
header: t("portal.editorAdmin.health.columns.status"),
|
||||
render: (i) => (
|
||||
<StatusBadge
|
||||
tone={INSTANCE_STATUS_TONE[i.status]}
|
||||
size="sm"
|
||||
pulse={i.status === "healthy"}
|
||||
>
|
||||
<StatusBadge tone={INSTANCE_STATUS_TONE[i.status]} size="sm">
|
||||
{t(INSTANCE_STATUS_LABEL[i.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Card,
|
||||
EmptyState,
|
||||
MetricCard,
|
||||
MetricStrip,
|
||||
StatusBadge,
|
||||
Table,
|
||||
Tabs,
|
||||
@@ -140,7 +141,7 @@ export function AuditTab() {
|
||||
/>
|
||||
|
||||
{data && (
|
||||
<section className="portal-infra__metrics">
|
||||
<MetricStrip layout="row">
|
||||
<MetricCard
|
||||
label={t("portal.infrastructure.audit.metrics.totalEvents")}
|
||||
value={data.summary.totalEvents.toLocaleString()}
|
||||
@@ -157,7 +158,7 @@ export function AuditTab() {
|
||||
label={t("portal.infrastructure.audit.metrics.config")}
|
||||
value={data.summary.config.toLocaleString()}
|
||||
/>
|
||||
</section>
|
||||
</MetricStrip>
|
||||
)}
|
||||
|
||||
{!forbidden && (
|
||||
|
||||
@@ -74,11 +74,7 @@ export function DeploymentsTab() {
|
||||
key: "status",
|
||||
header: t("portal.infrastructure.deployments.regionColumns.status"),
|
||||
render: (r) => (
|
||||
<StatusBadge
|
||||
tone={REGION_TONE[r.status]}
|
||||
size="sm"
|
||||
pulse={r.status === "healthy"}
|
||||
>
|
||||
<StatusBadge tone={REGION_TONE[r.status]} size="sm">
|
||||
{t(REGION_LABEL[r.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Chip,
|
||||
EmptyState,
|
||||
MetricCard,
|
||||
MetricStrip,
|
||||
ProgressBar,
|
||||
Select,
|
||||
StatusBadge,
|
||||
@@ -65,11 +66,7 @@ export function ModelsTab() {
|
||||
key: "status",
|
||||
header: t("portal.infrastructure.models.columns.status"),
|
||||
render: (m) => (
|
||||
<StatusBadge
|
||||
tone={MODEL_TONE[m.status]}
|
||||
size="sm"
|
||||
pulse={m.status === "active"}
|
||||
>
|
||||
<StatusBadge tone={MODEL_TONE[m.status]} size="sm">
|
||||
{t(MODEL_LABEL[m.status])}
|
||||
</StatusBadge>
|
||||
),
|
||||
@@ -174,7 +171,7 @@ export function ModelsTab() {
|
||||
/>
|
||||
|
||||
{data && (
|
||||
<section className="portal-infra__metrics">
|
||||
<MetricStrip layout="row">
|
||||
<MetricCard
|
||||
label={t("portal.infrastructure.models.metrics.activeModels")}
|
||||
value={data.summary.activeModels}
|
||||
@@ -193,7 +190,7 @@ export function ModelsTab() {
|
||||
: t("portal.infrastructure.models.metrics.included")
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</MetricStrip>
|
||||
)}
|
||||
|
||||
<section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@app/ui";
|
||||
import { PipelinesIcon } from "@portal/components/icons";
|
||||
import type { PipelinesOverviewResponse } from "@portal/api/pipelines";
|
||||
|
||||
/**
|
||||
@@ -22,7 +23,7 @@ interface KpiStripProps {
|
||||
export function KpiStrip({ data, loading }: KpiStripProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricStrip layout="row" leading={<PipelinesIcon size={22} />}>
|
||||
{KPI_LABEL_KEYS.map((labelKey, i) => {
|
||||
const k = loading ? undefined : data?.kpis[i];
|
||||
return (
|
||||
|
||||
@@ -49,11 +49,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
|
||||
key: "status",
|
||||
header: t("portal.pipelines.table.status"),
|
||||
render: (p) => (
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[p.status]}
|
||||
size="sm"
|
||||
pulse={p.status === "active"}
|
||||
>
|
||||
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
|
||||
{t(`portal.pipelines.status.${p.status}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@app/ui";
|
||||
import { PoliciesIcon } from "@portal/components/icons";
|
||||
import type { PoliciesResponse } from "@portal/api/policies";
|
||||
|
||||
interface CatalogueSummaryProps {
|
||||
@@ -16,7 +17,7 @@ export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = loading ? undefined : data?.summary;
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricStrip layout="row" leading={<PoliciesIcon size={22} />}>
|
||||
<MetricCard
|
||||
label={t("portal.policies.summary.active.label")}
|
||||
value={s ? s.active : "—"}
|
||||
|
||||
@@ -100,11 +100,7 @@ export function PolicyCatalogueTable({
|
||||
if (entry.policy) {
|
||||
const paused = entry.policy.state.status === "paused";
|
||||
return (
|
||||
<StatusBadge
|
||||
tone={paused ? "warning" : "success"}
|
||||
size="sm"
|
||||
pulse={!paused}
|
||||
>
|
||||
<StatusBadge tone={paused ? "warning" : "success"} size="sm">
|
||||
{paused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active")}
|
||||
|
||||
@@ -87,7 +87,6 @@ export function PolicyCategoryCard({
|
||||
<StatusBadge
|
||||
tone={status === "paused" ? "warning" : "success"}
|
||||
size="sm"
|
||||
pulse={status !== "paused"}
|
||||
>
|
||||
{status === "paused"
|
||||
? t("portal.policies.status.paused")
|
||||
|
||||
@@ -196,10 +196,7 @@ export function PolicyDetailPanel({
|
||||
>
|
||||
{/* Status + trigger strip */}
|
||||
<div className="portal-policies__detail-status">
|
||||
<StatusBadge
|
||||
tone={isPaused ? "warning" : "success"}
|
||||
pulse={!isPaused}
|
||||
>
|
||||
<StatusBadge tone={isPaused ? "warning" : "success"}>
|
||||
{isPaused
|
||||
? t("portal.policies.status.paused")
|
||||
: t("portal.policies.status.active")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@app/ui";
|
||||
import { SourcesIcon } from "@portal/components/icons";
|
||||
import type { SourcesResponse } from "@portal/api/sources";
|
||||
|
||||
/**
|
||||
@@ -22,7 +23,7 @@ interface KpiStripProps {
|
||||
export function KpiStrip({ data, loading }: KpiStripProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MetricStrip>
|
||||
<MetricStrip layout="row" leading={<SourcesIcon size={22} />}>
|
||||
{KPI_LABEL_KEYS.map((labelKey, i) => {
|
||||
const k = loading ? undefined : data?.kpis[i];
|
||||
return (
|
||||
|
||||
@@ -61,11 +61,7 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
|
||||
key: "status",
|
||||
header: t("portal.sources.table.status"),
|
||||
render: (s) => (
|
||||
<StatusBadge
|
||||
tone={STATUS_TONE[s.status]}
|
||||
size="sm"
|
||||
pulse={s.status === "active"}
|
||||
>
|
||||
<StatusBadge tone={STATUS_TONE[s.status]} size="sm">
|
||||
{t(`portal.sources.status.${s.status}`)}
|
||||
</StatusBadge>
|
||||
),
|
||||
|
||||
@@ -17,6 +17,8 @@ interface UIContextValue {
|
||||
openMobileNav: () => void;
|
||||
closeMobileNav: () => void;
|
||||
toggleMobileNav: () => void;
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebarCollapsed: () => void;
|
||||
|
||||
assistantOpen: boolean;
|
||||
openAssistant: () => void;
|
||||
@@ -52,9 +54,29 @@ interface UIContextValue {
|
||||
|
||||
const UIContext = createContext<UIContextValue | null>(null);
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "stirling.portalSidebarCollapsed";
|
||||
|
||||
function readSidebarCollapsed(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSidebarCollapsed(collapsed: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed));
|
||||
} catch {
|
||||
// private mode / quota: silently no-op
|
||||
}
|
||||
}
|
||||
|
||||
export function UIProvider({ children }: { children: ReactNode }) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] =
|
||||
useState(readSidebarCollapsed);
|
||||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<
|
||||
@@ -85,6 +107,14 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
closeMobileNav: () => setMobileNavOpen(false),
|
||||
toggleMobileNav: () => setMobileNavOpen((o) => !o),
|
||||
|
||||
sidebarCollapsed,
|
||||
toggleSidebarCollapsed: () =>
|
||||
setSidebarCollapsed((c) => {
|
||||
const next = !c;
|
||||
writeSidebarCollapsed(next);
|
||||
return next;
|
||||
}),
|
||||
|
||||
assistantOpen,
|
||||
openAssistant: () => setAssistantOpen(true),
|
||||
closeAssistant: () => setAssistantOpen(false),
|
||||
@@ -129,6 +159,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
[
|
||||
searchOpen,
|
||||
mobileNavOpen,
|
||||
sidebarCollapsed,
|
||||
assistantOpen,
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user