Restructure frontend code to allow for extensions (#4721)

# Description of Changes
Move frontend code into `core` folder and add infrastructure for
`proprietary` folder to include premium, non-OSS features
This commit is contained in:
James Brunton
2025-10-28 10:29:36 +00:00
committed by GitHub
parent 960d48f80c
commit d2b38ef4b8
725 changed files with 2485 additions and 2226 deletions
@@ -0,0 +1,112 @@
import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
interface AdminAnalyticsChoiceModalProps {
opened: boolean;
onClose: () => void;
}
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
const { t } = useTranslation();
const { refetch } = useAppConfig();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
// Refetch config to apply new settings without page reload
await refetch();
// Close the modal after successful save
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
setLoading(false);
}
};
const handleEnable = () => {
handleChoice(true);
};
const handleDisable = () => {
handleChoice(false);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
size="lg"
centered
zIndex={Z_ANALYTICS_MODAL}
>
<Stack gap="md">
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
</Text>
<Text size="sm" c="dimmed">
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
<Anchor
href="https://docs.stirlingpdf.com/analytics-telemetry"
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t('analytics.learnMore', 'Learn more')}
</Anchor>
</Text>
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Stack gap="sm">
<Button
onClick={handleEnable}
loading={loading}
fullWidth
size="md"
>
{t('analytics.enable', 'Enable analytics')}
</Button>
<Button
onClick={handleDisable}
loading={loading}
fullWidth
size="md"
variant="subtle"
c="gray"
>
{t('analytics.disable', 'Disable analytics')}
</Button>
</Stack>
<Text size="xs" c="dimmed" ta="center">
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
</Text>
</Stack>
</Modal>
);
}
@@ -0,0 +1,73 @@
import React from 'react';
import { ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
interface AllToolsNavButtonProps {
activeButton: string;
setActiveButton: (id: string) => void;
}
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation();
const handleClick = () => {
setActiveButton('tools');
// Preserve existing behavior used in QuickAccessBar header
handleReaderToggle();
handleBackToTools();
};
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const navProps = getHomeNavigation();
const handleNavClick = (e: React.MouseEvent) => {
handleUnlessSpecialClick(e, handleClick);
};
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: '2rem' }} />
</span>
);
return (
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
onClick={handleNavClick}
size={'lg'}
variant="subtle"
aria-label={t("quickAccess.allTools", "All Tools")}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
border: 'none',
borderRadius: '8px',
textDecoration: 'none'
}}
className={isActive ? 'activeIconScale' : ''}
>
{iconNode}
</ActionIcon>
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
{t("quickAccess.allTools", "All Tools")}
</span>
</div>
</Tooltip>
);
};
export default AllToolsNavButton;
@@ -0,0 +1,130 @@
/* AppConfigModal styles */
.modal-container {
display: flex;
gap: 0;
height: 37.5rem; /* 600px */
}
.modal-nav {
width: 15rem; /* 240px */
height: 37.5rem; /* 600px */
border-top-left-radius: 0.75rem; /* 12px */
border-bottom-left-radius: 0.75rem; /* 12px */
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Mobile: compact icon-only navigation */
@media (max-width: 1024px) {
.modal-container {
height: 100vh !important;
max-height: none !important;
}
.modal-nav {
width: 5rem; /* 80px - wider for larger icons */
height: 100vh !important;
max-height: none !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.modal-nav-scroll {
padding: 1rem 0.5rem;
}
.modal-nav-section {
margin-bottom: 1.5rem;
}
.modal-nav-item.mobile {
padding: 1rem;
justify-content: center;
border-radius: 0.75rem;
margin-bottom: 0.75rem;
}
.modal-content {
height: 100vh !important;
max-height: none !important;
border-radius: 0;
}
}
.modal-nav-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: 2rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-nav-scroll::-webkit-scrollbar {
display: none;
}
.modal-nav-section {
margin-bottom: 1rem;
}
.modal-nav-section-items {
margin-top: 0.5rem;
}
.modal-nav-item {
cursor: pointer;
padding: 0.5rem 0.625rem; /* 8px 10px */
border-radius: 0.5rem; /* 8px */
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem; /* 4px */
}
.modal-content {
flex: 1;
height: 37.5rem; /* 600px */
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-content-scroll {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-content-scroll::-webkit-scrollbar {
display: none;
}
.modal-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
}
.modal-body {
padding: 2rem;
padding-top: 1rem;
}
.confirm-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.confirm-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
@@ -0,0 +1,158 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import LocalIcon from '@app/components/shared/LocalIcon';
import Overview from '@app/components/shared/config/configSections/Overview';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
import { NavKey } from '@app/components/shared/config/types';
import '@app/components/shared/AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface AppConfigModalProps {
opened: boolean;
onClose: () => void;
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('overview');
const isMobile = useMediaQuery("(max-width: 1024px)");
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
}, []);
const colors = useMemo(() => ({
navBg: 'var(--modal-nav-bg)',
sectionTitle: 'var(--modal-nav-section-title)',
navItem: 'var(--modal-nav-item)',
navItemActive: 'var(--modal-nav-item-active)',
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
contentBg: 'var(--modal-content-bg)',
headerBorder: 'var(--modal-header-border)',
}), []);
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
const handleLogout = () => {
// In SaaS this would sign out, in open-source it does nothing
console.log('Logout placeholder for SaaS compatibility');
};
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
),
[]
);
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.label;
}
return '';
}, [configNavSections, active]);
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.component;
}
return null;
}, [configNavSections, active]);
return (
<Modal
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map(item => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
{/* Right content */}
<div className="modal-content">
<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}`,
}}
>
<Text fw={700} size="lg">{activeLabel}</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
</div>
</div>
</div>
</div>
</Modal>
);
};
export default AppConfigModal;
@@ -0,0 +1,101 @@
import React from 'react';
import { Box } from '@mantine/core';
interface BadgeProps {
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'colored';
color?: string;
textColor?: string;
backgroundColor?: string;
className?: string;
style?: React.CSSProperties;
}
const Badge: React.FC<BadgeProps> = ({
children,
size = 'sm',
variant = 'default',
color,
textColor,
backgroundColor,
className,
style
}) => {
const getSizeStyles = () => {
switch (size) {
case 'sm':
return {
padding: '0.125rem 0.5rem',
fontSize: '0.75rem',
fontWeight: 700,
borderRadius: '0.5rem',
};
case 'md':
return {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
fontWeight: 700,
borderRadius: '0.625rem',
};
case 'lg':
return {
padding: '0.375rem 1rem',
fontSize: '1rem',
fontWeight: 700,
borderRadius: '0.75rem',
};
default:
return {};
}
};
const getVariantStyles = () => {
// If explicit colors are provided, use them
if (textColor && backgroundColor) {
return {
backgroundColor,
color: textColor,
};
}
// If a single color is provided, use it for text and 20% opacity for background
if (color) {
return {
backgroundColor: `color-mix(in srgb, ${color} 20%, transparent)`,
color: color,
};
}
// If variant is colored but no color provided, use default colored styling
if (variant === 'colored') {
return {
backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`,
color: 'var(--category-color-default)',
borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`,
border: '1px solid',
};
}
// Default styling
return {
background: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
};
};
return (
<Box
className={className}
style={{
...getSizeStyles(),
...getVariantStyles(),
...style,
}}
>
{children}
</Box>
);
};
export default Badge;
@@ -0,0 +1,216 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import ButtonSelector from '@app/components/shared/ButtonSelector';
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('ButtonSelector', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all options as buttons', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Test Label"
/>
</TestWrapper>
);
expect(screen.getByText('Test Label')).toBeInTheDocument();
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
});
test('should highlight selected button with filled variant', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Selection Label"
/>
</TestWrapper>
);
const selectedButton = screen.getByRole('button', { name: 'Option 1' });
const unselectedButton = screen.getByRole('button', { name: 'Option 2' });
// Check data-variant attribute for filled/outline
expect(selectedButton).toHaveAttribute('data-variant', 'filled');
expect(unselectedButton).toHaveAttribute('data-variant', 'outline');
expect(screen.getByText('Selection Label')).toBeInTheDocument();
});
test('should call onChange when button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).toHaveBeenCalledWith('option2');
});
test('should handle undefined value (no selection)', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value={undefined}
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
// Both buttons should be outlined when no value is selected
const button1 = screen.getByRole('button', { name: 'Option 1' });
const button2 = screen.getByRole('button', { name: 'Option 2' });
expect(button1).toHaveAttribute('data-variant', 'outline');
expect(button2).toHaveAttribute('data-variant', 'outline');
});
test.each([
{
description: 'disable buttons when disabled prop is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
globalDisabled: true,
expectedStates: [true, true],
},
{
description: 'disable individual options when option.disabled is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
],
globalDisabled: false,
expectedStates: [false, true],
},
])('should $description', ({ options, globalDisabled, expectedStates }) => {
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
disabled={globalDisabled}
/>
</TestWrapper>
);
options.forEach((option, index) => {
const button = screen.getByRole('button', { name: option.label });
expect(button).toHaveProperty('disabled', expectedStates[index]);
});
});
test('should not call onChange when disabled button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).not.toHaveBeenCalled();
});
test('should not apply fullWidth styling when fullWidth is false', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
fullWidth={false}
label="Layout Label"
/>
</TestWrapper>
);
const button = screen.getByRole('button', { name: 'Option 1' });
expect(button).not.toHaveStyle({ flex: '1' });
expect(screen.getByText('Layout Label')).toBeInTheDocument();
});
test('should not render label element when not provided', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
// Should render buttons
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
// Stack should only contain the Group (buttons), no Text element for label
const stackElement = container.querySelector('[class*="mantine-Stack-root"]');
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
});
});
@@ -0,0 +1,74 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import FitText from "@app/components/shared/FitText";
export interface ButtonOption<T> {
value: T;
label: string;
disabled?: boolean;
}
interface ButtonSelectorProps<T> {
value: T | undefined;
onChange: (value: T) => void;
options: ButtonOption<T>[];
label?: string;
disabled?: boolean;
fullWidth?: boolean;
buttonClassName?: string;
textClassName?: string;
}
const ButtonSelector = <T extends string | number>({
value,
onChange,
options,
label = undefined,
disabled = false,
fullWidth = true,
buttonClassName,
textClassName,
}: ButtonSelectorProps<T>) => {
return (
<Stack gap='var(--mantine-spacing-sm)'>
{/* Label (if it exists) */}
{label && <Text style={{
fontSize: "var(--mantine-font-size-sm)",
lineHeight: "var(--mantine-line-height-sm)",
fontWeight: "var(--font-weight-medium)",
}}>{label}</Text>}
{/* Buttons */}
<Group gap='4px'>
{options.map((option) => (
<Button
key={option.value}
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
className={buttonClassName}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
minHeight: '2.5rem',
fontSize: 'var(--mantine-font-size-sm)',
lineHeight: '1.4',
paddingTop: '0.5rem',
paddingBottom: '0.5rem'
}}
>
<FitText
text={option.label}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={textClassName}
/>
</Button>
))}
</Group>
</Stack>
);
};
export default ButtonSelector;
@@ -0,0 +1,99 @@
import { Stack, Card, Text, Flex } from '@mantine/core';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useTranslation } from 'react-i18next';
export interface CardOption<T = string> {
value: T;
prefixKey: string;
nameKey: string;
tooltipKey?: string;
tooltipContent?: any[];
}
export interface CardSelectorProps<T, K extends CardOption<T>> {
options: K[];
onSelect: (value: T) => void;
disabled?: boolean;
getTooltipContent?: (option: K) => any[];
}
const CardSelector = <T, K extends CardOption<T>>({
options,
onSelect,
disabled = false,
getTooltipContent
}: CardSelectorProps<T, K>) => {
const { t } = useTranslation();
const handleOptionClick = (value: T) => {
if (!disabled) {
onSelect(value);
}
};
const getTooltips = (option: K) => {
if (getTooltipContent) {
return getTooltipContent(option);
}
return [];
};
return (
<Stack gap="sm">
{options.map((option) => (
<Tooltip
key={option.value as string}
sidebarTooltip
tips={getTooltips(option)}
>
<Card
radius="md"
w="100%"
h={'2.8rem'}
style={{
cursor: disabled ? 'default' : 'pointer',
backgroundColor: 'var(--mantine-color-gray-2)',
borderColor: 'var(--mantine-color-gray-3)',
opacity: disabled ? 0.6 : 1,
display: 'flex',
flexDirection: 'row',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-3)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)';
}
}}
onMouseLeave={(e) => {
if (!disabled) {
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-2)';
e.currentTarget.style.transform = 'translateY(0px)';
e.currentTarget.style.boxShadow = 'none';
}
}}
onClick={() => handleOptionClick(option.value)}
>
<Flex align={'center'} pl="sm" w="100%">
<Text size="sm" c="dimmed" ta="center" fw={350}>
{t(option.prefixKey, "Prefix")}
</Text>
<Text
fw={600}
size="sm"
c={undefined}
ta="center"
style={{ marginLeft: '0.25rem' }}
>
{t(option.nameKey, "Option Name")}
</Text>
</Flex>
</Card>
</Tooltip>
))}
</Stack>
);
};
export default CardSelector;
@@ -0,0 +1,51 @@
import React from 'react';
import { Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useFileState } from '@app/contexts/FileContext';
import { useFileActions } from '@app/contexts/file/fileHooks';
import CloseIcon from '@mui/icons-material/Close';
interface DismissAllErrorsButtonProps {
className?: string;
}
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
const { t } = useTranslation();
const { state } = useFileState();
const { actions } = useFileActions();
// Check if there are any files in error state
const hasErrors = state.ui.errorFileIds.length > 0;
// Don't render if there are no errors
if (!hasErrors) {
return null;
}
const handleDismissAllErrors = () => {
actions.clearAllFileErrors();
};
return (
<Group className={className}>
<Button
variant="light"
color="red"
size="sm"
leftSection={<CloseIcon fontSize="small" />}
onClick={handleDismissAllErrors}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
pointerEvents: 'auto'
}}
>
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
</Button>
</Group>
);
};
export default DismissAllErrorsButton;
@@ -0,0 +1,237 @@
import React, { ReactNode, useState, useMemo } from 'react';
import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import SearchIcon from '@mui/icons-material/Search';
export interface DropdownItem {
value: string;
name: string;
leftIcon?: ReactNode;
disabled?: boolean;
}
export interface DropdownListWithFooterProps {
// Value and onChange - support both single and multi-select
value: string | string[];
onChange: (value: string | string[]) => void;
// Items and display
items: DropdownItem[];
placeholder?: string;
disabled?: boolean;
// Labels and headers
label?: string;
header?: ReactNode;
footer?: ReactNode;
// Behavior
multiSelect?: boolean;
searchable?: boolean;
maxHeight?: number;
// Styling
className?: string;
dropdownClassName?: string;
// Popover props
position?: 'top' | 'bottom' | 'left' | 'right';
withArrow?: boolean;
width?: 'target' | number;
}
const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
value,
onChange,
items,
placeholder = 'Select option',
disabled = false,
label,
header,
footer,
multiSelect = false,
searchable = false,
maxHeight = 300,
className = '',
dropdownClassName = '',
position = 'bottom',
withArrow = false,
width = 'target'
}) => {
const [searchTerm, setSearchTerm] = useState('');
const isMultiValue = Array.isArray(value);
const selectedValues = isMultiValue ? value : (value ? [value] : []);
// Filter items based on search term
const filteredItems = useMemo(() => {
if (!searchable || !searchTerm.trim()) {
return items;
}
return items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [items, searchTerm, searchable]);
const handleItemClick = (itemValue: string) => {
if (multiSelect) {
const newSelection = selectedValues.includes(itemValue)
? selectedValues.filter(v => v !== itemValue)
: [...selectedValues, itemValue];
onChange(newSelection);
} else {
onChange(itemValue);
}
};
const getDisplayText = () => {
if (selectedValues.length === 0) {
return placeholder;
} else if (selectedValues.length === 1) {
const selectedItem = items.find(item => item.value === selectedValues[0]);
return selectedItem?.name || selectedValues[0];
} else {
return `${selectedValues.length} selected`;
}
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.currentTarget.value);
};
return (
<Box className={className}>
{label && (
<Text size="sm" fw={500} mb={4}>
{label}
</Text>
)}
<Popover
width={width}
position={position}
withArrow={withArrow}
shadow="md"
onClose={() => searchable && setSearchTerm('')}
>
<Popover.Target>
<Box
style={{
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
borderRadius: 'var(--mantine-radius-sm)',
padding: '8px 12px',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
opacity: disabled ? 0.6 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<Text size="sm" style={{ flex: 1 }}>
{getDisplayText()}
</Text>
<UnfoldMoreIcon style={{
fontSize: '1rem',
color: 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2))'
}} />
</Box>
</Popover.Target>
<Popover.Dropdown className={dropdownClassName}>
<Stack gap="xs">
{header && (
<Box style={{
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingBottom: '8px'
}}>
{header}
</Box>
)}
{searchable && (
<Box style={{
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingBottom: '8px'
}}>
<TextInput
placeholder="Search..."
value={searchTerm}
onChange={handleSearchChange}
leftSection={<SearchIcon style={{ fontSize: '1rem' }} />}
size="sm"
style={{ width: '100%' }}
/>
</Box>
)}
<Box style={{ maxHeight, overflowY: 'auto' }}>
{filteredItems.length === 0 ? (
<Box style={{ padding: '12px', textAlign: 'center' }}>
<Text size="sm" c="dimmed">
{searchable && searchTerm ? 'No results found' : 'No items available'}
</Text>
</Box>
) : (
filteredItems.map((item) => (
<Box
key={item.value}
onClick={() => !item.disabled && handleItemClick(item.value)}
style={{
padding: '8px 12px',
cursor: item.disabled ? 'not-allowed' : 'pointer',
borderRadius: 'var(--mantine-radius-sm)',
opacity: item.disabled ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
onMouseEnter={(e) => {
if (!item.disabled) {
e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<Group gap="sm" style={{ flex: 1 }}>
{item.leftIcon && (
<Box style={{ display: 'flex', alignItems: 'center' }}>
{item.leftIcon}
</Box>
)}
<Text size="sm">{item.name}</Text>
</Group>
{multiSelect && (
<Checkbox
checked={selectedValues.includes(item.value)}
onChange={() => {}} // Handled by parent onClick
size="sm"
disabled={item.disabled}
/>
)}
</Box>
))
)}
</Box>
{footer && (
<Box style={{
borderTop: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingTop: '8px'
}}>
{footer}
</Box>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Box>
);
};
export default DropdownListWithFooter;
@@ -0,0 +1,56 @@
import React from 'react';
import { Text, Button, Stack } from '@mantine/core';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
retry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
const Fallback = this.props.fallback;
return <Fallback error={this.state.error} retry={this.retry} />;
}
return (
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem' }}>
<Text size="lg" fw={500} c="red">Something went wrong</Text>
{process.env.NODE_ENV === 'development' && this.state.error && (
<Text size="sm" c="dimmed" style={{ textAlign: 'center', fontFamily: 'monospace' }}>
{this.state.error.message}
</Text>
)}
<Button onClick={this.retry} variant="light">
Try Again
</Button>
</Stack>
);
}
return this.props.children;
}
}
@@ -0,0 +1,214 @@
import { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { StirlingFileStub } from "@app/types/fileContext";
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: File;
fileStub?: StirlingFileStub;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
onEdit?: () => void;
isSelected?: boolean;
onSelect?: () => void;
isSupported?: boolean; // Whether the file format is supported by the current tool
}
const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub);
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick && isSupported ? "pointer" : undefined,
position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined,
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
data-testid="file-card"
data-tour="file-card-checkbox"
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
position: 'relative'
}}
>
{/* Hover action buttons */}
{isHovered && (onView || onEdit) && (
<div
style={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 4,
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 4,
padding: 2
}}
onClick={(e) => e.stopPropagation()}
>
{onView && (
<Tooltip label="View in Viewer">
<ActionIcon
size="sm"
variant="subtle"
color="blue"
onClick={(e) => {
e.stopPropagation();
onView();
}}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
{onEdit && (
<Tooltip label="Open in File Editor">
<ActionIcon
size="sm"
variant="subtle"
color="orange"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<EditIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
</div>
)}
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
)}
</div>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="red" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{fileStub?.id && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
DB
</Badge>
)}
{!isSupported && (
<Badge color="orange" variant="filled" size="sm">
{t("fileManager.unsupported", "Unsupported")}
</Badge>
)}
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
};
export default FileCard;
@@ -0,0 +1,78 @@
import React from 'react';
import { Menu, Loader, Group, Text } from '@mantine/core';
import VisibilityIcon from '@mui/icons-material/Visibility';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import FitText from '@app/components/shared/FitText';
interface FileDropdownMenuProps {
displayName: string;
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex: number;
onFileSelect?: (index: number) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
pillRef?: React.RefObject<HTMLDivElement>;
}
export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
displayName,
activeFiles,
currentFileIndex,
onFileSelect,
switchingTo,
viewOptionStyle,
}) => {
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} className="ph-no-capture" />
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
<Menu.Dropdown style={{
backgroundColor: 'var(--right-rail-bg)',
border: '1px solid var(--border-subtle)',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
maxHeight: '50vh',
overflowY: 'auto'
}}>
{activeFiles.map((file, index) => {
const itemName = file?.name || 'Untitled';
const isActive = index === currentFileIndex;
return (
<Menu.Item
key={file.fileId}
onClick={(e) => {
e.stopPropagation();
onFileSelect?.(index);
}}
className="viewer-file-tab"
{...(isActive && { 'data-active': true })}
style={{
justifyContent: 'flex-start',
}}
>
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} className="ph-no-capture" />
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed">
v{file.versionNumber}
</Text>
)}
</Group>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
};
@@ -0,0 +1,182 @@
import { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "@app/components/shared/FileCard";
import { StirlingFileStub } from "@app/types/fileContext";
import { FileId } from "@app/types/file";
interface FileGridProps {
files: Array<{ file: File; record?: StirlingFileStub }>;
onRemove?: (index: number) => void;
onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
onView?: (item: { file: File; record?: StirlingFileStub }) => void;
onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
onSelect?: (fileId: FileId) => void;
selectedFiles?: FileId[];
showSearch?: boolean;
showSort?: boolean;
maxDisplay?: number; // If set, shows only this many files with "Show All" option
onShowAll?: () => void;
showingAll?: boolean;
onDeleteAll?: () => void;
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
}
type SortOption = 'date' | 'name' | 'size';
const FileGrid = ({
files,
onRemove,
onDoubleClick,
onView,
onEdit,
onSelect,
selectedFiles = [],
showSearch = false,
showSort = false,
maxDisplay,
onShowAll,
showingAll = false,
onDeleteAll,
isFileSupported
}: FileGridProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<SortOption>('date');
// Filter files based on search term
const filteredFiles = files.filter(item =>
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Sort files
const sortedFiles = [...filteredFiles].sort((a, b) => {
switch (sortBy) {
case 'date':
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
case 'name':
return a.file.name.localeCompare(b.file.name);
case 'size':
return (b.file.size || 0) - (a.file.size || 0);
default:
return 0;
}
});
// Apply max display limit if specified
const displayFiles = maxDisplay && !showingAll
? sortedFiles.slice(0, maxDisplay)
: sortedFiles;
const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay;
return (
<Box >
{/* Search and Sort Controls */}
{(showSearch || showSort || onDeleteAll) && (
<Group mb="md" justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm">
{showSearch && (
<TextInput
placeholder={t("fileManager.searchFiles", "Search files...")}
leftSection={<SearchIcon fontSize="small" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
/>
)}
{showSort && (
<Select
data={[
{ value: 'date', label: t("fileManager.sortByDate", "Sort by Date") },
{ value: 'name', label: t("fileManager.sortByName", "Sort by Name") },
{ value: 'size', label: t("fileManager.sortBySize", "Sort by Size") }
]}
value={sortBy}
onChange={(value) => setSortBy(value as SortOption)}
leftSection={<SortIcon fontSize="small" />}
style={{ minWidth: 150 }}
/>
)}
</Group>
{onDeleteAll && (
<Button
color="red"
size="sm"
onClick={onDeleteAll}
>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)}
</Group>
)}
{/* File Grid */}
<Flex
direction="row"
wrap="wrap"
gap="md"
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
>
{displayFiles
.filter(item => {
if (!item.record?.id) {
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
return false;
}
return true;
})
.map((item, idx) => {
const fileId = item.record!.id; // Safe to assert after filter
const originalIdx = files.findIndex(f => f.record?.id === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
<FileCard
key={fileId + idx}
file={item.file}
fileStub={item.record}
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
onView={onView && supported ? () => onView(item) : undefined}
onEdit={onEdit && supported ? () => onEdit(item) : undefined}
isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
isSupported={supported}
/>
);
})}
</Flex>
{/* Show All Button */}
{hasMoreFiles && onShowAll && (
<Group justify="center" mt="md">
<Button
variant="light"
onClick={onShowAll}
>
{t("fileManager.showAll", "Show All")} ({sortedFiles.length} files)
</Button>
</Group>
)}
{/* Empty State */}
{displayFiles.length === 0 && (
<Box style={{ textAlign: 'center', padding: '2rem' }}>
<Text c="dimmed">
{searchTerm
? t("fileManager.noFilesFound", "No files found matching your search")
: t("fileManager.noFiles", "No files available")
}
</Text>
</Box>
)}
</Box>
);
};
export default FileGrid;
@@ -0,0 +1,266 @@
import { useState, useEffect } from 'react';
import {
Modal,
Text,
Button,
Group,
Stack,
Checkbox,
ScrollArea,
Box,
Image,
Badge,
ThemeIcon,
SimpleGrid
} from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { useTranslation } from 'react-i18next';
import { FileId } from '@app/types/file';
interface FilePickerModalProps {
opened: boolean;
onClose: () => void;
storedFiles: any[]; // Files from storage (various formats supported)
onSelectFiles: (selectedFiles: File[]) => void;
}
const FilePickerModal = ({
opened,
onClose,
storedFiles,
onSelectFiles,
}: FilePickerModalProps) => {
const { t } = useTranslation();
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
// Reset selection when modal opens
useEffect(() => {
if (opened) {
setSelectedFileIds([]);
}
}, [opened]);
const toggleFileSelection = (fileId: FileId) => {
setSelectedFileIds(prev => {
return prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId];
});
};
const selectAll = () => {
setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
};
const selectNone = () => {
setSelectedFileIds([]);
};
const handleConfirm = async () => {
const selectedFiles = storedFiles.filter(f =>
selectedFileIds.includes(f.id)
);
// Convert stored files to File objects
const convertedFiles = await Promise.all(
selectedFiles.map(async (fileItem) => {
try {
// If it's already a File object, return as is
if (fileItem instanceof File) {
return fileItem;
}
// If it has a file property, use that
if (fileItem.file && fileItem.file instanceof File) {
return fileItem.file;
}
// If it's from IndexedDB storage, reconstruct the File
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
const arrayBuffer = await fileItem.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
return new File([blob], fileItem.name, {
type: fileItem.type || 'application/pdf',
lastModified: fileItem.lastModified || Date.now()
});
}
// If it has data property, reconstruct the File
if (fileItem.data) {
const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' });
return new File([blob], fileItem.name, {
type: fileItem.type || 'application/pdf',
lastModified: fileItem.lastModified || Date.now()
});
}
console.warn('Could not convert file item:', fileItem);
return null;
} catch (error) {
console.error('Error converting file:', error, fileItem);
return null;
}
})
);
// Filter out any null values and return valid Files
const validFiles = convertedFiles.filter((f): f is File => f !== null);
onSelectFiles(validFiles);
onClose();
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
zIndex={1100}
>
<Stack gap="md">
{storedFiles.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")}
</Text>
) : (
<>
{/* Selection controls */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{storedFiles.length} {t("fileUpload.filesAvailable", "files available")}
{selectedFileIds.length > 0 && (
<> {selectedFileIds.length} selected</>
)}
</Text>
<Group gap="xs">
<Button size="xs" variant="light" onClick={selectAll}>
{t("pageEdit.selectAll", "Select All")}
</Button>
<Button size="xs" variant="light" onClick={selectNone}>
{t("pageEdit.deselectAll", "Select None")}
</Button>
</Group>
</Group>
{/* File grid */}
<ScrollArea.Autosize mah={400}>
<SimpleGrid cols={2} spacing="md">
{storedFiles.map((file) => {
const fileId = file.id;
const isSelected = selectedFileIds.includes(fileId);
return (
<Box
key={fileId}
p="sm"
style={{
border: isSelected
? '2px solid var(--mantine-color-blue-6)'
: '1px solid var(--mantine-color-gray-3)',
borderRadius: 8,
backgroundColor: isSelected
? 'var(--mantine-color-blue-0)'
: 'transparent',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onClick={() => toggleFileSelection(fileId)}
>
<Group gap="sm" align="flex-start">
<Checkbox
checked={isSelected}
onChange={() => toggleFileSelection(fileId)}
onClick={(e) => e.stopPropagation()}
/>
{/* Thumbnail */}
<Box
style={{
width: 60,
height: 80,
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-0)',
flexShrink: 0
}}
>
{file.thumbnail ? (
<Image
src={file.thumbnail}
alt="PDF thumbnail"
height={70}
width={50}
fit="contain"
/>
) : (
<ThemeIcon
variant="light"
color="red"
size={40}
>
<PictureAsPdfIcon style={{ fontSize: 24 }} />
</ThemeIcon>
)}
</Box>
{/* File info */}
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={2}>
{file.name}
</Text>
<Group gap="xs">
<Badge size="xs" variant="light" color="gray">
{formatFileSize(file.size || (file.file?.size || 0))}
</Badge>
</Group>
</Stack>
</Group>
</Box>
);
})}
</SimpleGrid>
</ScrollArea.Autosize>
{/* Selection summary */}
{selectedFileIds.length > 0 && (
<Text size="sm" c="blue" ta="center">
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
</Text>
)}
</>
)}
{/* Action buttons */}
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={onClose}>
{t("close", "Cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={selectedFileIds.length === 0}
>
{selectedFileIds.length > 0
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
: t("fileUpload.loadFromStorage", "Load Files")
}
</Button>
</Group>
</Stack>
</Modal>
);
};
export default FilePickerModal;
@@ -0,0 +1,111 @@
import React from 'react';
import { Box, Center } from '@mantine/core';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { StirlingFileStub } from '@app/types/fileContext';
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
import DocumentStack from '@app/components/shared/filePreview/DocumentStack';
import HoverOverlay from '@app/components/shared/filePreview/HoverOverlay';
import NavigationArrows from '@app/components/shared/filePreview/NavigationArrows';
export interface FilePreviewProps {
// Core file data
file: File | StirlingFileStub | null;
thumbnail?: string | null;
// Optional features
showStacking?: boolean;
showHoverOverlay?: boolean;
showNavigation?: boolean;
// State
totalFiles?: number;
isAnimating?: boolean;
// Event handlers
onFileClick?: (file: File | StirlingFileStub | null) => void;
onPrevious?: () => void;
onNext?: () => void;
}
const FilePreview: React.FC<FilePreviewProps> = ({
file,
thumbnail,
showStacking = false,
showHoverOverlay = false,
showNavigation = false,
totalFiles = 1,
isAnimating = false,
onFileClick,
onPrevious,
onNext
}) => {
if (!file) {
return (
<Box style={{ width: '100%', height: '100%' }}>
<Center style={{ width: '100%', height: '100%' }}>
<InsertDriveFileIcon
style={{
fontSize: '4rem',
color: 'var(--mantine-color-gray-4)',
opacity: 0.6
}}
/>
</Center>
</Box>
);
}
const hasMultipleFiles = totalFiles > 1;
// Animation styles
const animationStyle = isAnimating ? {
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'scale(0.95) translateX(1.25rem)',
opacity: 0.7
} : {};
// Build the component composition
let content = (
<DocumentThumbnail
file={file}
thumbnail={thumbnail}
style={animationStyle}
onClick={() => onFileClick?.(file)}
/>
);
// Wrap with hover overlay if needed
if (showHoverOverlay && onFileClick) {
content = <HoverOverlay>{content}</HoverOverlay>;
}
// Wrap with document stack if needed
if (showStacking) {
content = (
<DocumentStack totalFiles={totalFiles}>
{content}
</DocumentStack>
);
}
// Wrap with navigation if needed
if (showNavigation && hasMultipleFiles && onPrevious && onNext) {
content = (
<NavigationArrows
onPrevious={onPrevious}
onNext={onNext}
disabled={isAnimating}
>
{content}
</NavigationArrows>
);
}
return (
<Box style={{ width: '100%', height: '100%' }}>
{content}
</Box>
);
};
export default FilePreview;
@@ -0,0 +1,46 @@
import { useRef } from "react";
import { FileButton, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface FileUploadButtonProps {
file?: File;
onChange: (file: File | null) => void;
accept?: string;
disabled?: boolean;
placeholder?: string;
variant?: "outline" | "filled" | "light" | "default" | "subtle" | "gradient";
fullWidth?: boolean;
}
const FileUploadButton = ({
file,
onChange,
accept,
disabled = false,
placeholder,
variant = "outline",
fullWidth = true
}: FileUploadButtonProps) => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const defaultPlaceholder = t('chooseFile', 'Choose File');
return (
<FileButton
resetRef={resetRef}
onChange={onChange}
accept={accept}
disabled={disabled}
>
{(props) => (
<Button {...props} variant={variant} fullWidth={fullWidth} color="blue">
{file ? file.name : (placeholder || defaultPlaceholder)}
</Button>
)}
</FileButton>
);
};
export default FileUploadButton;
@@ -0,0 +1,79 @@
import React, { CSSProperties, useMemo, useRef } from 'react';
import { useAdjustFontSizeToFit } from '@app/components/shared/fitText/textFit';
type FitTextProps = {
text: string;
fontSize?: number; // px; if omitted, uses computed style
minimumFontScale?: number; // 0..1
lines?: number; // max lines
className?: string;
style?: CSSProperties;
as?: 'span' | 'div';
/**
* Insert zero-width soft breaks after these characters to prefer wrapping at them
* when multi-line is enabled. Defaults to '/'. Ignored when lines === 1.
*/
softBreakChars?: string | string[];
};
const FitText: React.FC<FitTextProps> = ({
text,
fontSize,
minimumFontScale = 0.8,
lines = 1,
className,
style,
as = 'span',
softBreakChars = ['-','_','/'],
}) => {
const ref = useRef<HTMLElement | null>(null);
// Hook runs after mount and on size/text changes; uses observers internally
useAdjustFontSizeToFit(ref as any, {
maxFontSizePx: fontSize,
minFontScale: minimumFontScale,
maxLines: lines,
singleLine: lines === 1,
});
// Memoize the HTML tag to render (span/div) from the `as` prop so
// React doesn't create a new component function on each render.
const ElementTag: any = useMemo(() => as, [as]);
// For the / character, insert zero-width soft breaks to prefer wrapping at them
const displayText = useMemo(() => {
if (!text) return text;
if (!lines || lines <= 1) return text;
const chars = Array.isArray(softBreakChars) ? softBreakChars : [softBreakChars];
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${chars.filter(Boolean).map(esc).join('|')})`, 'g');
return text.replace(re, `$1\u200B`);
}, [text, lines, softBreakChars]);
const clampStyles: CSSProperties = {
// Multi-line clamp with ellipsis fallback
whiteSpace: lines === 1 ? 'nowrap' : 'normal',
overflow: 'visible',
textOverflow: 'ellipsis',
display: lines > 1 ? ('-webkit-box' as any) : undefined,
WebkitBoxOrient: lines > 1 ? ('vertical' as any) : undefined,
WebkitLineClamp: lines > 1 ? (lines as any) : undefined,
lineClamp: lines > 1 ? (lines as any) : undefined,
// Favor shrinking over breaking words; only break at natural spaces or softBreakChars
wordBreak: lines > 1 ? ('keep-all' as any) : ('normal' as any),
overflowWrap: 'normal',
hyphens: 'manual',
// fontSize expects rem values (e.g., 1.2, 0.9) to scale with global font size
fontSize: fontSize ? `${fontSize}rem` : undefined,
};
return (
<ElementTag ref={ref} className={className} style={{ ...clampStyles, ...style }}>
{displayText}
</ElementTag>
);
};
export default FitText;
@@ -0,0 +1,88 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
interface FooterProps {
privacyPolicy?: string;
termsAndConditions?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
analyticsEnabled?: boolean;
}
export default function Footer({
privacyPolicy = 'https://www.stirling.com/legal/privacy-policy',
termsAndConditions = 'https://www.stirling.com/legal/terms-of-service',
accessibilityStatement = 'accessibility',
analyticsEnabled = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
return (
<div style={{
height: 'var(--footer-height)',
backgroundColor: 'var(--mantine-color-gray-1)',
borderTop: '1px solid var(--mantine-color-gray-2)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}>
<Flex gap="md"
justify="center"
align="center"
direction="row"
style={{ fontSize: '0.75rem' }}>
<a
className="footer-link px-3"
id="survey"
target="_blank"
rel="noopener noreferrer"
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
>
{t('survey.nav', 'Survey')}
</a>
{privacyPolicy && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={privacyPolicy}
>
{t('legal.privacy', 'Privacy Policy')}
</a>
)}
{termsAndConditions && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={termsAndConditions}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
)}
{accessibilityStatement && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={accessibilityStatement}
>
{t('legal.accessibility', 'Accessibility')}
</a>
)}
{analyticsEnabled && (
<button
className="footer-link px-3"
id="cookieBanner"
onClick={showCookiePreferences}
>
{t('legal.showCookieBanner', 'Cookie Preferences')}
</button>
)}
</Flex>
</div>
);
}
@@ -0,0 +1,28 @@
/* Base Hover Menu */
.hoverMenu {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
align-items: center;
background: var(--bg-toolbar);
border: 1px solid var(--border-default);
padding: 6px 12px;
border-radius: 20px;
box-shadow: var(--shadow-md);
z-index: 30;
white-space: nowrap;
pointer-events: auto;
transition: opacity 0.2s ease-in-out;
}
/* Inside positioning (Page Editor style) - within container */
.inside {
bottom: 8px;
}
/* Outside positioning (File Editor style) - below container */
.outside {
bottom: -8px;
}
@@ -0,0 +1,60 @@
import React from 'react';
import { ActionIcon, Tooltip } from '@mantine/core';
import styles from '@app/components/shared/HoverActionMenu.module.css';
export interface HoverAction {
id: string;
icon: React.ReactNode;
label: string;
onClick: (e: React.MouseEvent) => void;
disabled?: boolean;
color?: string;
hidden?: boolean;
}
interface HoverActionMenuProps {
show: boolean;
actions: HoverAction[];
position?: 'inside' | 'outside';
className?: string;
}
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
show,
actions,
position = 'inside',
className = ''
}) => {
const visibleActions = actions.filter(action => !action.hidden);
if (visibleActions.length === 0) {
return null;
}
return (
<div
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
style={{ opacity: show ? 1 : 0 }}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{visibleActions.map((action) => (
<Tooltip key={action.id} label={action.label}>
<ActionIcon
size="md"
variant="subtle"
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
disabled={action.disabled}
onClick={action.onClick}
c={action.color}
>
{action.icon}
</ActionIcon>
</Tooltip>
))}
</div>
);
};
export default HoverActionMenu;
@@ -0,0 +1,199 @@
import React from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { BASE_PATH } from '@app/constants/app';
const LandingPage = () => {
const { addFiles } = useFileHandler();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { colorScheme } = useMantineColorScheme();
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
};
const handleOpenFilesModal = () => {
openFilesModal();
};
const handleNativeUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
await addFiles(files);
}
// Reset the input so the same file can be selected again
event.target.value = '';
};
return (
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95%]"
style={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
bottom: 0,
borderRadius: '0.25rem 0.25rem 0 0',
filter: 'var(--drop-shadow-filter)',
backgroundColor: 'var(--landing-paper-bg)',
transition: 'background-color 0.4s ease',
}}
activateOnClick={false}
styles={{
root: {
'&[dataAccept]': {
backgroundColor: 'var(--landing-drop-paper-bg)',
},
},
}}
>
<div
style={{
position: 'absolute',
top: 0,
right: 0,
zIndex: 10,
}}
>
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
alt="Stirling PDF Logo"
style={{
height: 'auto',
pointerEvents: 'none',
}}
/>
</div>
<div
className={`min-h-[45vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[30rem] max-w-[calc(100%-2rem)] border transition-all duration-200 dropzone-inner relative`}
style={{
borderRadius: '0.5rem',
backgroundColor: 'var(--landing-inner-paper-bg)',
borderColor: 'var(--landing-inner-paper-border)',
borderWidth: '1px',
borderStyle: 'solid',
}}
>
{/* Logo positioned absolutely in top right corner */}
{/* Centered content container */}
<div className="flex flex-col items-center gap-4 flex-none w-full">
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Add Files + Native Upload Buttons */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '80%',
marginTop: '0.8rem',
marginBottom: '0.8rem'
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Hidden file input for native file picker */}
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem' }}
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
</div>
</Dropzone>
</Container>
);
};
export default LandingPage;
@@ -0,0 +1,88 @@
/* Language selector grid responsive layout */
.languageGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
}
.languageItem {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(4n) {
border-right: none;
}
/* Responsive breakpoints */
@media (max-width: 600px) {
.languageGrid {
grid-template-columns: repeat(2, 1fr);
}
.languageItem:nth-child(4n) {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(2n) {
border-right: none;
}
}
@media (min-width: 601px) and (max-width: 900px) {
.languageGrid {
grid-template-columns: repeat(3, 1fr);
}
.languageItem:nth-child(4n) {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(3n) {
border-right: none;
}
}
/* Dark theme support */
[data-mantine-color-scheme="dark"] .languageItem {
border-right-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) {
border-right: none;
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) {
border-right-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) {
border-right-color: var(--mantine-color-dark-3);
}
/* Responsive text visibility */
.languageText {
display: none;
}
@media (min-width: 768px) {
.languageText {
display: inline;
}
}
/* Ripple animation */
@keyframes ripple {
0% {
width: 0;
height: 0;
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
width: 100px;
height: 100px;
opacity: 0;
}
}
@@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '@app/i18n';
import LocalIcon from '@app/components/shared/LocalIcon';
import styles from '@app/components/shared/LanguageSelector.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
// Types
interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position'];
offset?: number;
compact?: boolean; // icon-only trigger
}
interface LanguageOption {
value: string;
label: string;
}
interface RippleEffect {
x: number;
y: number;
key: number;
}
// Sub-components
interface LanguageItemProps {
option: LanguageOption;
index: number;
animationTriggered: boolean;
isSelected: boolean;
onClick: (event: React.MouseEvent) => void;
rippleEffect?: RippleEffect | null;
pendingLanguage: string | null;
compact: boolean;
disabled?: boolean;
}
const LanguageItem: React.FC<LanguageItemProps> = ({
option,
index,
animationTriggered,
isSelected,
onClick,
rippleEffect,
pendingLanguage,
compact,
disabled = false
}) => {
const { t } = useTranslation();
const label = disabled ? (
<Tooltip label={t('comingSoon', 'Coming soon')} position="left" withArrow>
<p>{option.label}</p>
</Tooltip>
) : (
<p>{option.label}</p>
);
return (
<div
className={styles.languageItem}
style={{
opacity: animationTriggered ? 1 : 0,
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
}}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={disabled ? undefined : onClick}
data-selected={isSelected}
disabled={disabled}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: disabled
? 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))'
: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
cursor: disabled ? 'not-allowed' : 'pointer',
'&:hover': !disabled ? {
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
} : {}
},
label: {
fontSize: '13px',
fontWeight: isSelected ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
}
}}
>
{label}
{!compact && rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{
position: 'absolute',
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
)}
</Button>
</div>
);
};
const RippleStyles: React.FC = () => (
<style>
{`
@keyframes ripple-expand {
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
);
// Main component
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: name,
}));
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
// Create ripple effect at click position (only for button mode)
if (!compact) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
setRippleEffect({ x, y, key: Date.now() });
}
// Start transition animation
setPendingLanguage(value);
// Simulate processing time for smooth transition
setTimeout(() => {
i18n.changeLanguage(value);
setTimeout(() => {
setPendingLanguage(null);
setOpened(false);
// Clear ripple effect
setTimeout(() => setRippleEffect(null), 100);
}, 300);
}, 200);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'];
// Trigger animation when dropdown opens
useEffect(() => {
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 50);
}
}, [opened]);
return (
<>
<RippleStyles />
<Menu
opened={opened}
onChange={setOpened}
width={600}
position={position}
offset={offset}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
transitionProps={{
transition: 'scale-y',
duration: 200,
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
>
<Menu.Target>
{compact ? (
<ActionIcon
variant="subtle"
radius="md"
title={currentLanguage}
className="right-rail-icon"
styles={{
root: {
color: 'var(--right-rail-icon)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
}
}}
>
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
</ActionIcon>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
styles={{
root: {
border: 'none',
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
},
label: { fontSize: '12px', fontWeight: 500 }
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
)}
</Menu.Target>
<Menu.Dropdown
style={{
padding: '12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
// Enable languages with >90% translation completion
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
const isDisabled = !enabledLanguages.includes(option.value);
return (
<LanguageItem
key={option.value}
option={option}
index={index}
animationTriggered={animationTriggered}
isSelected={option.value === i18n.language}
onClick={(event) => handleLanguageChange(option.value, event)}
rippleEffect={rippleEffect}
pendingLanguage={pendingLanguage}
compact={compact}
disabled={isDisabled}
/>
);
})}
</div>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</>
);
};
export default LanguageSelector;
export type { LanguageSelectorProps, LanguageOption, RippleEffect };
@@ -0,0 +1,19 @@
/**
* Loading fallback component for i18next suspense
*/
export function LoadingFallback() {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontSize: "18px",
color: "#666",
}}
>
Loading...
</div>
);
}
@@ -0,0 +1,52 @@
import React from 'react';
import { addCollection, Icon } from '@iconify/react';
import iconSet from '../../../assets/material-symbols-icons.json';
// Load icons synchronously at import time - guaranteed to be ready on first render
let iconsLoaded = false;
let localIconCount = 0;
try {
if (iconSet) {
addCollection(iconSet);
iconsLoaded = true;
localIconCount = Object.keys(iconSet.icons || {}).length;
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
}
} catch {
console.info('️ Local icons not available - using CDN fallback');
}
interface LocalIconProps {
icon: string;
width?: string | number;
height?: string | number;
style?: React.CSSProperties;
className?: string;
}
/**
* LocalIcon component that uses our locally bundled Material Symbols icons
* instead of loading from CDN
*/
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
// Convert our icon naming convention to the local collection format
const iconName = icon.startsWith('material-symbols:')
? icon
: `material-symbols:${icon}`;
// Development logging (only in dev mode)
if (process.env.NODE_ENV === 'development') {
const logKey = `icon-${iconName}`;
if (!sessionStorage.getItem(logKey)) {
const source = iconsLoaded ? 'local' : 'CDN';
console.debug(`🎯 Icon: ${iconName} (${source})`);
sessionStorage.setItem(logKey, 'logged');
}
}
// Always render the icon - Iconify will use local if available, CDN if not
return <Icon icon={iconName} {...props} />;
};
export default LocalIcon;
@@ -0,0 +1,87 @@
import { Box, Group, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface MultiSelectControlsProps {
selectedCount: number;
onClearSelection: () => void;
onOpenInFileEditor?: () => void;
onOpenInPageEditor?: () => void;
onAddToUpload?: () => void;
onDeleteAll?: () => void;
}
const MultiSelectControls = ({
selectedCount,
onClearSelection,
onOpenInFileEditor,
onOpenInPageEditor,
onAddToUpload,
onDeleteAll
}: MultiSelectControlsProps) => {
const { t } = useTranslation();
if (selectedCount === 0) return null;
return (
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm">
{selectedCount} {t("fileManager.filesSelected", "files selected")}
</Text>
<Group>
<Button
size="xs"
variant="light"
onClick={onClearSelection}
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
{onAddToUpload && (
<Button
size="xs"
color="green"
onClick={onAddToUpload}
>
{t("fileManager.addToUpload", "Add to Upload")}
</Button>
)}
{onOpenInFileEditor && (
<Button
size="xs"
color="orange"
onClick={onOpenInFileEditor}
disabled={selectedCount === 0}
>
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
)}
{onOpenInPageEditor && (
<Button
size="xs"
color="blue"
onClick={onOpenInPageEditor}
disabled={selectedCount === 0}
>
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
)}
{onDeleteAll && (
<Button
size="xs"
color="red"
onClick={onDeleteAll}
>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)}
</Group>
</Group>
</Box>
);
};
export default MultiSelectControls;
@@ -0,0 +1,106 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
}
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();
};
const handleDiscardChanges = () => {
setHasUnsavedChanges(false);
confirmNavigation();
};
const handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const _handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const BUTTON_WIDTH = "10rem";
if (!hasUnsavedChanges) {
return null;
}
return (
<Modal
opened={showNavigationWarning}
onClose={handleKeepWorking}
title={t("unsavedChangesTitle", "Unsaved Changes")}
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
>
<Stack>
<Stack ta="center" p="md">
<Text size="md" fw="300">
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
</Text>
<Text size="lg" fw="500" >
{t("areYouSure", "Are you sure you want to leave?")}
</Text>
</Stack>
{/* Desktop layout: 2 groups side by side */}
<Group justify="space-between" gap="xl" visibleFrom="md">
<Group gap="sm">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
</Group>
<Group gap="sm">
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
</Group>
</Group>
{/* Mobile layout: centered stack of 4 buttons */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
</Stack>
</Stack>
</Modal>
);
};
export default NavigationWarningModal;
@@ -0,0 +1,50 @@
import React from 'react';
import styles from '@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css';
type ObscuredOverlayProps = {
obscured: boolean;
overlayMessage?: React.ReactNode;
buttonText?: string;
onButtonClick?: () => void;
children: React.ReactNode;
// Optional border radius for the overlay container. If undefined, no radius is applied.
borderRadius?: string | number;
};
export default function ObscuredOverlay({
obscured,
overlayMessage,
buttonText,
onButtonClick,
children,
borderRadius,
}: ObscuredOverlayProps) {
return (
<div className={styles.container}>
{children}
{obscured && (
<div
className={styles.overlay}
style={{
...(borderRadius !== undefined ? { borderRadius } : {}),
}}
>
<div className={styles.overlayContent}>
{overlayMessage && (
<div className={styles.overlayMessage}>
{overlayMessage}
</div>
)}
{buttonText && onButtonClick && (
<button type="button" onClick={onButtonClick} className={styles.overlayButton}>
{buttonText}
</button>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
.container {
position: relative;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px;
color: #ffffff;
font-weight: 600;
background: rgba(16, 18, 27, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.06);
z-index: 2;
}
.overlayContent {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.overlayMessage {
color: #ffffff;
font-weight: 600;
}
.overlayButton {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: #ffffff;
cursor: pointer;
}
@@ -0,0 +1,270 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
import { ActionIcon, Stack, Divider } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import {
isNavButtonActive,
getNavButtonStyle,
getActiveNavButton,
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const { startTour } = useOnboarding();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
const isOverflow = useIsOverflowing(scrollableRef);
useEffect(() => {
const next = getActiveNavButton(selectedToolKey, readerMode);
setActiveButton(next);
}, [leftPanelView, selectedToolKey, toolRegistry, readerMode]);
const handleFilesButtonClick = () => {
openFilesModal();
};
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
: null;
const handleClick = (e?: React.MouseEvent) => {
if (navProps && e) {
handleUnlessSpecialClick(e, config.onClick);
} else {
config.onClick();
}
};
// Render navigation button with conditional URL support
return (
<div
key={config.id}
className="flex flex-col items-center gap-1"
style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}
data-tour={`${config.id}-button`}
>
<ActionIcon
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick(),
'aria-label': config.name
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isActive ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
);
};
const mainButtons: ButtonConfig[] = [
{
id: 'read',
name: t("quickAccess.read", "Read"),
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
onClick: () => {
setActiveButton('read');
handleReaderToggle();
}
},
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
onClick: () => {
setActiveButton('automate');
// If already on automate tool, reset it directly
if (selectedToolKey === 'automate') {
resetTool('automate');
} else {
handleToolSelect('automate');
}
}
},
];
const middleButtons: ButtonConfig[] = [
{
id: 'files',
name: t("quickAccess.files", "Files"),
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
isRound: true,
size: 'lg',
type: 'modal',
onClick: handleFilesButtonClick
},
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
];
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
isRound: true,
size: 'lg',
type: 'action',
onClick: () => {
startTour();
},
},
{
id: 'config',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
type: 'modal',
onClick: () => {
setConfigModalOpen(true);
}
}
];
return (
<div
ref={ref}
data-sidebar="quick-access"
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
style={{
borderRight: '1px solid var(--border-default)'
}}
>
{/* Fixed header outside scrollable area */}
<div className="quick-access-header">
<ActiveToolButton activeButton={activeButton} setActiveButton={setActiveButton} />
<AllToolsNavButton activeButton={activeButton} setActiveButton={setActiveButton} />
</div>
{/* Conditional divider when overflowing */}
{isOverflow && (
<Divider
size="xs"
className="overflow-divider"
/>
)}
{/* Scrollable content area */}
<div
ref={scrollableRef}
className="quick-access-bar flex-1"
onWheel={(e) => {
// Prevent the wheel event from bubbling up to parent containers
e.stopPropagation();
}}
>
<div className="scrollable-content">
{/* Main navigation section */}
<Stack gap="lg" align="center">
{mainButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
{/* Divider after main buttons */}
<Divider
size="xs"
className="content-divider"
/>
{/* Middle section */}
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
{/* Bottom section */}
<Stack gap="lg" align="center">
{bottomButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
</div>
</div>
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
);
});
QuickAccessBar.displayName = 'QuickAccessBar';
export default QuickAccessBar;
@@ -0,0 +1,60 @@
import { createContext, useContext, ReactNode } from 'react';
import { MantineProvider } from '@mantine/core';
import { useRainbowTheme } from '@app/hooks/useRainbowTheme';
import { mantineTheme } from '@app/theme/mantineTheme';
import rainbowStyles from '@app/styles/rainbow.module.css';
import { ToastProvider } from '@app/components/toast';
import ToastRenderer from '@app/components/toast/ToastRenderer';
import { ToastPortalBinder } from '@app/components/toast';
import type { ThemeMode } from '@app/constants/theme';
interface RainbowThemeContextType {
themeMode: ThemeMode;
isRainbowMode: boolean;
isToggleDisabled: boolean;
toggleTheme: () => void;
activateRainbow: () => void;
deactivateRainbow: () => void;
}
const RainbowThemeContext = createContext<RainbowThemeContextType | null>(null);
export function useRainbowThemeContext() {
const context = useContext(RainbowThemeContext);
if (!context) {
throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider');
}
return context;
}
interface RainbowThemeProviderProps {
children: ReactNode;
}
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const rainbowTheme = useRainbowTheme();
// Determine the Mantine color scheme
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
return (
<RainbowThemeContext.Provider value={rainbowTheme}>
<MantineProvider
theme={mantineTheme}
defaultColorScheme={mantineColorScheme}
forceColorScheme={mantineColorScheme}
>
<div
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
style={{ minHeight: '100vh' }}
>
<ToastProvider>
<ToastPortalBinder />
{children}
<ToastRenderer />
</ToastProvider>
</div>
</MantineProvider>
</RainbowThemeContext.Provider>
);
}
@@ -0,0 +1,230 @@
import React, { useCallback, useMemo } from 'react';
import { ActionIcon, Divider } from '@mantine/core';
import '@app/components/shared/rightRail/RightRail.css';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useRightRail } from '@app/contexts/RightRailContext';
import { useFileState, useFileSelection } from '@app/contexts/FileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import LanguageSelector from '@app/components/shared/LanguageSelector';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
function renderWithTooltip(
node: React.ReactNode,
tooltip: React.ReactNode | undefined
) {
if (!tooltip) return node;
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
return (
<Tooltip content={tooltip} position="left" offset={12} arrow portalTarget={portalTarget}>
<div className="right-rail-tooltip-wrapper">{node}</div>
</Tooltip>
);
}
export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
const { workbench: currentView } = useNavigationState();
const { selectors } = useFileState();
const { selectedFiles, selectedFileIds } = useFileSelection();
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
const exportState = viewerContext?.getExportState?.();
const totalItems = useMemo(() => {
if (currentView === 'pageEditor') return pageEditorTotalPages;
return activeFiles.length;
}, [currentView, pageEditorTotalPages, activeFiles.length]);
const selectedCount = useMemo(() => {
if (currentView === 'pageEditor') {
return pageEditorSelectedCount;
}
return selectedFileIds.length;
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
const sectionsWithButtons = useMemo(() => {
return SECTION_ORDER
.map(section => {
const sectionButtons = buttons.filter(btn => (btn.section ?? 'top') === section && (btn.visible ?? true));
return { section, buttons: sectionButtons };
})
.filter(entry => entry.buttons.length > 0);
}, [buttons]);
const renderButton = useCallback(
(btn: RightRailButtonConfig) => {
const action = actions[btn.id];
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
const triggerAction = () => {
if (!disabled) action?.();
};
if (btn.render) {
const context: RightRailRenderContext = {
id: btn.id,
disabled,
allButtonsDisabled,
action,
triggerAction,
};
return btn.render(context) ?? null;
}
if (!btn.icon) return null;
const ariaLabel =
btn.ariaLabel || (typeof btn.tooltip === 'string' ? (btn.tooltip as string) : undefined);
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
const buttonNode = (
<ActionIcon
variant="subtle"
radius="md"
className={className}
onClick={triggerAction}
disabled={disabled}
aria-label={ariaLabel}
>
{btn.icon}
</ActionIcon>
);
return renderWithTooltip(buttonNode, btn.tooltip);
},
[actions, allButtonsDisabled, disableForFullscreen]
);
const handleExportAll = useCallback(async () => {
if (currentView === 'viewer') {
if (!signaturesApplied) {
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
return;
}
viewerContext?.exportActions?.download();
return;
}
if (currentView === 'pageEditor') {
pageEditorFunctions?.onExportAll?.();
return;
}
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
});
}, [
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
signaturesApplied
]);
const downloadTooltip = useMemo(() => {
if (currentView === 'pageEditor') {
return t('rightRail.exportAll', 'Export PDF');
}
if (selectedCount > 0) {
return t('rightRail.downloadSelected', 'Download Selected Files');
}
return t('rightRail.downloadAll', 'Download All');
}, [currentView, selectedCount, t]);
return (
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
<div className="right-rail-inner">
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
<React.Fragment key={section}>
<div className="right-rail-section" data-tour="right-rail-controls">
{sectionButtons.map((btn, index) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div
key={btn.id}
className="right-rail-button-wrapper"
style={{ animationDelay: `${index * 50}ms` }}
>
{content}
</div>
);
})}
</div>
<Divider className="right-rail-divider" />
</React.Fragment>
))}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }} data-tour="right-rail-settings">
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={toggleTheme}
>
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
</ActionIcon>,
t('rightRail.toggleTheme', 'Toggle Theme')
)}
{renderWithTooltip(
<div style={{ display: 'inline-flex' }}>
<LanguageSelector position="left-start" offset={6} compact />
</div>,
t('rightRail.language', 'Language')
)}
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={
disableForFullscreen ||
(currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>,
downloadTooltip
)}
</div>
<div className="right-rail-spacer" />
</div>
</div>
);
}
@@ -0,0 +1,104 @@
import React from 'react';
import { Box, Group, Stack } from '@mantine/core';
interface SkeletonLoaderProps {
type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer';
count?: number;
animated?: boolean;
}
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
type,
count = 8,
animated = true
}) => {
const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
const renderPageGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
w="100%"
h={240}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
}}
/>
))}
</div>
);
const renderFileGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
w="100%"
h={280}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
}}
/>
))}
</div>
);
const renderControlsSkeleton = () => (
<Group mb="md">
<Box w={150} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={120} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={100} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
</Group>
);
const renderViewerSkeleton = () => (
<Stack gap="md" h="100%">
{/* Toolbar skeleton */}
<Group>
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={80} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
</Group>
{/* Main content skeleton */}
<Box
flex={1}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle
}}
/>
</Stack>
);
switch (type) {
case 'pageGrid':
return renderPageGridSkeleton();
case 'fileGrid':
return renderFileGridSkeleton();
case 'controls':
return renderControlsSkeleton();
case 'viewer':
return renderViewerSkeleton();
default:
return null;
}
};
export default SkeletonLoader;
@@ -0,0 +1,119 @@
import React, { forwardRef } from 'react';
import { useMantineColorScheme } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import styles from '@app/components/shared/textInput/TextInput.module.css';
/**
* Props for the TextInput component
*/
export interface TextInputProps {
/** The input ID (required) */
id: string;
/** The input name (required) */
name: string;
/** The input value (required) */
value: string;
/** Callback when input value changes (required) */
onChange: (value: string) => void;
/** Placeholder text */
placeholder?: string;
/** Optional left icon */
icon?: React.ReactNode;
/** Whether to show the clear button (default: true) */
showClearButton?: boolean;
/** Custom clear handler (defaults to setting value to empty string) */
onClear?: () => void;
/** Additional CSS classes */
className?: string;
/** Additional inline styles */
style?: React.CSSProperties;
/** HTML autocomplete attribute (default: 'off') */
autoComplete?: string;
/** Whether the input is disabled (default: false) */
disabled?: boolean;
/** Whether the input is read-only (default: false) */
readOnly?: boolean;
/** Accessibility label */
'aria-label'?: string;
/** Focus event handler */
onFocus?: () => void;
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
id,
name,
value,
onChange,
placeholder,
icon,
showClearButton = true,
onClear,
className = '',
style,
autoComplete = 'off',
disabled = false,
readOnly = false,
'aria-label': ariaLabel,
onFocus,
...props
}, ref) => {
const { colorScheme } = useMantineColorScheme();
const handleClear = () => {
if (onClear) {
onClear();
} else {
onChange('');
}
};
const shouldShowClearButton = showClearButton && value.trim().length > 0 && !disabled && !readOnly;
return (
<div className={`${styles.container} ${className}`} style={style}>
{icon && (
<span
className={styles.icon}
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
>
{icon}
</span>
)}
<input
ref={ref}
type="text"
id={id}
name={name}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
autoComplete={autoComplete}
className={styles.input}
disabled={disabled}
readOnly={readOnly}
aria-label={ariaLabel}
onFocus={onFocus}
style={{
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
paddingRight: shouldShowClearButton ? '40px' : '12px',
paddingLeft: icon ? '40px' : '12px',
}}
{...props}
/>
{shouldShowClearButton && (
<button
type="button"
className={styles.clearButton}
onClick={handleClear}
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
aria-label="Clear input"
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
</div>
);
});
TextInput.displayName = 'TextInput';
@@ -0,0 +1,161 @@
/**
* Reusable ToolChain component with smart truncation and tooltip expansion
* Used across FileListItem, FileDetails, and FileThumbnail for consistent display
*/
import React from 'react';
import { Text, Tooltip, Badge, Group } from '@mantine/core';
import { ToolOperation } from '@app/types/file';
import { useTranslation } from 'react-i18next';
import { ToolId } from '@app/types/toolId';
interface ToolChainProps {
toolChain: ToolOperation[];
maxWidth?: string;
displayStyle?: 'text' | 'badges' | 'compact';
size?: 'xs' | 'sm' | 'md';
color?: string;
}
const ToolChain: React.FC<ToolChainProps> = ({
toolChain,
maxWidth = '100%',
displayStyle = 'text',
size = 'xs',
color = 'var(--mantine-color-blue-7)'
}) => {
if (!toolChain || toolChain.length === 0) return null;
const { t } = useTranslation();
const toolIds = toolChain.map(tool => tool.toolId);
const getToolName = (toolId: ToolId) => {
return t(`home.${toolId}.title`, toolId);
};
// Create full tool chain for tooltip
const fullChainDisplay = displayStyle === 'badges' ? (
<Group gap="xs" wrap="wrap">
{toolChain.map((tool, index) => (
<React.Fragment key={`${tool.toolId}-${index}`}>
<Badge size="sm" variant="light" color="blue">
{getToolName(tool.toolId)}
</Badge>
{index < toolChain.length - 1 && (
<Text size="sm" c="dimmed"></Text>
)}
</React.Fragment>
))}
</Group>
) : (
<Text size="sm">{toolIds.map(getToolName).join(' → ')}</Text>
);
// Create truncated display based on available space
const getTruncatedDisplay = () => {
if (toolIds.length <= 2) {
// Show all tools if 2 or fewer
return { text: toolIds.map(getToolName).join(' → '), isTruncated: false };
} else {
// Show first tool ... last tool for longer chains
return {
text: `${getToolName(toolIds[0])} → +${toolIds.length-2}${getToolName(toolIds[toolIds.length - 1])}`,
isTruncated: true,
};
}
};
const { text: truncatedText, isTruncated } = getTruncatedDisplay();
// Compact style for very small spaces
if (displayStyle === 'compact') {
const compactText = toolIds.length === 1 ? getToolName(toolIds[0]) : `${toolIds.length} tools`;
const isCompactTruncated = toolIds.length > 1;
const compactElement = (
<Text
size={size}
style={{
color,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: `${maxWidth}`,
cursor: isCompactTruncated ? 'help' : 'default'
}}
>
{compactText}
</Text>
);
return isCompactTruncated ? (
<Tooltip label={fullChainDisplay} multiline withinPortal>
{compactElement}
</Tooltip>
) : compactElement;
}
// Badge style for file details
if (displayStyle === 'badges') {
const isBadgesTruncated = toolChain.length > 3;
const badgesElement = (
<div style={{ maxWidth: `${maxWidth}`, overflow: 'hidden' }}>
<Group gap="2px" wrap="nowrap">
{toolChain.slice(0, 3).map((tool, index) => (
<React.Fragment key={`${tool.toolId}-${index}`}>
<Badge size={size} variant="light" color="blue">
{getToolName(tool.toolId)}
</Badge>
{index < Math.min(toolChain.length - 1, 2) && (
<Text size="xs" c="dimmed"></Text>
)}
</React.Fragment>
))}
{toolChain.length > 3 && (
<>
<Text size="xs" c="dimmed">...</Text>
<Badge size={size} variant="light" color="blue">
{getToolName(toolChain[toolChain.length - 1].toolId)}
</Badge>
</>
)}
</Group>
</div>
);
return isBadgesTruncated ? (
<Tooltip label={`${toolIds.map(getToolName).join(' → ')}`} withinPortal>
{badgesElement}
</Tooltip>
) : badgesElement;
}
// Text style (default) for file list items
const textElement = (
<Text
size={size}
style={{
color,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: `${maxWidth}`,
cursor: isTruncated ? 'help' : 'default'
}}
>
{truncatedText}
</Text>
);
return isTruncated ? (
<Tooltip label={fullChainDisplay} withinPortal>
{textElement}
</Tooltip>
) : textElement;
};
export default ToolChain;
@@ -0,0 +1,34 @@
import React from "react";
interface ToolIconProps {
icon: React.ReactNode;
opacity?: number;
color?: string;
marginRight?: string;
}
/**
* Shared icon component for consistent tool icon styling across the application.
* Uses the same visual pattern as ToolButton: scaled to 0.8, centered transform, consistent spacing.
*/
export const ToolIcon: React.FC<ToolIconProps> = ({
icon,
opacity = 1,
color = "var(--tools-text-and-icon-color)",
marginRight = "0.5rem"
}) => {
return (
<div
className="tool-button-icon"
style={{
color,
marginRight,
transform: "scale(0.8)",
transformOrigin: "center",
opacity
}}
>
{icon}
</div>
);
};
@@ -0,0 +1,365 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { addEventListenerWithCleanup } from '@app/utils/genericUtils';
import { useTooltipPosition } from '@app/hooks/useTooltipPosition';
import { TooltipTip } from '@app/types/tips';
import { TooltipContent } from '@app/components/shared/tooltip/TooltipContent';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { BASE_PATH } from '@app/constants/app';
import styles from '@app/components/shared/tooltip/Tooltip.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
export interface TooltipProps {
sidebarTooltip?: boolean;
position?: 'right' | 'left' | 'top' | 'bottom';
content?: React.ReactNode;
tips?: TooltipTip[];
children: React.ReactElement;
offset?: number;
maxWidth?: number | string;
minWidth?: number | string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
arrow?: boolean;
portalTarget?: HTMLElement;
header?: { title: string; logo?: React.ReactNode };
delay?: number;
containerStyle?: React.CSSProperties;
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
/** If true, tooltip interaction is disabled entirely */
disabled?: boolean;
/** If false, tooltip will not open on focus (hover only) */
openOnFocus?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
sidebarTooltip = false,
position = 'right',
content,
tips,
children,
offset: gap = 8,
maxWidth,
minWidth,
open: controlledOpen,
onOpenChange,
arrow = false,
portalTarget,
header,
delay = 0,
containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
disabled = false,
openOnFocus = true,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const triggerRef = useRef<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clickPendingRef = useRef(false);
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
const clearTimers = useCallback(() => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
}, []);
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const setOpen = useCallback(
(newOpen: boolean) => {
if (newOpen === open) return; // avoid churn
if (isControlled) onOpenChange?.(newOpen);
else setInternalOpen(newOpen);
if (!newOpen) setIsPinned(false);
},
[isControlled, onOpenChange, open]
);
const { coords, positionReady } = useTooltipPosition({
open,
sidebarTooltip,
position,
gap,
triggerRef,
tooltipRef,
sidebarRefs: sidebarContext?.sidebarRefs,
sidebarState: sidebarContext?.sidebarState,
});
// Close on outside click: pinned → close; not pinned → optionally close
const handleDocumentClick = useCallback(
(e: MouseEvent) => {
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as Node | null;
const insideTooltip = tEl && target && tEl.contains(target);
const insideTrigger = trg && target && trg.contains(target);
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
if (!insideTooltip && !insideTrigger) {
setIsPinned(false);
setOpen(false);
}
return;
}
// Not pinned and configured to close on outside
if (closeOnOutside && !insideTooltip && !insideTrigger) {
setOpen(false);
}
},
[isPinned, closeOnOutside, setOpen]
);
useEffect(() => {
// Attach global click when open (so hover tooltips can also close on outside if desired)
if (open || isPinned) {
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
}
}, [open, isPinned, handleDocumentClick]);
useEffect(() => () => clearTimers(), [clearTimers]);
const arrowClass = useMemo(() => {
if (sidebarTooltip) return null;
const map: Record<NonNullable<TooltipProps['position']>, string> = {
top: 'tooltip-arrow-bottom',
bottom: 'tooltip-arrow-top',
left: 'tooltip-arrow-left',
right: 'tooltip-arrow-right',
};
return map[position] || map.right;
}, [position, sidebarTooltip]);
const getArrowStyleClass = useCallback(
(key: string) =>
styles[key as keyof typeof styles] ||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
'',
[]
);
// === Trigger handlers ===
const openWithDelay = useCallback(() => {
clearTimers();
if (disabled) return;
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
}, [clearTimers, setOpen, delay, disabled]);
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned && !disabled) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled]
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
clearTimers();
if (!isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
},
[children.props]
);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
// allow microtask turn so click can see this false
queueMicrotask(() => (clickPendingRef.current = false));
(children.props as any)?.onMouseUp?.(e);
},
[children.props]
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
clearTimers();
if (pinOnClick) {
e.preventDefault?.();
e.stopPropagation?.();
if (!open) setOpen(true);
setIsPinned(true);
clickPendingRef.current = false;
return;
}
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props]
);
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned && !disabled && openOnFocus) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
);
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
return;
}
if (!isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
}, [setOpen]);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => {
clearTimers();
}, [clearTimers]);
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
},
[isPinned, setOpen]
);
// Enhance child with handlers and ref
const childWithHandlers = React.cloneElement(children as any, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') originalRef(node);
else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node;
},
'aria-describedby': open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
});
const shouldShowTooltip = open;
const tooltipElement = shouldShowTooltip ? (
<div
id={tooltipIdRef.current}
ref={tooltipRef}
role="tooltip"
tabIndex={-1}
onPointerEnter={handleTooltipPointerEnter}
onPointerLeave={handleTooltipPointerLeave}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
color: 'var(--text-primary)',
...containerStyle,
}}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
>
{isPinned && (
<button
className={styles['tooltip-pin-button']}
onClick={(e) => {
e.stopPropagation();
setIsPinned(false);
setOpen(false);
}}
title="Close tooltip"
aria-label="Close tooltip"
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
{arrow && !sidebarTooltip && (
<div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
style={
coords.arrowOffset !== null
? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
: undefined
}
/>
)}
{header && (
<div className={styles['tooltip-header']}>
<div className={styles['tooltip-logo']}>
{header.logo || (
<img
src={`${BASE_PATH}/logo-tooltip.svg`}
alt="Stirling PDF"
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
/>
)}
</div>
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent content={content} tips={tips} />
</div>
) : null;
return (
<>
{childWithHandlers}
{(() => {
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
const target = portalTarget ?? defaultTarget;
return tooltipElement && target
? createPortal(tooltipElement, target)
: tooltipElement;
})()}
</>
);
};
@@ -0,0 +1,206 @@
import React, { useState, useCallback } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import rainbowStyles from '@app/styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
const viewOptionStyle: React.CSSProperties = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
};
// Build view options showing text always
const createViewOptions = (
currentView: WorkbenchType,
switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void,
customViews?: CustomWorkbenchViewInstance[]
) => {
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || '';
const displayName = isInViewer && fileName ? fileName : 'Viewer';
const hasMultipleFiles = activeFiles.length > 1;
const showDropdown = isInViewer && hasMultipleFiles;
const viewerOption = {
label: showDropdown ? (
<FileDropdownMenu
displayName={displayName}
activeFiles={activeFiles}
currentFileIndex={currentFileIndex}
onFileSelect={onFileSelect}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<span className="ph-no-capture">{displayName}</span>
</div>
),
value: "viewer",
};
const pageEditorOption = {
label: (
<div style={viewOptionStyle}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
)}
</div>
),
value: "pageEditor",
};
const fileEditorOption = {
label: (
<div style={viewOptionStyle}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
)}
</div>
),
value: "fileEditor",
};
const baseOptions = [
viewerOption,
pageEditorOption,
fileEditorOption,
];
const customOptions = (customViews ?? [])
.filter((view) => view.data != null)
.map((view) => ({
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === view.workbenchId ? (
<Loader size="xs" />
) : (
view.icon || <PictureAsPdfIcon fontSize="small" />
)}
<span>{view.label}</span>
</div>
),
value: view.workbenchId,
}));
return [...baseOptions, ...customOptions];
};
interface TopControlsProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
customViews?: CustomWorkbenchViewInstance[];
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex?: number;
onFileSelect?: (index: number) => void;
}
const TopControls = ({
currentView,
setCurrentView,
customViews = [],
activeFiles = [],
currentFileIndex = 0,
onFileSelect,
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
return;
}
const workbench = view;
// Show immediate feedback
setSwitchingTo(workbench);
// Defer the heavy view change to next frame so spinner can render
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(workbench);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
}, [setCurrentView]);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data-tour="view-switcher"
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
value={currentView}
onChange={handleViewChange}
color="blue"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
pointerEvents: 'auto'
}}
styles={{
root: {
borderRadius: 9999,
maxHeight: '2.6rem',
},
control: {
borderRadius: 9999,
},
indicator: {
borderRadius: 9999,
maxHeight: '2rem',
},
label: {
paddingTop: '0rem',
}
}}
/>
</div>
</div>
);
};
export default TopControls;
@@ -0,0 +1,15 @@
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export function OverviewHeader() {
const { t } = useTranslation();
return (
<div>
<Text fw={600} size="lg">{t('config.overview.title', 'Application Configuration')}</Text>
<Text size="sm" c="dimmed">
{t('config.overview.description', 'Current application settings and configuration details.')}
</Text>
</div>
);
}
@@ -0,0 +1,64 @@
import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
export interface ConfigNavItem {
key: NavKey;
label: string;
icon: string;
component: React.ReactNode;
}
export interface ConfigNavSection {
title: string;
items: ConfigNavItem[];
}
export interface ConfigColors {
navBg: string;
sectionTitle: string;
navItem: string;
navItemActive: string;
navItemActiveBg: string;
contentBg: string;
headerBorder: string;
}
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void,
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
title: 'Account',
items: [
{
key: 'overview',
label: 'Overview',
icon: 'person-rounded',
component: <Overview onLogoutClick={onLogoutClick} />
},
],
},
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />
},
],
},
];
return sections;
};
@@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '@app/contexts/PreferencesContext';
import type { ToolPanelMode } from '@app/constants/toolPanel';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
return (
<Stack gap="lg">
<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>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
</Stack>
);
};
export default GeneralSection;
@@ -0,0 +1,204 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useHotkeys } from '@app/contexts/HotkeyContext';
import { ToolId } from '@app/types/toolId';
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
import { bindingEquals, eventToBinding, HotkeyBinding } from '@app/utils/hotkeys';
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
const rowStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
};
const rowHeaderStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
};
const HotkeysSection: React.FC = () => {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
const filteredTools = useMemo(() => {
if (!searchQuery.trim()) return tools;
const query = searchQuery.toLowerCase();
return tools.filter(([toolId, tool]) =>
tool.name.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query) ||
toolId.toLowerCase().includes(query)
);
}, [tools, searchQuery]);
useEffect(() => {
if (!editingTool) {
return;
}
pauseHotkeys();
return () => {
resumeHotkeys();
};
}, [editingTool, pauseHotkeys, resumeHotkeys]);
useEffect(() => {
if (!editingTool) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setEditingTool(null);
setError(null);
return;
}
event.preventDefault();
event.stopPropagation();
const binding = eventToBinding(event as KeyboardEvent);
if (!binding) {
const osKey = isMac ? 'mac' : 'windows';
const fallbackText = isMac
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
return;
}
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
toolId !== editingTool && bindingEquals(existing, binding)
));
if (conflictEntry) {
const conflictKey = conflictEntry[0];
const conflictTool = (conflictKey in toolRegistry)
? toolRegistry[conflictKey as ToolId]?.name
: conflictKey;
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
return;
}
updateHotkey(editingTool, binding);
setEditingTool(null);
setError(null);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
};
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
const handleStartCapture = (toolId: ToolId) => {
setEditingTool(toolId);
setError(null);
};
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
<Text size="sm" c="dimmed">
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
</Text>
</div>
<TextInput
placeholder={t('settings.hotkeys.searchPlaceholder', 'Search tools...')}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
size="md"
radius="md"
/>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{filteredTools.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('toolPicker.noToolsFound', 'No tools found')}
</Text>
) : (
filteredTools.map(([toolId, tool], index) => {
const currentBinding = hotkeys[toolId];
const defaultBinding = defaults[toolId];
const isEditing = editingTool === toolId;
const defaultParts = getDisplayParts(defaultBinding);
const defaultLabel = defaultParts.length > 0
? defaultParts.join(' + ')
: t('settings.hotkeys.none', 'Not assigned');
return (
<React.Fragment key={toolId}>
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
<div style={rowHeaderStyle}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
<Text fw={600}>{tool.name}</Text>
<Group gap="xs" wrap="wrap" align="center">
<HotkeyDisplay binding={currentBinding} size="md" />
{!bindingEquals(currentBinding, defaultBinding) && (
<Badge variant="light" color="orange" radius="sm">
{t('settings.hotkeys.customBadge', 'Custom')}
</Badge>
)}
<Text size="xs" c="dimmed">
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
</Text>
</Group>
</div>
<Group gap="xs">
<Button
size="xs"
variant={isEditing ? 'filled' : 'default'}
color={isEditing ? 'blue' : undefined}
onClick={() => handleStartCapture(toolId)}
>
{isEditing
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
: t('settings.hotkeys.change', 'Change shortcut')}
</Button>
<Button
size="xs"
variant="subtle"
disabled={bindingEquals(currentBinding, defaultBinding)}
onClick={() => resetHotkey(toolId)}
>
{t('settings.hotkeys.reset', 'Reset')}
</Button>
</Group>
</div>
{isEditing && error && (
<Alert color="red" radius="sm" variant="filled">
{error}
</Alert>
)}
</Box>
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})
)}
</Stack>
</Paper>
</Stack>
);
};
export default HotkeysSection;
@@ -0,0 +1,97 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { OverviewHeader } from '@app/components/shared/config/OverviewHeader';
const Overview: React.FC = () => {
const { config, loading, error } = useAppConfig();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const integrationConfig = config ? {
SSOAutoLogin: config.SSOAutoLogin,
} : null;
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
);
}
if (error) {
return (
<Alert color="red" title="Error">
{error}
</Alert>
);
}
return (
<Stack gap="lg">
<OverviewHeader />
{config && (
<>
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
</>
)}
</Stack>
);
};
export default Overview;
@@ -0,0 +1,19 @@
export type NavKey =
| 'overview'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys';
// some of these are not used yet, but appear in figma designs
@@ -0,0 +1,65 @@
import React from 'react';
import { Box } from '@mantine/core';
export interface DocumentStackProps {
totalFiles: number;
children: React.ReactNode;
}
const DocumentStack: React.FC<DocumentStackProps> = ({
totalFiles,
children
}) => {
const stackDocumentBaseStyle = {
position: 'absolute' as const,
width: '100%',
height: '100%'
};
const stackDocumentShadows = {
back: '0 2px 8px rgba(0, 0, 0, 0.1)',
middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
};
return (
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
{/* Background documents (stack effect) */}
{totalFiles >= 3 && (
<Box
style={{
...stackDocumentBaseStyle,
backgroundColor: 'var(--mantine-color-gray-3)',
boxShadow: stackDocumentShadows.back,
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
zIndex: 1
}}
/>
)}
{totalFiles >= 2 && (
<Box
style={{
...stackDocumentBaseStyle,
backgroundColor: 'var(--mantine-color-gray-2)',
boxShadow: stackDocumentShadows.middle,
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
zIndex: 2
}}
/>
)}
{/* Main document container */}
<Box style={{
position: 'relative',
width: '100%',
height: '100%',
zIndex: 3,
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)'
}}>
{children}
</Box>
</Box>
);
};
export default DocumentStack;
@@ -0,0 +1,66 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { StirlingFileStub } from '@app/types/fileContext';
export interface DocumentThumbnailProps {
file: File | StirlingFileStub | null;
thumbnail?: string | null;
style?: React.CSSProperties;
onClick?: () => void;
children?: React.ReactNode;
}
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
file,
thumbnail,
style = {},
onClick,
children
}) => {
if (!file) return null;
const containerStyle = {
position: 'relative' as const,
cursor: onClick ? 'pointer' : 'default',
transition: 'opacity 0.2s ease',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...style
};
if (thumbnail) {
return (
<Box style={containerStyle} onClick={onClick}>
<Image
className='ph-no-capture'
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
{children}
</Box>
);
}
return (
<Box style={containerStyle} onClick={onClick}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
<PictureAsPdfIcon
className='ph-no-capture'
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
/>
</Center>
{children}
</Box>
);
};
export default DocumentThumbnail;
@@ -0,0 +1,63 @@
import React from 'react';
import { Box } from '@mantine/core';
import VisibilityIcon from '@mui/icons-material/Visibility';
export interface HoverOverlayProps {
onMouseEnter?: (e: React.MouseEvent) => void;
onMouseLeave?: (e: React.MouseEvent) => void;
children: React.ReactNode;
}
const HoverOverlay: React.FC<HoverOverlayProps> = ({
onMouseEnter,
onMouseLeave,
children
}) => {
const defaultMouseEnter = (e: React.MouseEvent) => {
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
if (overlay) overlay.style.opacity = '1';
};
const defaultMouseLeave = (e: React.MouseEvent) => {
const overlay = e.currentTarget.querySelector('.hover-overlay') as HTMLElement;
if (overlay) overlay.style.opacity = '0';
};
return (
<Box
style={{
position: 'relative',
width: '100%',
height: '100%'
}}
onMouseEnter={onMouseEnter || defaultMouseEnter}
onMouseLeave={onMouseLeave || defaultMouseLeave}
>
{children}
{/* Hover overlay */}
<Box
className="hover-overlay"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: '0.25rem',
opacity: 0,
transition: 'opacity 0.2s ease',
pointerEvents: 'none'
}}
>
<VisibilityIcon style={{ color: 'white', fontSize: '1.5rem' }} />
</Box>
</Box>
);
};
export default HoverOverlay;
@@ -0,0 +1,66 @@
import React from 'react';
import { Box, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
export interface NavigationArrowsProps {
onPrevious: () => void;
onNext: () => void;
disabled?: boolean;
children: React.ReactNode;
}
const NavigationArrows: React.FC<NavigationArrowsProps> = ({
onPrevious,
onNext,
disabled = false,
children
}) => {
const navigationArrowStyle = {
position: 'absolute' as const,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
};
return (
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
{/* Left Navigation Arrow */}
<ActionIcon
variant="light"
size="sm"
onClick={onPrevious}
color="blue"
disabled={disabled}
style={{
...navigationArrowStyle,
left: '0'
}}
>
<ChevronLeftIcon />
</ActionIcon>
{/* Content */}
<Box style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{children}
</Box>
{/* Right Navigation Arrow */}
<ActionIcon
variant="light"
size="sm"
onClick={onNext}
color="blue"
disabled={disabled}
style={{
...navigationArrowStyle,
right: '0'
}}
>
<ChevronRightIcon />
</ActionIcon>
</Box>
);
};
export default NavigationArrows;
@@ -0,0 +1,112 @@
# FitText Component
Adaptive text component that automatically scales font size down so the content fits within its container, with optional multi-line clamping. Built with a small hook wrapper around ResizeObserver and MutationObserver for reliable, responsive fitting.
## Features
- 📏 Auto-fit text to available width (and optional line count)
- 🧵 Single-line and multi-line support with clamping and ellipsis
- 🔁 React hook + component interface
- ⚡ Efficient: observers and rAF, minimal layout thrash
- 🎛️ Configurable min scale, max font size, and step size
## Behavior
- On mount and whenever size/text changes, the font is reduced (never increased) until the text fits the given constraints.
- If `lines` is provided, height is constrained to an estimated maximum based on computed line-height.
## Basic Usage
```tsx
import FitText from '@/components/shared/FitText';
export function CardTitle({ title }: { title: string }) {
return (
<FitText text={title} />
);
}
```
## API Reference
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `text` | `string` | — | The string to render and fit |
| `fontSize` | `number` | computed | Maximum starting font size in rem (e.g., 1.2, 0.9) |
| `minimumFontScale` | `number` | `0.8` | Smallest scale relative to the max (0..1) |
| `lines` | `number` | `1` | Maximum number of lines to display and fit |
| `className` | `string` | — | Optional class on the rendered element |
| `style` | `CSSProperties` | — | Inline styles (merged with internal clamp styles) |
| `as` | `'span' | 'div'` | `'span'` | HTML tag to render |
Notes:
- For multi-line, the component applies WebKit line clamping (with reasonable fallbacks) and fits within that height.
- The component only scales down; if the content already fits, it keeps the starting size.
## Examples
### Single-line title (default)
```tsx
<FitText text="Very long single-line title that should shrink" />
```
### Multi-line label (up to 3 lines)
```tsx
<FitText
text="This label can wrap up to three lines and will shrink so it fits nicely"
lines={3}
minimumFontScale={0.6}
className="my-multiline-label"
/>
```
### Explicit starting size
```tsx
<FitText text="Starts at 1.2rem, scales down if needed" fontSize={1.2} />
```
### Render as a div
```tsx
<FitText as="div" text="Block-level content" lines={2} />
```
## Hook Usage (Advanced)
If you need to control your own element, you can use the underlying hook directly.
```tsx
import React, { useRef } from 'react';
import { useAdjustFontSizeToFit } from '@/components/shared/fitText/textFit';
export function CustomFit() {
const ref = useRef<HTMLSpanElement | null>(null);
useAdjustFontSizeToFit(ref as any, {
maxFontSizePx: 20,
minFontScale: 0.6,
maxLines: 2,
singleLine: false,
});
return (
<span ref={ref} style={{ display: 'inline-block', maxWidth: 240 }}>
Arbitrary text that will scale to fit two lines.
</span>
);
}
```
## Tips
- For predictable measurements, ensure the container has a fixed width (or stable layout) when fitting occurs.
- Avoid animating width while fitting; update after animation completes for best results.
- When you need more control of typography, pass `fontSize` to define the starting ceiling.
- **Important**: The `fontSize` prop expects `rem` values (e.g., 1.2, 0.9) to ensure text scales with global font size changes.
@@ -0,0 +1,102 @@
import { RefObject, useEffect } from 'react';
export type AdjustFontSizeOptions = {
/** Max font size to start from. Defaults to the element's computed font size. */
maxFontSizePx?: number;
/** Minimum scale relative to max size (like React Native's minimumFontScale). Default 0.7 */
minFontScale?: number;
/** Step as a fraction of max size used while shrinking. Default 0.05 (5%). */
stepScale?: number;
/** Limit the number of lines to fit. If omitted, only width is considered for multi-line. */
maxLines?: number;
/** If true, force single-line fitting (uses nowrap). Default false. */
singleLine?: boolean;
};
/**
* Imperative util: progressively reduces font-size until content fits within the element
* (width and optional line count). Returns a cleanup that disconnects observers.
*/
export function adjustFontSizeToFit(
element: HTMLElement,
options: AdjustFontSizeOptions = {}
): () => void {
if (!element) return () => {};
const computed = window.getComputedStyle(element);
const baseFontPx = options.maxFontSizePx ?? parseFloat(computed.fontSize || '16');
const minScale = Math.max(0.1, options.minFontScale ?? 0.7);
const stepScale = Math.max(0.005, options.stepScale ?? 0.05);
const singleLine = options.singleLine ?? false;
const maxLines = options.maxLines;
// Ensure measurement is consistent
if (singleLine) {
element.style.whiteSpace = 'nowrap';
}
// Never split within words; only allow natural breaks (spaces) or explicit soft breaks
element.style.wordBreak = 'keep-all';
element.style.overflowWrap = 'normal';
// Disable automatic hyphenation to avoid mid-word breaks; use only manual opportunities
element.style.setProperty('hyphens', 'manual');
element.style.overflow = 'visible';
const minFontPx = baseFontPx * minScale;
const stepPx = Math.max(0.5, baseFontPx * stepScale);
const fit = () => {
// Reset to largest before measuring
element.style.fontSize = `${baseFontPx}px`;
// Calculate target height threshold for line limit
let maxHeight = Number.POSITIVE_INFINITY;
if (typeof maxLines === 'number' && maxLines > 0) {
const cs = window.getComputedStyle(element);
const lineHeight = parseFloat(cs.lineHeight) || baseFontPx * 1.2;
maxHeight = lineHeight * maxLines + 0.1; // small epsilon
}
let current = baseFontPx;
// Guard against excessive loops
let iterations = 0;
while (iterations < 200) {
const fitsWidth = element.scrollWidth <= element.clientWidth + 1; // tolerance
const fitsHeight = element.scrollHeight <= maxHeight + 1;
const fits = fitsWidth && fitsHeight;
if (fits || current <= minFontPx) break;
current = Math.max(minFontPx, current - stepPx);
element.style.fontSize = `${current}px`;
iterations += 1;
}
};
// Defer to next frame to ensure layout is ready
const raf = requestAnimationFrame(fit);
const ro = new ResizeObserver(() => fit());
ro.observe(element);
if (element.parentElement) ro.observe(element.parentElement);
const mo = new MutationObserver(() => fit());
mo.observe(element, { characterData: true, childList: true, subtree: true });
return () => {
cancelAnimationFrame(raf);
try { ro.disconnect(); } catch { /* Ignore errors */ }
try { mo.disconnect(); } catch { /* Ignore errors */ }
};
}
/** React hook wrapper for convenience */
export function useAdjustFontSizeToFit(
ref: RefObject<HTMLElement | null>,
options: AdjustFontSizeOptions = {}
) {
useEffect(() => {
if (!ref.current) return;
const cleanup = adjustFontSizeToFit(ref.current, options);
return cleanup;
}, [ref, options.maxFontSizePx, options.minFontScale, options.stepScale, options.maxLines, options.singleLine]);
}
@@ -0,0 +1,192 @@
/**
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
*
* When a user selects a tool from the All Tools list, this component displays the tool's
* icon and name at the top of the navigation bar. It provides a quick way to see which
* tool is currently active and offers a back button to return to the All Tools list.
*
* Features:
* - Shows tool icon and name when a tool is selected
* - Hover to reveal back arrow for returning to All Tools
* - Smooth slide-down/slide-up animations
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
*/
import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import FitText from '@app/components/shared/FitText';
import { Tooltip } from '@app/components/shared/Tooltip';
interface ActiveToolButtonProps {
activeButton: string;
setActiveButton: (id: string) => void;
}
const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
// Special case: multiTool should always show even when sidebars are hidden
const indicatorShouldShow = Boolean(
selectedToolKey &&
((leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)) ||
selectedToolKey === 'multiTool')
);
// Local animation and hover state
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
const [replayAnim, setReplayAnim] = useState<boolean>(false);
const [isBackHover, setIsBackHover] = useState<boolean>(false);
const prevKeyRef = useRef<string | null>(null);
const collapseTimeoutRef = useRef<number | null>(null);
const animTimeoutRef = useRef<number | null>(null);
const replayRafRef = useRef<number | null>(null);
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey; };
const clearTimers = () => {
if (collapseTimeoutRef.current) {
window.clearTimeout(collapseTimeoutRef.current);
collapseTimeoutRef.current = null;
}
if (animTimeoutRef.current) {
window.clearTimeout(animTimeoutRef.current);
animTimeoutRef.current = null;
}
};
const playGrowDown = () => {
clearTimers();
setIndicatorTool(selectedTool);
setIndicatorVisible(true);
// Force a replay even if the class is already applied
setReplayAnim(false);
if (replayRafRef.current) {
cancelAnimationFrame(replayRafRef.current);
replayRafRef.current = null;
}
replayRafRef.current = requestAnimationFrame(() => {
setReplayAnim(true);
});
prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => {
setReplayAnim(false);
animTimeoutRef.current = null;
}, 500);
};
const firstShow = () => {
clearTimers();
setIndicatorTool(selectedTool);
setIndicatorVisible(true);
prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => {
animTimeoutRef.current = null;
}, 500);
};
const triggerCollapse = () => {
clearTimers();
setIndicatorVisible(false);
collapseTimeoutRef.current = window.setTimeout(() => {
setIndicatorTool(null);
prevKeyRef.current = null;
collapseTimeoutRef.current = null;
}, 500); // match CSS transition duration
};
useEffect(() => {
if (indicatorShouldShow) {
clearTimers();
if (!indicatorVisible) {
firstShow();
return;
}
if (!indicatorTool) {
firstShow();
} else if (isSwitchingToNewTool()) {
playGrowDown();
} else {
// keep reference in sync
prevKeyRef.current = (selectedToolKey as string) || null;
}
} else if (indicatorTool || indicatorVisible) {
triggerCollapse();
}
}, [indicatorShouldShow, selectedTool, selectedToolKey]);
useEffect(() => {
return () => {
clearTimers();
if (replayRafRef.current) {
cancelAnimationFrame(replayRafRef.current);
replayRafRef.current = null;
}
};
}, []);
return (
<>
<div style={{ overflow: 'visible' }} className={`current-tool-slot ${indicatorVisible ? 'visible' : ''} ${replayAnim ? 'replay' : ''}`}>
{indicatorTool && (
<div className="current-tool-content">
<div className="flex flex-col items-center gap-1">
<Tooltip content={isBackHover ? 'Back to all tools' : indicatorTool.name} position="right" arrow maxWidth={140}>
<ActionIcon
component="a"
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
handleUnlessSpecialClick(e, () => {
setActiveButton('tools');
handleBackToTools();
});
}}
size={'xl'}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
style={{
backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)',
color: isBackHover ? '#fff' : 'var(--icon-tools-color)',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
textDecoration: 'none'
}}
>
<span className="iconContainer">
{isBackHover ? (
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
) : (
indicatorTool.icon
)}
</span>
</ActionIcon>
</Tooltip>
<FitText
as="span"
text={indicatorTool.name}
lines={3}
minimumFontScale={0.4}
className="button-text active current-tool-label"
/>
</div>
</div>
)}
</div>
</>
);
};
export default ActiveToolButton;
@@ -0,0 +1,265 @@
.activeIconScale {
transform: scale(1.3);
transition: transform 0.2s;
z-index: 1;
}
.iconContainer {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
/* Action icon styles */
.action-icon-style {
background-color: var(--icon-user-bg);
color: var(--icon-user-color);
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
}
/* Main container styles */
.quick-access-bar-main {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
position: relative;
z-index: 10;
}
/* Rainbow mode container */
.quick-access-bar-main.rainbow-mode {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
position: relative;
z-index: 10;
}
/* Header padding */
.quick-access-header {
padding: 1rem 0.5rem 0.5rem 0.5rem;
}
.nav-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 0;
gap: 0.5rem;
}
/* Nav header divider */
.nav-header-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin-top: 0.5rem;
margin-bottom: 1rem;
}
/* All tools text styles */
.all-tools-text {
margin-top: 0.75rem;
font-size: 0.75rem;
text-rendering: optimizeLegibility;
font-synthesis: none;
text-align: center;
display: block;
}
.all-tools-text.active {
color: var(--text-primary);
font-weight: bold;
}
.all-tools-text.inactive {
color: var(--color-gray-700);
font-weight: normal;
}
/* Overflow divider */
.overflow-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 0 0.5rem;
}
/* Scrollable content area */
.quick-access-bar {
overflow-x: auto;
overflow-y: auto;
scrollbar-gutter: stable both-edges;
-webkit-overflow-scrolling: touch;
padding: 0 0.5rem 1rem 0.5rem;
}
/* Scrollable content container */
.scrollable-content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
}
/* Button text styles */
.button-text {
margin-top: 0.75rem;
font-size: 0.75rem;
text-rendering: optimizeLegibility;
font-synthesis: none;
text-align: center;
display: block;
}
/* Allow wrapping under the active top indicator; constrain to two lines */
.current-tool-label {
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* show up to two lines */
line-clamp: 2;
-webkit-box-orient: vertical;
word-break: keep-all;
overflow-wrap: normal;
hyphens: manual;
}
.button-text.active {
color: var(--text-primary);
font-weight: bold;
}
.button-text.inactive {
color: var(--color-gray-700);
font-weight: normal;
}
/* Content divider */
.content-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 1rem 0;
}
/* Spacer */
.spacer {
flex: 1;
margin-top: 1rem;
}
/* Config button text */
.config-button-text {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--color-gray-700);
font-weight: normal;
text-rendering: optimizeLegibility;
font-synthesis: none;
}
/* Font size utility */
.font-size-20 {
font-size: 20px;
}
/* Hide scrollbar by default, show on scroll (Webkit browsers - Chrome, Safari, Edge) */
.quick-access-bar::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
background: transparent;
}
.quick-access-bar:hover::-webkit-scrollbar,
.quick-access-bar:active::-webkit-scrollbar,
.quick-access-bar:focus::-webkit-scrollbar {
background: rgba(0, 0, 0, 0.1);
}
.quick-access-bar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 0.25rem;
}
.quick-access-bar::-webkit-scrollbar-track {
background: transparent;
}
/* Firefox scrollbar styling */
.quick-access-bar {
scrollbar-width: auto;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
/* Animated current tool indicator that slides in from the top and pushes content down */
/* Container grows down so it pushes items below during animation */
.current-tool-slot {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 450ms ease-out, opacity 300ms ease-out;
}
.current-tool-enter {
animation: currentToolGrowDown 450ms ease-out;
}
.current-tool-slot.visible {
max-height: 8.25rem; /* icon + up to 3-line label + divider (132px) */
opacity: 1;
border-bottom: 1px solid var(--color-gray-300);
padding-bottom: 0.75rem; /* push border down for spacing */
margin-bottom: 1rem;
}
/* Replay the grow-down animation when switching tools while visible */
.current-tool-slot.replay .current-tool-content {
animation: currentToolGrowDown 450ms ease-out;
}
/* Also animate the container itself when replaying so it "pushes down" again */
.current-tool-slot.replay {
animation: currentToolGrowDown 450ms ease-out;
}
@keyframes currentToolGrowDown {
0% {
max-height: 0;
opacity: 0;
}
100% {
max-height: 7.875rem; /* enough space for icon + up to 3-line label (126px) */
opacity: 1;
}
}
/* Divider that animates growing from top */
.current-tool-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 0.5rem auto 0.5rem auto;
transform-origin: top;
animation: dividerGrowDown 350ms ease-out;
animation-fill-mode: both;
}
@keyframes dividerGrowDown {
0% {
transform: scaleY(0);
opacity: 0;
margin-top: 0;
margin-bottom: 0;
}
100% {
transform: scaleY(1);
opacity: 1;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
}
@@ -0,0 +1,83 @@
import { ButtonConfig } from '@app/types/sidebar';
// Border radius constants
export const ROUND_BORDER_RADIUS = '0.5rem';
/**
* Check if a navigation button is currently active
*/
export const isNavButtonActive = (
config: ButtonConfig,
activeButton: string,
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
): boolean => {
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
const isActiveByContext =
config.type === 'navigation' &&
leftPanelView === 'toolContent' &&
selectedToolKey === config.id;
const isActiveByModal =
(config.type === 'modal' && config.id === 'files' && isFilesModalOpen) ||
(config.type === 'modal' && config.id === 'config' && configModalOpen);
return isActiveByLocalState || isActiveByContext || isActiveByModal;
};
/**
* Get button styles based on active state
*/
export const getNavButtonStyle = (
config: ButtonConfig,
activeButton: string,
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
) => {
const isActive = isNavButtonActive(
config,
activeButton,
isFilesModalOpen,
configModalOpen,
selectedToolKey,
leftPanelView
);
if (isActive) {
return {
backgroundColor: `var(--icon-${config.id}-bg)`,
color: `var(--icon-${config.id}-color)`,
border: 'none',
borderRadius: ROUND_BORDER_RADIUS,
};
}
// Inactive state for all buttons
return {
backgroundColor: 'var(--icon-inactive-bg)',
color: 'var(--icon-inactive-color)',
border: 'none',
borderRadius: ROUND_BORDER_RADIUS,
};
};
/**
* Determine the active nav button based on current tool state and registry
*/
export const getActiveNavButton = (
selectedToolKey: string | null,
readerMode: boolean
): string => {
// Reader mode takes precedence and should highlight the Read nav item
if (readerMode) {
return 'read';
}
// If a tool is selected, highlight it immediately even if the panel view
// transition to 'toolContent' has not completed yet. This prevents a brief
// period of no-highlight during rapid navigation.
return selectedToolKey ? selectedToolKey : 'tools';
};
@@ -0,0 +1,143 @@
# RightRail Component
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
## Structure
- **Top Section**: Dynamic buttons from tools (empty when none)
- **Middle Section**: Grid, Cut, Undo, Redo
- **Bottom Section**: Save, Print, Share
## Usage
### For Tools (Recommended)
```tsx
import { useRightRailButtons } from '../hooks/useRightRailButtons';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
function MyTool() {
const handleAction = useCallback(() => {
// Your action here
}, []);
useRightRailButtons([
{
id: 'my-action',
icon: <PlayArrowIcon />,
tooltip: 'Execute Action',
onClick: handleAction,
},
]);
return <div>My Tool</div>;
}
```
### Multiple Buttons
```tsx
useRightRailButtons([
{
id: 'primary',
icon: <StarIcon />,
tooltip: 'Primary Action',
order: 1,
onClick: handlePrimary,
},
{
id: 'secondary',
icon: <SettingsIcon />,
tooltip: 'Secondary Action',
order: 2,
onClick: handleSecondary,
},
]);
```
### Conditional Buttons
```tsx
useRightRailButtons([
// Always show
{
id: 'process',
icon: <PlayArrowIcon />,
tooltip: 'Process',
disabled: isProcessing,
onClick: handleProcess,
},
// Only show when condition met
...(hasResults ? [{
id: 'export',
icon: <DownloadIcon />,
tooltip: 'Export',
onClick: handleExport,
}] : []),
]);
```
## API
### Button Config
```typescript
interface RightRailButtonWithAction {
id: string; // Unique identifier
icon?: React.ReactNode; // Icon component (omit when using render)
tooltip?: React.ReactNode; // Hover tooltip / description
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
order?: number; // Sort order (default: 0)
disabled?: boolean; // Disabled state (default: false)
visible?: boolean; // Visibility (default: true)
render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer
onClick?: () => void; // Click handler (optional if using render)
}
interface RightRailRenderContext {
id: string;
disabled: boolean;
allButtonsDisabled: boolean;
action?: () => void;
triggerAction: () => void;
}
```
### Custom Rendering (Popovers, Multi-button Blocks)
```tsx
useRightRailButtons([
{
id: 'viewer-search',
tooltip: t('rightRail.search', 'Search PDF'),
render: ({ disabled }) => (
<Tooltip content={t('rightRail.search', 'Search PDF')}>
<Popover position="left">
<Popover.Target>
<ActionIcon disabled={disabled}>
<SearchIcon />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<SearchInterface />
</Popover.Dropdown>
</Popover>
</Tooltip>
),
},
]);
```
## Built-in Features
- **Undo/Redo**: Automatically integrates with Page Editor
- **Theme Support**: Light/dark mode with CSS variables
- **Auto Cleanup**: Buttons unregister when tool unmounts
## Best Practices
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
- Choose appropriate Material-UI icons
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
- Use `useCallback` for click handlers to prevent re-registration
- Reach for `render` when you need popovers or multi-control groups inside the rail
@@ -0,0 +1,161 @@
.right-rail {
background-color: var(--right-rail-bg);
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
height: 100vh;
border-left: 1px solid var(--border-subtle);
}
.right-rail-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.right-rail-section {
background-color: var(--right-rail-foreground);
border-radius: 12px;
padding: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.right-rail-button-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
animation: rightRailButtonReveal 200ms ease forwards;
transform-origin: top center;
opacity: 0;
}
.right-rail-tooltip-wrapper {
display: inline-flex;
width: 100%;
justify-content: center;
}
@keyframes rightRailButtonReveal {
0% {
opacity: 0;
transform: scaleY(0.6) translateY(-6px);
}
100% {
opacity: 1;
transform: scaleY(1) translateY(0);
}
}
.right-rail-divider {
width: 2.75rem;
border: none;
border-top: 1px solid var(--tool-subcategory-rule-color);
margin: 0.25rem 0;
}
.right-rail-icon {
color: var(--right-rail-icon);
}
.right-rail-icon[aria-disabled="true"],
.right-rail-icon[disabled] {
color: var(--right-rail-icon-disabled) !important;
background-color: transparent !important;
}
/* When all buttons are disabled via context */
.right-rail--all-disabled .right-rail-icon {
color: var(--right-rail-icon-disabled) !important;
background-color: transparent !important;
pointer-events: none !important;
}
.right-rail-spacer {
flex: 1;
}
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
.right-rail-slot {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 450ms ease-out, opacity 300ms ease-out;
}
.right-rail-enter {
animation: rightRailGrowDown 450ms ease-out;
}
.right-rail-exit {
animation: rightRailShrinkUp 450ms ease-out;
}
.right-rail-slot.visible {
max-height: 40rem; /* increased to fit additional controls + divider */
opacity: 1;
}
@keyframes rightRailGrowDown {
0% {
max-height: 0;
opacity: 0;
}
100% {
max-height: 40rem;
opacity: 1;
}
}
@keyframes rightRailShrinkUp {
0% {
max-height: 40rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
/* Remove bottom margin from close icon */
.right-rail-slot .right-rail-icon {
margin-bottom: 0;
}
/* Inline appear/disappear animation for page-number selector button */
.right-rail-fade {
transition-property: opacity, transform, max-height, visibility;
transition-duration: 220ms, 220ms, 220ms, 0s;
transition-timing-function: ease, ease, ease, linear;
transition-delay: 0s, 0s, 0s, 0s;
transform-origin: top center;
overflow: hidden;
}
.right-rail-fade.enter {
opacity: 1;
transform: scale(1);
max-height: 3rem;
visibility: visible;
}
.right-rail-fade.exit {
opacity: 0;
transform: scale(0.85);
max-height: 0;
visibility: hidden;
/* delay visibility change so opacity/max-height can finish */
transition-delay: 0s, 0s, 0s, 220ms;
pointer-events: none;
}
@@ -0,0 +1,233 @@
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '@app/contexts/FileContext';
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
import { createProcessedFile } from '@app/contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
interface ViewerAnnotationControlsProps {
currentView: string;
disabled?: boolean;
}
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
}
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}
@@ -0,0 +1,43 @@
import { Slider, Text, Group, NumberInput } from '@mantine/core';
interface Props {
label: string;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
}
export default function SliderWithInput({
label,
value,
onChange,
disabled,
min = 0,
max = 200,
step = 1,
}: Props) {
return (
<div>
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text>
<Group gap="sm" align="center">
<div style={{ flex: 1 }}>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
</div>
<NumberInput
value={value}
onChange={(v) => onChange(Number(v) || 0)}
min={min}
max={max}
step={step}
disabled={disabled}
style={{ width: 90 }}
/>
</Group>
</div>
);
}
@@ -0,0 +1,73 @@
.container {
position: relative;
display: flex;
align-items: center;
}
.icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
font-size: 16px;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.input {
width: 100%;
padding: 8px 12px;
border: none;
border-radius: 6px;
font-size: 14px;
outline: none;
box-shadow: none;
transition: box-shadow 0.2s ease;
}
.input::placeholder {
color: var(--search-text-and-icon-color);
opacity: 1;
}
.input:focus {
outline: none;
box-shadow: none;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input:read-only {
cursor: default;
}
.clearButton {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: background-color 0.2s ease;
}
.clearButton:hover {
background-color: rgba(0, 0, 0, 0.1);
}
[data-mantine-color-scheme="dark"] .clearButton:hover {
background-color: rgba(255, 255, 255, 0.1);
}
@@ -0,0 +1,212 @@
# Tooltip Component
A flexible, accessible tooltip component supporting regular positioning and special sidebar positioning, with optional clicktopin behavior. By default, it opens on hover/focus and can be pinned on click when `pinOnClick` is enabled.
---
## Highlights
* 🎯 **Smart Positioning**: Keeps tooltips within the viewport and aligns the arrow dynamically.
* 📱 **Sidebar Aware**: Purposebuilt logic for sidebar/navigation contexts.
***Accessible**: Keyboard and screenreader friendly (`role="tooltip"`, `aria-describedby`, Escape to close, focus/blur support).
* 🎨 **Customizable**: Arrows, headers, rich JSX content, and structured tips.
* 🌙 **Themeable**: Uses CSS variables; supports dark mode out of the box.
***Efficient**: Memoized calculations and stable callbacks to minimize rerenders.
* 📜 **Scrollable Content**: When content exceeds max height.
* 📌 **ClicktoPin**: (Optional) Pin open; close via outside click or close button.
* 🔗 **LinkSafe**: Fully clickable links in descriptions, bullets, and custom content.
* 🖱️ **PointerFriendly**: Uses pointer events (works with mouse/pen/touch hover where applicable).
---
## Behavior
### Default
* **Hover/Focus**: Opens on pointer **enter** or when the trigger receives **focus** (respects optional `delay`).
* **Leave/Blur**: Closes on pointer **leave** (from trigger *and* tooltip) or when the trigger/tooltip **blurs** to the page—unless pinned.
* **Inside Tooltip**: Moving from trigger → tooltip keeps it open; moving out of both closes it (unless pinned).
* **Escape**: Press **Esc** to close.
### ClicktoPin (optional)
* Enable with `pinOnClick`.
* **Click trigger** (or tooltip) to pin open.
* **Click outside** **both** trigger and tooltip to close when pinned.
* Use the close button (X) to unpin and close.
> **Note**: Outsideclick closing when **not** pinned is configurable via `closeOnOutside` (default `true`).
---
## Installation
```tsx
import { Tooltip } from '@/components/shared';
```
---
## Basic Usage
```tsx
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
```
With structured tips and a header:
```tsx
<Tooltip
tips={[{
title: 'OCR Mode',
description: 'Choose how to process text in your documents.',
bullets: [
'<strong>Auto</strong> skips pages that already contain text.',
'<strong>Force</strong> re-processes every page.',
'<strong>Strict</strong> stops if text is found.',
"<a href='https://docs.example.com' target='_blank' rel='noreferrer'>Learn more</a>",
],
}]}
header={{ title: 'Basic Settings Overview', logo: <img src="/logo.svg" alt="Logo" /> }}
>
<button>Settings</button>
</Tooltip>
```
---
## API
### `<Tooltip />` Props
| Prop | Type | Default | Description |
| ---------------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `children` | `ReactElement` | **required** | The trigger element. Receives ARIA and event handlers. |
| `content` | `ReactNode` | `undefined` | Custom JSX content rendered below any `tips`. |
| `tips` | `TooltipTip[]` | `undefined` | Structured content (title, description, bullets, optional body). |
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic (no arrow in sidebar mode). |
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Preferred placement (ignored if `sidebarTooltip` is `true`). |
| `offset` | `number` | `8` | Gap (px) between trigger and tooltip. |
| `maxWidth` | `number \| string` | `undefined` | Max width. If omitted and `sidebarTooltip` is true, defaults visually to \~`25rem`. |
| `minWidth` | `number \| string` | `undefined` | Min width. |
| `open` | `boolean` | `undefined` | Controlled open state. If provided, the component is controlled. |
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback when open state would change. |
| `arrow` | `boolean` | `false` | Shows a directional arrow (suppressed in sidebar mode). |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into. |
| `header` | `{ title: string; logo?: ReactNode }` | `undefined` | Optional header with title and logo. |
| `delay` | `number` | `0` | Hover/focus open delay in ms. |
| `containerStyle` | `React.CSSProperties` | `{}` | Inline style overrides for the tooltip container. |
| `pinOnClick` | `boolean` | `false` | Clicking the trigger pins the tooltip open. |
| `closeOnOutside` | `boolean` | `true` | When not pinned, clicking outside closes the tooltip. Always closes when pinned and clicking outside both trigger & tooltip. |
### `TooltipTip`
```ts
export interface TooltipTip {
title?: string; // Optional pill label
description?: string; // HTML allowed (e.g., <a>)
bullets?: string[]; // HTML allowed in each string
body?: React.ReactNode; // Optional custom JSX
}
```
---
## Accessibility
* The tooltip container uses `role="tooltip"` and gets a stable `id`.
* The trigger receives `aria-describedby` when the tooltip is open.
* Opens on **focus** and closes on **blur** (unless pinned), supporting keyboard navigation.
* **Escape** closes the tooltip.
* Pointer events are mirrored with keyboard/focus for parity.
> Ensure custom triggers remain focusable (e.g., `button`, `a`, or add `tabIndex=0`).
---
## Interaction Details
* **Hover Timing**: Opening can be delayed via `delay`. Closing is immediate on pointer leave from both trigger and tooltip (unless pinned). Timers are cleared on state changes and unmounts.
* **Outside Clicks**: When pinned, clicking outside **both** the trigger and tooltip closes it. When not pinned, outside clicks close it if `closeOnOutside` is `true`.
* **Event Preservation**: Original child event handlers (`onClick`, `onPointerEnter`, etc.) are called after the tooltip augments them.
* **Refs**: The triggers existing `ref` (function or object) is preserved.
---
## Examples
### With Arrow
```tsx
<Tooltip content="Arrow tooltip" arrow position="top">
<button>Arrow tooltip</button>
</Tooltip>
```
### Optional Hover Delay
```tsx
<Tooltip content="Appears after 1s" delay={1000}>
<button>Delayed</button>
</Tooltip>
```
### Manual Control (Advanced)
```tsx
function ManualControlTooltip() {
const [open, setOpen] = useState(false);
return (
<Tooltip content="Fully controlled tooltip" open={open} onOpenChange={setOpen}>
<button onClick={() => setOpen(!open)}>Toggle tooltip</button>
</Tooltip>
);
}
```
### Sidebar Tooltip
```tsx
<Tooltip content="Appears to the right of the sidebar" sidebarTooltip>
<div className="sidebar-item">📁 File Manager</div>
</Tooltip>
```
### Mixed Content
```tsx
<Tooltip
tips={[{ title: 'Section', description: 'Description' }]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
---
## Positioning Notes
* Initial placement is derived from `position` (or sidebar rules when `sidebarTooltip` is true).
* Tooltip is clamped within the viewport; the arrow is offset to remain visually aligned with the trigger.
* Sidebar mode positions to the sidebars edge and clamps vertically. Arrows are disabled in sidebar mode.
---
## Caveats & Tips
* Ensure your container doesnt block pointer events between trigger and tooltip.
* When using `portalTarget`, confirm its attached to `document.body` before rendering.
* For very dynamic layouts, call positioning after layout changes (the hook already listens to open/refs/viewport).
---
## Changelog (since previous README)
* Added keyboard & ARIA details (focus/blur, Escape, `aria-describedby`).
* Clarified outsideclick behavior for pinned vs unpinned.
* Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`.
* Removed references to nonexistent props (e.g., `delayAppearance`).
* Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`).
@@ -0,0 +1,190 @@
/* Tooltip Container */
.tooltip-container {
position: fixed;
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
background-color: var(--bg-raised);
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05);
font-size: 0.875rem;
line-height: 1.5;
pointer-events: auto;
z-index: 9999;
transition: opacity 100ms ease-out, transform 100ms ease-out;
max-width: 50vh;
max-height: 80vh;
color: var(--text-primary);
display: flex;
flex-direction: column;
}
/* Pinned tooltip indicator */
.tooltip-container.pinned {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), 0 0 0 0.125rem rgba(59, 130, 246, 0.1);
}
/* Pinned tooltip header */
.tooltip-container.pinned .tooltip-header {
background-color: var(--primary-color, #3b82f6);
color: white;
border-color: var(--primary-color, #3b82f6);
}
/* Close button */
.tooltip-pin-button {
position: absolute;
top: -0.5rem;
right: 0.5rem;
font-size: 0.875rem;
background: var(--bg-raised);
padding: 0.25rem;
border-radius: 0.25rem;
border: 0.0625rem solid var(--primary-color, #3b82f6);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
min-height: 1.5rem;
}
.tooltip-pin-button .material-symbols-outlined {
font-size: 1rem;
line-height: 1;
}
.tooltip-pin-button:hover {
background-color: #ef4444 !important;
border-color: #ef4444 !important;
}
/* Tooltip Header */
.tooltip-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background-color: var(--tooltip-header-bg);
color: var(--tooltip-header-color);
font-size: 0.875rem;
font-weight: 500;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
margin: -0.0625rem -0.0625rem 0 -0.0625rem;
border: 0.0625rem solid var(--tooltip-border);
flex-shrink: 0;
}
.tooltip-logo {
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.tooltip-title {
flex: 1;
}
/* Tooltip Body */
.tooltip-body {
padding: 1rem !important;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
line-height: 1.6 !important;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.tooltip-body * {
color: var(--text-primary) !important;
}
/* Link styling within tooltips */
.tooltip-body a {
color: var(--link-color, #3b82f6) !important;
text-decoration: underline;
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
transition: color 0.2s ease, text-decoration-color 0.2s ease;
}
.tooltip-body a:hover {
color: var(--link-hover-color, #2563eb) !important;
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
}
.tooltip-container .tooltip-body {
color: var(--text-primary) !important;
}
.tooltip-container .tooltip-body * {
color: var(--text-primary) !important;
}
/* Ensure links maintain their styling */
.tooltip-container .tooltip-body a {
color: var(--link-color, #3b82f6) !important;
text-decoration: underline;
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
}
.tooltip-container .tooltip-body a:hover {
color: var(--link-hover-color, #2563eb) !important;
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
}
/* Tooltip Arrows */
.tooltip-arrow {
position: absolute;
width: 0.5rem;
height: 0.5rem;
background: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
transform: rotate(45deg);
}
.tooltip-arrow-sidebar {
top: 50%;
left: -0.25rem;
transform: translateY(-50%) rotate(45deg);
border-left: none;
border-bottom: none;
}
.tooltip-arrow-top {
top: -0.25rem;
left: 50%;
transform: translateX(-50%) rotate(-135deg);
border-top: none;
border-left: none;
}
.tooltip-arrow-bottom {
bottom: -0.25rem;
left: 50%;
transform: translateX(-50%) rotate(45deg);
border-bottom: none;
border-right: none;
}
.tooltip-arrow-left {
right: -0.25rem;
top: 50%;
transform: translateY(-50%) rotate(45deg);
border-left: none;
border-bottom: none;
}
.tooltip-arrow-right {
left: -0.25rem;
top: 50%;
transform: translateY(-50%) rotate(45deg);
border-right: none;
border-top: none;
}
@@ -0,0 +1,72 @@
import React from 'react';
import styles from '@app/components/shared/tooltip/Tooltip.module.css';
import { TooltipTip } from '@app/types/tips';
interface TooltipContentProps {
content?: React.ReactNode;
tips?: TooltipTip[];
}
export const TooltipContent: React.FC<TooltipContentProps> = ({
content,
tips,
}) => {
return (
<div
className={`${styles['tooltip-body']}`}
style={{
color: 'var(--text-primary)',
padding: '16px',
fontSize: '14px',
lineHeight: '1.6'
}}
>
<div style={{ color: 'var(--text-primary)' }}>
{tips ? (
<>
{tips.map((tip, index) => (
<div key={index} style={{ marginBottom: index < tips.length - 1 ? '24px' : '0' }}>
{tip.title && (
<div style={{
display: 'inline-block',
backgroundColor: 'var(--tooltip-title-bg)',
color: 'var(--tooltip-title-color)',
padding: '6px 12px',
borderRadius: '16px',
fontSize: '12px',
fontWeight: '600',
marginBottom: '12px'
}}>
{tip.title}
</div>
)}
{tip.description && (
<p style={{ margin: '0 0 12px 0', color: 'var(--text-secondary)', fontSize: '13px' }} dangerouslySetInnerHTML={{ __html: tip.description }} />
)}
{tip.bullets && tip.bullets.length > 0 && (
<ul style={{ margin: '0', paddingLeft: '16px', color: 'var(--text-secondary)', fontSize: '13px' }}>
{tip.bullets.map((bullet, bulletIndex) => (
<li key={bulletIndex} style={{ marginBottom: '6px' }} dangerouslySetInnerHTML={{ __html: bullet }} />
))}
</ul>
)}
{tip.body && (
<div style={{ marginTop: '12px' }}>
{tip.body}
</div>
)}
</div>
))}
{content && (
<div style={{ marginTop: '24px' }}>
{content}
</div>
)}
</>
) : (
content
)}
</div>
</div>
);
};