Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
@@ -1,21 +1,18 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
import React from "react";
import { useTranslation } from "react-i18next";
import AppsIcon from "@mui/icons-material/AppsRounded";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation";
import { handleUnlessSpecialClick } from "@app/utils/clickHandlers";
import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton";
interface AllToolsNavButtonProps {
activeButton: string;
setActiveButton: (id: string) => void;
}
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
activeButton,
setActiveButton,
}) => {
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
@@ -23,7 +20,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
const { getHomeNavigation } = useSidebarNavigation();
const performNavigation = () => {
setActiveButton('tools');
setActiveButton("tools");
// Preserve existing behavior used in QuickAccessBar header
handleReaderToggle();
handleBackToTools();
@@ -38,7 +35,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
};
// Do not highlight All Tools when a specific tool is open (indicator is shown)
const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker';
const isActive = activeButton === "tools" && !selectedToolKey && leftPanelView === "toolPicker";
const navProps = getHomeNavigation();
@@ -54,7 +51,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
return (
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
icon={<AppsIcon sx={{ fontSize: isActive ? "1.875rem" : "1.5rem" }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
@@ -68,4 +65,3 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
};
export default AllToolsNavButton;
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { updateSupportedLanguages } from '@app/i18n';
import { useEffect } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { updateSupportedLanguages } from "@app/i18n";
/**
* Component that loads app configuration and applies it to the application.
@@ -220,7 +220,7 @@
margin: 0 -1rem;
margin-bottom: -1rem;
}
.settings-section-content {
padding-bottom: 4rem;
}
@@ -1,16 +1,16 @@
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useConfigNavSections } from '@app/components/shared/config/configNavSections';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
import { SettingsSearchBar } from '@app/components/shared/config/SettingsSearchBar';
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
import { useNavigate, useLocation } from "react-router-dom";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import "@app/components/shared/AppConfigModal.css";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { UnsavedChangesProvider, useUnsavedChanges } from "@app/contexts/UnsavedChangesContext";
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
interface AppConfigModalProps {
opened: boolean;
@@ -18,7 +18,7 @@ interface AppConfigModalProps {
}
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('general');
const [active, setActive] = useState<NavKey>("general");
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
@@ -42,9 +42,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const section = getSectionFromPath(location.pathname);
if (opened && section) {
setActive(section);
} else if (opened && location.pathname.startsWith('/settings') && !section) {
} else if (opened && location.pathname.startsWith("/settings") && !section) {
// If at /settings without a section, redirect to general
navigate('/settings/general', { replace: true });
navigate("/settings/general", { replace: true });
}
}, [location.pathname, opened, navigate]);
@@ -63,19 +63,22 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
navigate(`/settings/${detail.key}`);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
window.addEventListener("appConfig:navigate", handler as EventListener);
return () => window.removeEventListener("appConfig:navigate", handler as EventListener);
}, [navigate]);
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)',
}), []);
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)",
}),
[],
);
// Get isAdmin and runningEE from app config
const isAdmin = config?.isAdmin ?? false;
@@ -83,23 +86,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const loginEnabled = config?.enableLogin ?? false;
// Left navigation structure and icons
const configNavSections = useConfigNavSections(
isAdmin,
runningEE,
loginEnabled
);
const configNavSections = useConfigNavSections(isAdmin, runningEE, loginEnabled);
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
const found = section.items.find((i) => i.key === active);
if (found) return found.label;
}
return '';
return "";
}, [configNavSections, active]);
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
const found = section.items.find((i) => i.key === active);
if (found) return found.component;
}
return null;
@@ -108,19 +107,22 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const handleClose = useCallback(async () => {
const canProceed = await confirmIfDirty();
if (!canProceed) return;
// Navigate back to home when closing modal
navigate('/', { replace: true });
navigate("/", { replace: true });
onClose();
}, [confirmIfDirty, navigate, onClose]);
const handleNavigation = useCallback(async (key: NavKey) => {
const canProceed = await confirmIfDirty();
if (!canProceed) return;
const handleNavigation = useCallback(
async (key: NavKey) => {
const canProceed = await confirmIfDirty();
if (!canProceed) return;
setActive(key);
navigate(`/settings/${key}`);
}, [confirmIfDirty, navigate]);
setActive(key);
navigate(`/settings/${key}`);
},
[confirmIfDirty, navigate],
);
return (
<Modal
@@ -139,30 +141,28 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
className={`modal-nav ${isMobile ? "mobile" : ""}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
{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 }}>
<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 => {
{section.items.map((item) => {
const isActive = active === item.key;
const isDisabled = item.disabled ?? false;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
const showPlanWarning =
item.key === 'adminPlan' &&
licenseAlert.active &&
licenseAlert.audience === 'admin';
item.key === "adminPlan" && licenseAlert.active && licenseAlert.audience === "admin";
const navItemContent = (
<div
@@ -172,11 +172,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
handleNavigation(item.key);
}
}}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
background: isActive ? colors.navItemActiveBg : "transparent",
opacity: isDisabled ? 0.6 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
cursor: isDisabled ? "not-allowed" : "pointer",
}}
data-tour={`admin-${item.key}-nav`}
>
@@ -187,7 +187,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{item.label}
</Text>
{item.badge && (
<Badge size="xs" variant="light" color={item.badgeColor ?? 'orange'} style={{ flexShrink: 0 }}>
<Badge size="xs" variant="light" color={item.badgeColor ?? "orange"} style={{ flexShrink: 0 }}>
{item.badge}
</Badge>
)}
@@ -196,7 +196,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
icon="warning-rounded"
width={14}
height={14}
style={{ color: 'var(--mantine-color-orange-7)' }}
style={{ color: "var(--mantine-color-orange-7)" }}
/>
)}
</Group>
@@ -235,27 +235,17 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">{activeLabel}</Text>
<Text fw={700} size="lg">
{activeLabel}
</Text>
<Group gap="xs" wrap="nowrap">
<SettingsSearchBar
configNavSections={configNavSections}
onNavigate={handleNavigation}
isMobile={isMobile}
/>
<ActionIcon
ref={closeButtonRef}
variant="subtle"
onClick={handleClose}
aria-label="Close"
data-autofocus
>
<SettingsSearchBar configNavSections={configNavSections} onNavigate={handleNavigation} isMobile={isMobile} />
<ActionIcon ref={closeButtonRef} variant="subtle" onClick={handleClose} aria-label="Close" data-autofocus>
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</div>
<div className="modal-body">
{activeComponent}
</div>
<div className="modal-body">{activeComponent}</div>
</div>
</div>
</div>
+29 -29
View File
@@ -1,10 +1,10 @@
import React from 'react';
import { Box } from '@mantine/core';
import React from "react";
import { Box } from "@mantine/core";
interface BadgeProps {
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'colored';
size?: "sm" | "md" | "lg";
variant?: "default" | "colored";
color?: string;
textColor?: string;
backgroundColor?: string;
@@ -12,38 +12,38 @@ interface BadgeProps {
style?: React.CSSProperties;
}
const Badge: React.FC<BadgeProps> = ({
children,
size = 'sm',
variant = 'default',
const Badge: React.FC<BadgeProps> = ({
children,
size = "sm",
variant = "default",
color,
textColor,
backgroundColor,
className,
style
style,
}) => {
const getSizeStyles = () => {
switch (size) {
case 'sm':
case "sm":
return {
padding: '0.125rem 0.5rem',
fontSize: '0.75rem',
padding: "0.125rem 0.5rem",
fontSize: "0.75rem",
fontWeight: 700,
borderRadius: '0.5rem',
borderRadius: "0.5rem",
};
case 'md':
case "md":
return {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
padding: "0.25rem 0.75rem",
fontSize: "0.875rem",
fontWeight: 700,
borderRadius: '0.625rem',
borderRadius: "0.625rem",
};
case 'lg':
case "lg":
return {
padding: '0.375rem 1rem',
fontSize: '1rem',
padding: "0.375rem 1rem",
fontSize: "1rem",
fontWeight: 700,
borderRadius: '0.75rem',
borderRadius: "0.75rem",
};
default:
return {};
@@ -58,7 +58,7 @@ const Badge: React.FC<BadgeProps> = ({
color: textColor,
};
}
// If a single color is provided, use it for text and 20% opacity for background
if (color) {
return {
@@ -66,21 +66,21 @@ const Badge: React.FC<BadgeProps> = ({
color: color,
};
}
// If variant is colored but no color provided, use default colored styling
if (variant === 'colored') {
if (variant === "colored") {
return {
backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`,
color: 'var(--category-color-default)',
color: "var(--category-color-default)",
borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`,
border: '1px solid',
border: "1px solid",
};
}
// Default styling
return {
background: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
};
};
@@ -1,19 +1,19 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core';
import LinkIcon from '@mui/icons-material/Link';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import { useTranslation } from 'react-i18next';
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core";
import LinkIcon from "@mui/icons-material/Link";
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
import { useTranslation } from "react-i18next";
import apiClient from '@app/services/apiClient';
import { absoluteWithBasePath } from '@app/constants/app';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { StirlingFileStub } from '@app/types/fileContext';
import { uploadHistoryChains } from '@app/services/serverStorageUpload';
import { fileStorage } from '@app/services/fileStorage';
import { useFileActions } from '@app/contexts/FileContext';
import type { FileId } from '@app/types/file';
import apiClient from "@app/services/apiClient";
import { absoluteWithBasePath } from "@app/constants/app";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import { uploadHistoryChains } from "@app/services/serverStorageUpload";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
interface BulkShareModalProps {
opened: boolean;
@@ -22,12 +22,7 @@ interface BulkShareModalProps {
onShared?: () => Promise<void> | void;
}
const BulkShareModal: React.FC<BulkShareModalProps> = ({
opened,
onClose,
files,
onShared,
}) => {
const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files, onShared }) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { actions } = useFileActions();
@@ -35,7 +30,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
const [isWorking, setIsWorking] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [shareToken, setShareToken] = useState<string | null>(null);
const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor');
const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">("editor");
useEffect(() => {
if (!opened) {
@@ -47,34 +42,35 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
useEffect(() => {
if (opened) {
setShareRole('editor');
setShareRole("editor");
}
}, [opened]);
const shareUrl = useMemo(() => {
if (!shareToken) return '';
const frontendUrl = (config?.frontendUrl || '').trim();
if (!shareToken) return "";
const frontendUrl = (config?.frontendUrl || "").trim();
if (frontendUrl) {
const normalized = frontendUrl.endsWith('/')
? frontendUrl.slice(0, -1)
: frontendUrl;
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
return `${normalized}/share/${shareToken}`;
}
return absoluteWithBasePath(`/share/${shareToken}`);
}, [config?.frontendUrl, shareToken]);
const createShareLink = useCallback(async (storedFileId: number) => {
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
accessRole: shareRole,
});
return response.data as { token?: string };
}, [shareRole]);
const createShareLink = useCallback(
async (storedFileId: number) => {
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
accessRole: shareRole,
});
return response.data as { token?: string };
},
[shareRole],
);
const handleGenerateLink = useCallback(async () => {
if (!shareLinksEnabled) {
alert({
alertType: 'warning',
title: t('storageShare.linksDisabled', 'Share links are disabled.'),
alertType: "warning",
title: t("storageShare.linksDisabled", "Share links are disabled."),
expandable: false,
durationMs: 2500,
});
@@ -85,19 +81,11 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
setShareToken(null);
try {
const rootIds = Array.from(
new Set(files.map((file) => (file.originalFileId || file.id) as FileId))
);
const remoteIds = Array.from(
new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])
);
const existingRemoteId =
rootIds.length === 1 && remoteIds.length === 1 ? remoteIds[0] : undefined;
const rootIds = Array.from(new Set(files.map((file) => (file.originalFileId || file.id) as FileId)));
const remoteIds = Array.from(new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[]));
const existingRemoteId = rootIds.length === 1 && remoteIds.length === 1 ? remoteIds[0] : undefined;
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChains(
rootIds,
existingRemoteId
);
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChains(rootIds, existingRemoteId);
const shareResponse = await createShareLink(storedId);
setShareToken(shareResponse.token ?? null);
@@ -120,8 +108,8 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
}
alert({
alertType: 'success',
title: t('storageShare.generated', 'Share link generated'),
alertType: "success",
title: t("storageShare.generated", "Share link generated"),
expandable: false,
durationMs: 3000,
});
@@ -129,10 +117,8 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
await onShared();
}
} catch (error: any) {
console.error('Failed to generate share link:', error);
setErrorMessage(
t('storageShare.failure', 'Unable to generate a share link. Please try again.')
);
console.error("Failed to generate share link:", error);
setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again."));
} finally {
setIsWorking(false);
}
@@ -143,16 +129,16 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
try {
await navigator.clipboard.writeText(shareUrl);
alert({
alertType: 'success',
title: t('storageShare.copied', 'Link copied to clipboard'),
alertType: "success",
title: t("storageShare.copied", "Link copied to clipboard"),
expandable: false,
durationMs: 2000,
});
} catch (error) {
console.error('Failed to copy share link:', error);
console.error("Failed to copy share link:", error);
alert({
alertType: 'warning',
title: t('storageShare.copyFailed', 'Copy failed'),
alertType: "warning",
title: t("storageShare.copyFailed", "Copy failed"),
expandable: false,
durationMs: 2500,
});
@@ -164,7 +150,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
opened={opened}
onClose={onClose}
centered
title={t('storageShare.bulkTitle', 'Share selected files')}
title={t("storageShare.bulkTitle", "Share selected files")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
size="lg"
overlayProps={{ blur: 6 }}
@@ -172,14 +158,11 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Stack gap={4}>
<Text size="sm">
{t(
'storageShare.bulkDescription',
'Create one link to share all selected files with signed-in users.'
)}
</Text>
<Text size="sm">
{t("storageShare.bulkDescription", "Create one link to share all selected files with signed-in users.")}
</Text>
<Text size="sm" c="dimmed">
{t('storageShare.fileCount', '{{count}} files selected', {
{t("storageShare.fileCount", "{{count}} files selected", {
count: files.length,
})}
</Text>
@@ -187,13 +170,13 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
</Paper>
{errorMessage && (
<Alert color="red" title={t('storageShare.errorTitle', 'Share failed')}>
<Alert color="red" title={t("storageShare.errorTitle", "Share failed")}>
{errorMessage}
</Alert>
)}
{!shareLinksEnabled && (
<Alert color="yellow" title={t('storageShare.linksDisabled', 'Share links are disabled.')}>
{t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')}
<Alert color="yellow" title={t("storageShare.linksDisabled", "Share links are disabled.")}>
{t("storageShare.linksDisabledBody", "Share links are disabled by your server settings.")}
</Alert>
)}
@@ -203,7 +186,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
<TextInput
readOnly
value={shareUrl}
label={t('storageShare.linkLabel', 'Share link')}
label={t("storageShare.linkLabel", "Share link")}
rightSection={
<Button
variant="subtle"
@@ -211,7 +194,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
onClick={handleCopyLink}
>
{t('storageShare.copy', 'Copy')}
{t("storageShare.copy", "Copy")}
</Button>
}
/>
@@ -222,22 +205,22 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('storageShare.linkAccessTitle', 'Share link access')}
{t("storageShare.linkAccessTitle", "Share link access")}
</Text>
<Select
label={t('storageShare.roleLabel', 'Role')}
label={t("storageShare.roleLabel", "Role")}
value={shareRole}
onChange={(value) => setShareRole((value as typeof shareRole) || 'editor')}
onChange={(value) => setShareRole((value as typeof shareRole) || "editor")}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
data={[
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
{ value: "editor", label: t("storageShare.roleEditor", "Editor") },
{ value: "commenter", label: t("storageShare.roleCommenter", "Commenter") },
{ value: "viewer", label: t("storageShare.roleViewer", "Viewer") },
]}
/>
{shareRole === 'commenter' && (
{shareRole === "commenter" && (
<Text size="xs" c="dimmed">
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
{t("storageShare.commenterHint", "Commenting is coming soon.")}
</Text>
)}
</Stack>
@@ -245,7 +228,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={isWorking}>
{t('cancel', 'Cancel')}
{t("cancel", "Cancel")}
</Button>
<Button
leftSection={<LinkIcon style={{ fontSize: 18 }} />}
@@ -253,7 +236,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
loading={isWorking}
disabled={!shareLinksEnabled}
>
{t('storageShare.generate', 'Generate Link')}
{t("storageShare.generate", "Generate Link")}
</Button>
</Group>
</Stack>
@@ -1,15 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { useTranslation } from 'react-i18next';
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { useTranslation } from "react-i18next";
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import type { StirlingFileStub } from '@app/types/fileContext';
import { uploadHistoryChains } from '@app/services/serverStorageUpload';
import { fileStorage } from '@app/services/fileStorage';
import { useFileActions } from '@app/contexts/FileContext';
import type { FileId } from '@app/types/file';
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import type { StirlingFileStub } from "@app/types/fileContext";
import { uploadHistoryChains } from "@app/services/serverStorageUpload";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
interface BulkUploadToServerModalProps {
opened: boolean;
@@ -18,12 +18,7 @@ interface BulkUploadToServerModalProps {
onUploaded?: () => Promise<void> | void;
}
const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
opened,
onClose,
files,
onUploaded,
}) => {
const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opened, onClose, files, onUploaded }) => {
const { t } = useTranslation();
const { actions } = useFileActions();
const [isUploading, setIsUploading] = useState(false);
@@ -44,18 +39,11 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
setErrorMessage(null);
try {
const rootIds = Array.from(
new Set(files.map((file) => (file.originalFileId || file.id) as FileId))
);
const remoteIds = Array.from(
new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])
);
const rootIds = Array.from(new Set(files.map((file) => (file.originalFileId || file.id) as FileId)));
const remoteIds = Array.from(new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[]));
const existingRemoteId = remoteIds.length === 1 ? remoteIds[0] : undefined;
const { remoteId, updatedAt, chain } = await uploadHistoryChains(
rootIds,
existingRemoteId
);
const { remoteId, updatedAt, chain } = await uploadHistoryChains(rootIds, existingRemoteId);
for (const stub of chain) {
actions.updateStirlingFileStub(stub.id, {
@@ -73,8 +61,8 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
}
alert({
alertType: 'success',
title: t('storageUpload.success', 'Uploaded to server'),
alertType: "success",
title: t("storageUpload.success", "Uploaded to server"),
expandable: false,
durationMs: 3000,
});
@@ -83,10 +71,8 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
}
onClose();
} catch (error) {
console.error('Failed to upload files to server:', error);
setErrorMessage(
t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.')
);
console.error("Failed to upload files to server:", error);
setErrorMessage(t("storageUpload.failure", "Upload failed. Please check your login and storage settings."));
} finally {
setIsUploading(false);
}
@@ -97,48 +83,39 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
opened={opened}
onClose={onClose}
centered
title={t('storageUpload.bulkTitle', 'Upload selected files')}
title={t("storageUpload.bulkTitle", "Upload selected files")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Stack gap="sm">
<Text size="sm">
{t(
'storageUpload.bulkDescription',
'This uploads the selected files to your server storage.'
)}
</Text>
<Text size="sm">{t("storageUpload.bulkDescription", "This uploads the selected files to your server storage.")}</Text>
<Text size="sm" c="dimmed">
{t('storageUpload.fileCount', '{{count}} files selected', {
{t("storageUpload.fileCount", "{{count}} files selected", {
count: files.length,
})}
</Text>
{displayNames.length > 0 && (
<Text size="xs" c="dimmed">
{displayNames.join(', ')}
{displayNames.join(", ")}
{fileNames.length > displayNames.length
? t('storageUpload.more', ' +{{count}} more', {
? t("storageUpload.more", " +{{count}} more", {
count: fileNames.length - displayNames.length,
})
: ''}
: ""}
</Text>
)}
{errorMessage && (
<Alert color="red" title={t('storageUpload.errorTitle', 'Upload failed')}>
<Alert color="red" title={t("storageUpload.errorTitle", "Upload failed")}>
{errorMessage}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={isUploading}>
{t('cancel', 'Cancel')}
{t("cancel", "Cancel")}
</Button>
<Button
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
onClick={handleUpload}
loading={isUploading}
>
{t('storageUpload.uploadButton', 'Upload to Server')}
<Button leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />} onClick={handleUpload} loading={isUploading}>
{t("storageUpload.uploadButton", "Upload to Server")}
</Button>
</Group>
</Stack>
@@ -1,214 +1,175 @@
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';
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>
);
const TestWrapper = ({ children }: { children: React.ReactNode }) => <MantineProvider>{children}</MantineProvider>;
describe('ButtonSelector', () => {
describe("ButtonSelector", () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all options as buttons', () => {
test("should render all options as buttons", () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Test Label"
/>
</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();
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', () => {
test("should highlight selected button with filled variant", () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Selection Label"
/>
</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' });
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();
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', () => {
test("should call onChange when button is clicked", () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} />
</TestWrapper>,
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
expect(mockOnChange).toHaveBeenCalledWith('option2');
expect(mockOnChange).toHaveBeenCalledWith("option2");
});
test('should handle undefined value (no selection)', () => {
test("should handle undefined value (no selection)", () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
render(
<TestWrapper>
<ButtonSelector
value={undefined}
onChange={mockOnChange}
options={options}
/>
</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' });
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');
expect(button1).toHaveAttribute("data-variant", "outline");
expect(button2).toHaveAttribute("data-variant", "outline");
});
test.each([
{
description: 'disable buttons when disabled prop is true',
description: "disable buttons when disabled prop is true",
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
],
globalDisabled: true,
expectedStates: [true, true],
},
{
description: 'disable individual options when option.disabled is true',
description: "disable individual options when option.disabled is true",
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2", disabled: true },
],
globalDisabled: false,
expectedStates: [false, true],
},
])('should $description', ({ options, globalDisabled, expectedStates }) => {
])("should $description", ({ options, globalDisabled, expectedStates }) => {
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
disabled={globalDisabled}
/>
</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]);
const button = screen.getByRole("button", { name: option.label });
expect(button).toHaveProperty("disabled", expectedStates[index]);
});
});
test('should not call onChange when disabled button is clicked', () => {
test("should not call onChange when disabled button is clicked", () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2", disabled: true },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} />
</TestWrapper>,
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
expect(mockOnChange).not.toHaveBeenCalled();
});
test('should not apply fullWidth styling when fullWidth is false', () => {
test("should not apply fullWidth styling when fullWidth is false", () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ 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>
<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();
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', () => {
test("should not render label element when not provided", () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} />
</TestWrapper>,
);
// Should render buttons
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
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
@@ -5,7 +5,7 @@ export interface ButtonOption<T> {
value: T;
label: string;
disabled?: boolean;
tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled)
tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled)
}
interface ButtonSelectorProps<T> {
@@ -30,42 +30,42 @@ const ButtonSelector = <T extends string | number>({
textClassName,
}: ButtonSelectorProps<T>) => {
return (
<Stack gap='var(--mantine-spacing-sm)'>
<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>}
{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'>
<Group gap="4px">
{options.map((option) => {
const isDisabled = disabled || option.disabled;
const button = (
<Button
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
variant={value === option.value ? "filled" : "outline"}
color={value === option.value ? "var(--color-primary-500)" : "var(--text-muted)"}
onClick={() => onChange(option.value)}
disabled={isDisabled}
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'
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}
/>
<FitText text={option.label} lines={1} minimumFontScale={0.5} fontSize={10} className={textClassName} />
</Button>
);
@@ -73,12 +73,16 @@ const ButtonSelector = <T extends string | number>({
if (option.tooltip && isDisabled) {
return (
<Tooltip key={option.value} label={option.tooltip} position="top" withArrow>
<span style={{ flex: fullWidth ? 1 : undefined, display: 'flex' }}>{button}</span>
<span style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}>{button}</span>
</Tooltip>
);
}
return <span key={option.value} style={{ flex: fullWidth ? 1 : undefined, display: 'flex' }}>{button}</span>;
return (
<span key={option.value} style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}>
{button}
</span>
);
})}
</Group>
</Stack>
@@ -1,5 +1,5 @@
import { Button, Stack } from '@mantine/core';
import React from 'react';
import { Button, Stack } from "@mantine/core";
import React from "react";
export interface ButtonToggleOption {
value: string;
@@ -13,8 +13,8 @@ export interface ButtonToggleProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
orientation?: 'vertical' | 'horizontal';
size?: 'xs' | 'sm' | 'md' | 'lg';
orientation?: "vertical" | "horizontal";
size?: "xs" | "sm" | "md" | "lg";
fullWidth?: boolean;
}
@@ -23,18 +23,18 @@ export const ButtonToggle: React.FC<ButtonToggleProps> = ({
value,
onChange,
disabled = false,
orientation = 'vertical',
size = 'md',
orientation = "vertical",
size = "md",
fullWidth = true,
}) => {
const isVertical = orientation === 'vertical';
const isVertical = orientation === "vertical";
const buttonStyle: React.CSSProperties = {
justifyContent: 'flex-start',
height: isVertical ? 'auto' : undefined,
minHeight: isVertical ? '50px' : undefined,
padding: isVertical ? '12px 16px' : undefined,
textAlign: 'left',
justifyContent: "flex-start",
height: isVertical ? "auto" : undefined,
minHeight: isVertical ? "50px" : undefined,
padding: isVertical ? "12px 16px" : undefined,
textAlign: "left",
};
const renderButton = (option: ButtonToggleOption) => {
@@ -44,21 +44,21 @@ export const ButtonToggle: React.FC<ButtonToggleProps> = ({
return (
<Button
key={option.value}
variant={isSelected ? 'filled' : 'outline'}
variant={isSelected ? "filled" : "outline"}
onClick={() => !isDisabled && onChange(option.value)}
disabled={isDisabled}
size={size}
fullWidth={fullWidth}
style={buttonStyle}
>
<div style={{ width: '100%' }}>
<div style={{ width: "100%" }}>
<div style={{ fontWeight: 600 }}>{option.label}</div>
{option.description && (
<div
style={{
fontSize: '0.85em',
fontSize: "0.85em",
opacity: 0.8,
marginTop: '4px',
marginTop: "4px",
fontWeight: 400,
}}
>
@@ -71,16 +71,8 @@ export const ButtonToggle: React.FC<ButtonToggleProps> = ({
};
if (isVertical) {
return (
<Stack gap="xs">
{options.map(renderButton)}
</Stack>
);
return <Stack gap="xs">{options.map(renderButton)}</Stack>;
}
return (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{options.map(renderButton)}
</div>
);
return <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>{options.map(renderButton)}</div>;
};
@@ -1,6 +1,6 @@
import { Stack, Card, Text, Flex } from '@mantine/core';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useTranslation } from 'react-i18next';
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;
@@ -21,7 +21,7 @@ const CardSelector = <T, K extends CardOption<T>>({
options,
onSelect,
disabled = false,
getTooltipContent
getTooltipContent,
}: CardSelectorProps<T, K>) => {
const { t } = useTranslation();
@@ -36,7 +36,7 @@ const CardSelector = <T, K extends CardOption<T>>({
return getTooltipContent(option);
}
if (option.tooltipKey) {
const text = t(option.tooltipKey, '');
const text = t(option.tooltipKey, "");
if (text) return [{ description: text }];
}
return [];
@@ -47,58 +47,47 @@ const CardSelector = <T, K extends CardOption<T>>({
{options.map((option) => {
const tips = getTooltips(option);
return (
<Tooltip
key={option.value as string}
sidebarTooltip
tips={tips}
disabled={tips.length === 0}
>
<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>
);
<Tooltip key={option.value as string} sidebarTooltip tips={tips} disabled={tips.length === 0}>
<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>
);
@@ -1,10 +1,10 @@
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';
import { Z_INDEX_TOAST } from '@app/styles/zIndex';
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";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
interface DismissAllErrorsButtonProps {
className?: string;
@@ -14,19 +14,19 @@ const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ classNa
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
@@ -36,14 +36,14 @@ const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ classNa
leftSection={<CloseIcon fontSize="small" />}
onClick={handleDismissAllErrors}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
position: "absolute",
top: "1rem",
right: "1rem",
zIndex: Z_INDEX_TOAST,
pointerEvents: 'auto'
pointerEvents: "auto",
}}
>
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
{t("error.dismissAllErrors", "Dismiss All Errors")} ({state.ui.errorFileIds.length})
</Button>
</Group>
);
@@ -1,8 +1,8 @@
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';
import { Z_INDEX_AUTOMATE_DROPDOWN } from '@app/styles/zIndex';
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";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
export interface DropdownItem {
value: string;
@@ -15,30 +15,30 @@ 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';
position?: "top" | "bottom" | "left" | "right";
withArrow?: boolean;
width?: 'target' | number;
width?: "target" | number;
withinPortal?: boolean;
zIndex?: number;
}
@@ -47,7 +47,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
value,
onChange,
items,
placeholder = 'Select option',
placeholder = "Select option",
disabled = false,
label,
header,
@@ -55,34 +55,31 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
multiSelect = false,
searchable = false,
maxHeight = 300,
className = '',
dropdownClassName = '',
position = 'bottom',
className = "",
dropdownClassName = "",
position = "bottom",
withArrow = false,
width = 'target',
width = "target",
withinPortal = true,
zIndex = Z_INDEX_AUTOMATE_DROPDOWN
zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [searchTerm, setSearchTerm] = useState("");
const isMultiValue = Array.isArray(value);
const selectedValues = isMultiValue ? value : (value ? [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())
);
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.filter((v) => v !== itemValue)
: [...selectedValues, itemValue];
onChange(newSelection);
} else {
@@ -94,7 +91,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
if (selectedValues.length === 0) {
return placeholder;
} else if (selectedValues.length === 1) {
const selectedItem = items.find(item => item.value === selectedValues[0]);
const selectedItem = items.find((item) => item.value === selectedValues[0]);
return selectedItem?.name || selectedValues[0];
} else {
return `${selectedValues.length} selected`;
@@ -112,125 +109,130 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
{label}
</Text>
)}
<Popover
width={width}
position={position}
withArrow={withArrow}
<Popover
width={width}
position={position}
withArrow={withArrow}
shadow="md"
onClose={() => searchable && setSearchTerm('')}
onClose={() => searchable && setSearchTerm("")}
withinPortal={withinPortal}
zIndex={zIndex}
>
<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))',
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'
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))'
}} />
<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'
}}>
<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'
}}>
<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' }} />}
leftSection={<SearchIcon style={{ fontSize: "1rem" }} />}
size="sm"
style={{ width: '100%' }}
style={{ width: "100%" }}
/>
</Box>
)}
<Box style={{ maxHeight, overflowY: 'auto' }}>
<Box style={{ maxHeight, overflowY: "auto" }}>
{filteredItems.length === 0 ? (
<Box style={{ padding: '12px', textAlign: 'center' }}>
<Box style={{ padding: "12px", textAlign: "center" }}>
<Text size="sm" c="dimmed">
{searchable && searchTerm ? 'No results found' : 'No items available'}
{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>
<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}
/>
)}
<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>
))
)}
</Box>
{footer && (
<Box style={{
borderTop: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingTop: '8px'
}}>
<Box
style={{
borderTop: "light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))",
paddingTop: "8px",
}}
>
{footer}
</Box>
)}
@@ -241,4 +243,4 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
);
};
export default DropdownListWithFooter;
export default DropdownListWithFooter;
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useState, useRef, useEffect } from "react";
import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
interface EditableSecretFieldProps {
label?: string;
@@ -26,16 +26,16 @@ export default function EditableSecretField({
description,
value,
onChange,
placeholder = 'Enter value',
placeholder = "Enter value",
disabled = false,
error,
}: EditableSecretFieldProps) {
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(false);
const [tempValue, setTempValue] = useState('');
const [tempValue, setTempValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const isMasked = value === '********';
const isMasked = value === "********";
useEffect(() => {
if (isEditing && inputRef.current) {
@@ -44,45 +44,34 @@ export default function EditableSecretField({
}, [isEditing]);
const handleEdit = () => {
setTempValue('');
setTempValue("");
setIsEditing(true);
};
const handleCancel = () => {
setTempValue('');
setTempValue("");
setIsEditing(false);
};
const handleSave = () => {
if (tempValue.trim() !== '') {
if (tempValue.trim() !== "") {
onChange(tempValue);
}
setTempValue('');
setTempValue("");
setIsEditing(false);
};
return (
<div>
{label && <label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>{label}</label>}
{description && <p style={{ margin: '4px 0 12px 0', fontSize: '0.75rem', color: '#666' }}>{description}</p>}
{label && <label style={{ display: "block", marginBottom: 4, fontWeight: 500, fontSize: "0.875rem" }}>{label}</label>}
{description && <p style={{ margin: "4px 0 12px 0", fontSize: "0.75rem", color: "#666" }}>{description}</p>}
{isMasked && !isEditing ? (
// Masked value from backend: show display + Edit button
<Group gap="xs" align="flex-end">
<TextInput
value="••••••••"
disabled
style={{ flex: 1 }}
readOnly
/>
<Tooltip label={t('editSecret')} withArrow>
<ActionIcon
variant="light"
onClick={handleEdit}
disabled={disabled}
title="Edit"
aria-label="Edit secret value"
>
<TextInput value="••••••••" disabled style={{ flex: 1 }} readOnly />
<Tooltip label={t("editSecret")} withArrow>
<ActionIcon variant="light" onClick={handleEdit} disabled={disabled} title="Edit" aria-label="Edit secret value">
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
@@ -99,7 +88,7 @@ export default function EditableSecretField({
autoComplete="new-password"
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Escape') handleCancel();
if (e.key === "Escape") handleCancel();
}}
/>
) : (
@@ -1,7 +1,7 @@
import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { type KeyboardEventHandler } from 'react';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { Modal, Stack, Text, Button, PasswordInput, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { type KeyboardEventHandler } from "react";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface EncryptedPdfUnlockModalProps {
opened: boolean;
@@ -27,7 +27,7 @@ const EncryptedPdfUnlockModal = ({
const { t } = useTranslation();
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) {
if (event.key === "Enter" && !isProcessing && password.trim().length > 0) {
onUnlock();
}
};
@@ -36,7 +36,7 @@ const EncryptedPdfUnlockModal = ({
<Modal
opened={opened}
onClose={onSkip}
title={t('encryptedPdfUnlock.title', 'Remove password to continue')}
title={t("encryptedPdfUnlock.title", "Remove password to continue")}
centered
size="md"
closeOnClickOutside={!isProcessing}
@@ -44,18 +44,20 @@ const EncryptedPdfUnlockModal = ({
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="md">
<Text fw={600} ta="center">{fileName}</Text>
<Text fw={600} ta="center">
{fileName}
</Text>
<Text c="dimmed" ta="center">
{t(
'encryptedPdfUnlock.description',
'This PDF is password protected. Enter the password so you can continue working with it.'
"encryptedPdfUnlock.description",
"This PDF is password protected. Enter the password so you can continue working with it.",
)}
</Text>
<Stack gap={4}>
<PasswordInput
label={t('encryptedPdfUnlock.password.label', 'PDF password')}
placeholder={t('encryptedPdfUnlock.password.placeholder', 'Enter the PDF password')}
label={t("encryptedPdfUnlock.password.label", "PDF password")}
placeholder={t("encryptedPdfUnlock.password.placeholder", "Enter the PDF password")}
value={password}
onChange={(event) => onPasswordChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
@@ -71,10 +73,10 @@ const EncryptedPdfUnlockModal = ({
<Group justify="space-between">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
{t('encryptedPdfUnlock.skip', 'Skip for now')}
{t("encryptedPdfUnlock.skip", "Skip for now")}
</Button>
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
{t('encryptedPdfUnlock.unlock', 'Unlock & Continue')}
{t("encryptedPdfUnlock.unlock", "Unlock & Continue")}
</Button>
</Group>
</Stack>
@@ -1,5 +1,5 @@
import React from 'react';
import { Text, Button, Stack } from '@mantine/core';
import React from "react";
import { Text, Button, Stack } from "@mantine/core";
interface ErrorBoundaryState {
hasError: boolean;
@@ -8,7 +8,7 @@ interface ErrorBoundaryState {
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
fallback?: React.ComponentType<{ error?: Error; retry: () => void }>;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
@@ -23,22 +23,22 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Enhanced logging for diagnosis
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.error('🔴 ErrorBoundary caught an error');
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.error('Error:', error);
console.error('Error name:', error.name);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
console.error('Component stack:', errorInfo.componentStack);
console.error('Current URL:', window.location.href);
console.error('Current pathname:', window.location.pathname);
console.error('Current hash:', window.location.hash);
console.error('Current search:', window.location.search);
console.error('Timestamp:', new Date().toISOString());
console.error('User agent:', navigator.userAgent);
console.error("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.error("🔴 ErrorBoundary caught an error");
console.error("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.error("Error:", error);
console.error("Error name:", error.name);
console.error("Error message:", error.message);
console.error("Error stack:", error.stack);
console.error("Component stack:", errorInfo.componentStack);
console.error("Current URL:", window.location.href);
console.error("Current pathname:", window.location.pathname);
console.error("Current hash:", window.location.hash);
console.error("Current search:", window.location.search);
console.error("Timestamp:", new Date().toISOString());
console.error("User agent:", navigator.userAgent);
// Check for React error codes
if (error.message.includes('Minified React error')) {
if (error.message.includes("Minified React error")) {
const errorCodeMatch = error.message.match(/#(\d+)/);
if (errorCodeMatch) {
const errorCode = errorCodeMatch[1];
@@ -48,16 +48,16 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
// Check localStorage for auth state
try {
const jwt = localStorage.getItem('stirling_jwt');
console.error('Auth state:', {
const jwt = localStorage.getItem("stirling_jwt");
console.error("Auth state:", {
hasJWT: !!jwt,
jwtLength: jwt?.length || 0,
});
} catch (e) {
console.error('Could not check localStorage:', e);
console.error("Could not check localStorage:", e);
}
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.error("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
retry = () => {
@@ -72,26 +72,36 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
}
return (
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
<Text size="lg" fw={500} c="red">Something went wrong</Text>
{process.env.NODE_ENV === 'development' && this.state.error && (
<Stack
align="center"
justify="center"
style={{ minHeight: "200px", padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
>
<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', marginTop: '1rem' }}>
<Text size="sm" c="dimmed" style={{ textAlign: "center", fontFamily: "monospace", marginTop: "1rem" }}>
{this.state.error.message}
</Text>
{this.state.error.stack && (
<details style={{ marginTop: '1rem', width: '100%' }}>
<summary style={{ cursor: 'pointer', marginBottom: '0.5rem' }}>
<Text size="sm" component="span">Show stack trace</Text>
<details style={{ marginTop: "1rem", width: "100%" }}>
<summary style={{ cursor: "pointer", marginBottom: "0.5rem" }}>
<Text size="sm" component="span">
Show stack trace
</Text>
</summary>
<pre style={{
fontSize: '0.75rem',
overflow: 'auto',
backgroundColor: '#f5f5f5',
padding: '1rem',
borderRadius: '4px',
maxHeight: '300px'
}}>
<pre
style={{
fontSize: "0.75rem",
overflow: "auto",
backgroundColor: "#f5f5f5",
padding: "1rem",
borderRadius: "4px",
maxHeight: "300px",
}}
>
{this.state.error.stack}
</pre>
</details>
@@ -22,7 +22,17 @@ interface FileCardProps {
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 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);
@@ -30,7 +40,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
const [isHovered, setIsHovered] = useState(false);
// Show loading state during hydration: PDF file without thumbnail yet
const isPdf = file.type === 'application/pdf';
const isPdf = file.type === "application/pdf";
const isHydrating = isPdf && !thumb && !isGenerating;
return (
@@ -44,11 +54,11 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
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,
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%)'
filter: isSupported ? "none" : "grayscale(50%)",
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
@@ -69,22 +79,22 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
position: 'relative'
position: "relative",
}}
>
{/* Hover action buttons */}
{isHovered && (onView || onEdit) && (
<div
style={{
position: 'absolute',
position: "absolute",
top: 4,
right: 4,
display: 'flex',
display: "flex",
gap: 4,
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: 4,
padding: 2
padding: 2,
}}
onClick={(e) => e.stopPropagation()}
>
@@ -121,26 +131,23 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
</div>
)}
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : (isGenerating || isHydrating) ? (
<Image src={thumb} alt="PDF thumbnail" height={110} width={80} fit="contain" radius="sm" />
) : isGenerating || isHydrating ? (
<Stack align="center" justify="center" gap="xs">
<Loader size="sm" />
<Text size="xs" c="dimmed">Loading...</Text>
<Text size="xs" c="dimmed">
Loading...
</Text>
</Stack>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
@@ -151,7 +158,9 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
<Text size="xs" c="dimmed" mt={4}>
Large File
</Text>
)}
</div>
)}
@@ -169,12 +178,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
{getFileDate(file)}
</Badge>
{fileStub?.id && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
<Badge color="green" variant="light" size="sm" leftSection={<StorageIcon style={{ fontSize: 12 }} />}>
DB
</Badge>
)}
@@ -1,12 +1,12 @@
import React from 'react';
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from '@mantine/core';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import CloseIcon from '@mui/icons-material/Close';
import FitText from '@app/components/shared/FitText';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { FileId } from '@app/types/file';
import { truncateCenter } from '@app/utils/textUtils';
import React from "react";
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import CloseIcon from "@mui/icons-material/Close";
import FitText from "@app/components/shared/FitText";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { FileId } from "@app/types/file";
import { truncateCenter } from "@app/utils/textUtils";
interface FileDropdownMenuProps {
displayName: string;
@@ -31,7 +31,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
<div style={{...viewOptionStyle, cursor: 'pointer', maxWidth: '100%'}}>
<div style={{ ...viewOptionStyle, cursor: "pointer", maxWidth: "100%" }}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
@@ -41,22 +41,24 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
<FitText
text={truncateCenter(displayName, 30)}
minimumFontScale={0.6}
style={{ maxWidth: '12rem', display: 'inline-block' }}
style={{ maxWidth: "12rem", display: "inline-block" }}
/>
</PrivateContent>
<KeyboardArrowDownIcon fontSize="small" style={{ flexShrink: 0 }} />
</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'
}}>
<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 itemName = file?.name || "Untitled";
const isActive = index === currentFileIndex;
return (
<Menu.Item
@@ -66,18 +68,18 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
onFileSelect?.(index);
}}
className="viewer-file-tab"
{...(isActive && { 'data-active': true })}
{...(isActive && { "data-active": true })}
style={{
justifyContent: 'flex-start',
justifyContent: "flex-start",
}}
>
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<Group gap="xs" style={{ width: "100%", justifyContent: "space-between" }}>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<PrivateContent>
<FitText
text={truncateCenter(itemName, 50)}
minimumFontScale={0.7}
style={{ display: 'block', width: '100%' }}
style={{ display: "block", width: "100%" }}
/>
</PrivateContent>
</div>
@@ -24,7 +24,7 @@ interface FileGridProps {
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
}
type SortOption = 'date' | 'name' | 'size';
type SortOption = "date" | "name" | "size";
const FileGrid = ({
files,
@@ -40,25 +40,23 @@ const FileGrid = ({
onShowAll,
showingAll = false,
onDeleteAll,
isFileSupported
isFileSupported,
}: FileGridProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<SortOption>('date');
const [sortBy, setSortBy] = useState<SortOption>("date");
// Filter files based on search term
const filteredFiles = files.filter(item =>
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const filteredFiles = files.filter((item) => item.file.name.toLowerCase().includes(searchTerm.toLowerCase()));
// Sort files
const sortedFiles = [...filteredFiles].sort((a, b) => {
switch (sortBy) {
case 'date':
case "date":
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
case 'name':
case "name":
return a.file.name.localeCompare(b.file.name);
case 'size':
case "size":
return (b.file.size || 0) - (a.file.size || 0);
default:
return 0;
@@ -66,14 +64,12 @@ const FileGrid = ({
});
// Apply max display limit if specified
const displayFiles = maxDisplay && !showingAll
? sortedFiles.slice(0, maxDisplay)
: sortedFiles;
const displayFiles = maxDisplay && !showingAll ? sortedFiles.slice(0, maxDisplay) : sortedFiles;
const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay;
return (
<Box >
<Box>
{/* Search and Sort Controls */}
{(showSearch || showSort || onDeleteAll) && (
<Group mb="md" justify="space-between" wrap="wrap" gap="sm">
@@ -91,9 +87,9 @@ const FileGrid = ({
{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: "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)}
@@ -104,11 +100,7 @@ const FileGrid = ({
</Group>
{onDeleteAll && (
<Button
color="red"
size="sm"
onClick={onDeleteAll}
>
<Button color="red" size="sm" onClick={onDeleteAll}>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)}
@@ -116,49 +108,40 @@ const FileGrid = ({
)}
{/* File Grid */}
<Flex
direction="row"
wrap="wrap"
gap="md"
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
>
<Flex direction="row" wrap="wrap" gap="md" h="30rem" style={{ overflowY: "auto", width: "100%" }}>
{displayFiles
.filter(item => {
.filter((item) => {
if (!item.record?.id) {
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
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}
/>
);
})}
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}
>
<Button variant="light" onClick={onShowAll}>
{t("fileManager.showAll", "Show All")} ({sortedFiles.length} files)
</Button>
</Group>
@@ -166,12 +149,11 @@ const FileGrid = ({
{/* Empty State */}
{displayFiles.length === 0 && (
<Box style={{ textAlign: 'center', padding: '2rem' }}>
<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")
}
: t("fileManager.noFiles", "No files available")}
</Text>
</Box>
)}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect } from "react";
import {
Modal,
Text,
@@ -11,12 +11,12 @@ import {
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';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
SimpleGrid,
} from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { useTranslation } from "react-i18next";
import { FileId } from "@app/types/file";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
interface FilePickerModalProps {
opened: boolean;
@@ -25,12 +25,7 @@ interface FilePickerModalProps {
onSelectFiles: (selectedFiles: File[]) => void;
}
const FilePickerModal = ({
opened,
onClose,
storedFiles,
onSelectFiles,
}: FilePickerModalProps) => {
const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePickerModalProps) => {
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
@@ -43,15 +38,13 @@ const FilePickerModal = ({
}, [opened]);
const toggleFileSelection = (fileId: FileId) => {
setSelectedFileIds(prev => {
return prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId];
setSelectedFileIds((prev) => {
return prev.includes(fileId) ? prev.filter((id) => id !== fileId) : [...prev, fileId];
});
};
const selectAll = () => {
setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
setSelectedFileIds(storedFiles.map((f) => f.id).filter(Boolean));
};
const selectNone = () => {
@@ -59,9 +52,7 @@ const FilePickerModal = ({
};
const handleConfirm = async () => {
const selectedFiles = storedFiles.filter(f =>
selectedFileIds.includes(f.id)
);
const selectedFiles = storedFiles.filter((f) => selectedFileIds.includes(f.id));
// Convert stored files to File objects
const convertedFiles = await Promise.all(
@@ -78,31 +69,31 @@ const FilePickerModal = ({
}
// If it's from IndexedDB storage, reconstruct the File
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === "function") {
const arrayBuffer = await fileItem.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
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()
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' });
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()
type: fileItem.type || "application/pdf",
lastModified: fileItem.lastModified || Date.now(),
});
}
console.warn('Could not convert file item:', fileItem);
console.warn("Could not convert file item:", fileItem);
return null;
} catch (error) {
console.error('Error converting file:', error, fileItem);
console.error("Error converting file:", error, fileItem);
return null;
}
})
}),
);
// Filter out any null values and return valid Files
@@ -113,11 +104,11 @@ const FilePickerModal = ({
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
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 parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
return (
@@ -140,9 +131,7 @@ const FilePickerModal = ({
<Group justify="space-between">
<Text size="sm" c="dimmed">
{storedFiles.length} {t("fileUpload.filesAvailable", "files available")}
{selectedFileIds.length > 0 && (
<> {selectedFileIds.length} selected</>
)}
{selectedFileIds.length > 0 && <> {selectedFileIds.length} selected</>}
</Text>
<Group gap="xs">
<Button size="xs" variant="light" onClick={selectAll}>
@@ -166,15 +155,11 @@ const FilePickerModal = ({
key={fileId}
p="sm"
style={{
border: isSelected
? '2px solid var(--mantine-color-blue-6)'
: '1px solid var(--mantine-color-gray-3)',
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'
backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : "transparent",
cursor: "pointer",
transition: "all 0.2s ease",
}}
onClick={() => toggleFileSelection(fileId)}
>
@@ -190,29 +175,19 @@ const FilePickerModal = ({
style={{
width: 60,
height: 80,
border: '1px solid var(--mantine-color-gray-3)',
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-0)',
flexShrink: 0
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"
/>
<Image src={file.thumbnail} alt="PDF thumbnail" height={70} width={50} fit="contain" />
) : (
<ThemeIcon
variant="light"
color="red"
size={40}
>
<ThemeIcon variant="light" color="red" size={40}>
<PictureAsPdfIcon style={{ fontSize: 24 }} />
</ThemeIcon>
)}
@@ -225,7 +200,7 @@ const FilePickerModal = ({
</Text>
<Group gap="xs">
<Badge size="xs" variant="light" color="gray">
{formatFileSize(file.size || (file.file?.size || 0))}
{formatFileSize(file.size || file.file?.size || 0)}
</Badge>
</Group>
</Stack>
@@ -250,14 +225,10 @@ const FilePickerModal = ({
<Button variant="light" onClick={onClose}>
{t("close", "Cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={selectedFileIds.length === 0}
>
<Button onClick={handleConfirm} disabled={selectedFileIds.length === 0}>
{selectedFileIds.length > 0
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${terminology.uploadFiles}`
: t("fileUpload.loadFromStorage", "Load Files")
}
: t("fileUpload.loadFromStorage", "Load Files")}
</Button>
</Group>
</Stack>
@@ -1,26 +1,26 @@
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';
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;
@@ -37,41 +37,38 @@ const FilePreview: React.FC<FilePreviewProps> = ({
isAnimating = false,
onFileClick,
onPrevious,
onNext
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
}}
<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
} : {};
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)}
/>
<DocumentThumbnail file={file} thumbnail={thumbnail} style={animationStyle} onClick={() => onFileClick?.(file)} />
);
// Wrap with hover overlay if needed
@@ -81,31 +78,19 @@ const FilePreview: React.FC<FilePreviewProps> = ({
// Wrap with document stack if needed
if (showStacking) {
content = (
<DocumentStack totalFiles={totalFiles}>
{content}
</DocumentStack>
);
content = <DocumentStack totalFiles={totalFiles}>{content}</DocumentStack>;
}
// Wrap with navigation if needed
if (showNavigation && hasMultipleFiles && onPrevious && onNext) {
content = (
<NavigationArrows
onPrevious={onPrevious}
onNext={onNext}
disabled={isAnimating}
>
<NavigationArrows onPrevious={onPrevious} onNext={onNext} disabled={isAnimating}>
{content}
</NavigationArrows>
);
}
return (
<Box style={{ width: '100%', height: '100%' }}>
{content}
</Box>
);
return <Box style={{ width: "100%", height: "100%" }}>{content}</Box>;
};
export default FilePreview;
export default FilePreview;
@@ -19,24 +19,18 @@ const FileUploadButton = ({
disabled = false,
placeholder,
variant = "outline",
fullWidth = true
fullWidth = true,
}: FileUploadButtonProps) => {
const { t } = useTranslation();
const resetRef = useRef<() => void>(null);
const defaultPlaceholder = t('chooseFile', 'Choose File');
const defaultPlaceholder = t("chooseFile", "Choose File");
return (
<FileButton
resetRef={resetRef}
onChange={onChange}
accept={accept}
disabled={disabled}
>
<FileButton resetRef={resetRef} onChange={onChange} accept={accept} disabled={disabled}>
{(props) => (
<Button {...props} variant={variant} fullWidth={fullWidth} color="blue">
{file ? file.name : (placeholder || defaultPlaceholder)}
{file ? file.name : placeholder || defaultPlaceholder}
</Button>
)}
</FileButton>
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { Modal, Stack, Text, PasswordInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { accountService } from '@app/services/accountService';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { useState } from "react";
import { Modal, Stack, Text, PasswordInput, Button, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { accountService } from "@app/services/accountService";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface FirstLoginModalProps {
opened: boolean;
@@ -20,49 +20,49 @@ interface FirstLoginModalProps {
*/
export default function FirstLoginModal({ opened, onPasswordChanged, username }: FirstLoginModalProps) {
const { t } = useTranslation();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const handleSubmit = async () => {
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
return;
}
if (newPassword !== confirmPassword) {
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
return;
}
if (newPassword.length < 8) {
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters"));
return;
}
if (newPassword === currentPassword) {
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password"));
return;
}
try {
setLoading(true);
setError('');
setError("");
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
alert({
alertType: 'success',
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
alertType: "success",
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
});
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
// Wait a moment for the user to see the success message
// Then the backend will have logged them out, and onPasswordChanged will handle redirect
@@ -70,10 +70,10 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
onPasswordChanged();
}, 1500);
} catch (err: any) {
console.error('Failed to change password:', err);
console.error("Failed to change password:", err);
setError(
err.response?.data?.message ||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."),
);
} finally {
setLoading(false);
@@ -84,7 +84,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
<Modal
opened={opened}
onClose={() => {}} // Cannot close
title={t('firstLogin.title', 'First Time Login')}
title={t("firstLogin.title", "First Time Login")}
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
@@ -95,25 +95,22 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
<Stack gap="md">
<Alert
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
title={t('firstLogin.welcomeTitle', 'Welcome!')}
title={t("firstLogin.welcomeTitle", "Welcome!")}
color="blue"
>
<Text size="sm">
{t(
'firstLogin.welcomeMessage',
'For security reasons, you must change your password on your first login.'
)}
{t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
</Text>
</Alert>
<Text size="sm" fw={500}>
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
</Text>
{error && (
<Alert
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
title={t('firstLogin.error', 'Error')}
title={t("firstLogin.error", "Error")}
color="red"
>
{error}
@@ -121,16 +118,16 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
)}
<PasswordInput
label={t('firstLogin.currentPassword', 'Current Password')}
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
/>
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
label={t("firstLogin.newPassword", "New Password")}
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
@@ -138,8 +135,8 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
/>
<PasswordInput
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
label={t("firstLogin.confirmPassword", "Confirm New Password")}
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
minLength={8}
@@ -150,10 +147,12 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!currentPassword || !newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
disabled={
!currentPassword || !newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8
}
mt="md"
>
{t('firstLogin.changePassword', 'Change Password')}
{t("firstLogin.changePassword", "Change Password")}
</Button>
</Stack>
</Modal>
+15 -17
View File
@@ -1,5 +1,5 @@
import React, { CSSProperties, useMemo, useRef } from 'react';
import { useAdjustFontSizeToFit } from '@app/components/shared/fitText/textFit';
import React, { CSSProperties, useMemo, useRef } from "react";
import { useAdjustFontSizeToFit } from "@app/components/shared/fitText/textFit";
type FitTextProps = {
text: string;
@@ -8,7 +8,7 @@ type FitTextProps = {
lines?: number; // max lines
className?: string;
style?: CSSProperties;
as?: 'span' | 'div';
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.
@@ -23,8 +23,8 @@ const FitText: React.FC<FitTextProps> = ({
lines = 1,
className,
style,
as = 'span',
softBreakChars = ['-','_','/'],
as = "span",
softBreakChars = ["-", "_", "/"],
}) => {
const ref = useRef<HTMLElement | null>(null);
@@ -45,23 +45,23 @@ const FitText: React.FC<FitTextProps> = ({
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');
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: 'hidden',
textOverflow: 'ellipsis',
display: lines > 1 ? '-webkit-box' : undefined,
WebkitBoxOrient: lines > 1 ? 'vertical' : undefined,
whiteSpace: lines === 1 ? "nowrap" : "normal",
overflow: "hidden",
textOverflow: "ellipsis",
display: lines > 1 ? "-webkit-box" : undefined,
WebkitBoxOrient: lines > 1 ? "vertical" : undefined,
WebkitLineClamp: lines > 1 ? lines : undefined,
// Favor breaking words when necessary to prevent overflow
wordBreak: lines > 1 ? 'break-word' : 'normal',
overflowWrap: lines > 1 ? 'break-word' : 'normal',
hyphens: lines > 1 ? 'auto' : 'manual',
wordBreak: lines > 1 ? "break-word" : "normal",
overflowWrap: lines > 1 ? "break-word" : "normal",
hyphens: lines > 1 ? "auto" : "manual",
// fontSize expects rem values (e.g., 1.2, 0.9) to scale with global font size
fontSize: fontSize ? `${fontSize}rem` : undefined,
};
@@ -74,5 +74,3 @@ const FitText: React.FC<FitTextProps> = ({
};
export default FitText;
+68 -98
View File
@@ -1,7 +1,7 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useFooterInfo } from '@app/hooks/useFooterInfo';
import { Flex } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
import { useFooterInfo } from "@app/hooks/useFooterInfo";
interface FooterProps {
privacyPolicy?: string;
@@ -20,7 +20,7 @@ export default function Footer({
cookiePolicy,
impressum,
analyticsEnabled,
forceLightMode = false
forceLightMode = false,
}: FooterProps) {
const { t } = useTranslation();
const { footerInfo } = useFooterInfo();
@@ -49,101 +49,71 @@ export default function Footer({
const isValidLink = (link?: string) => link && link.trim().length > 0;
return (
<div style={{
height: 'var(--footer-height)',
backgroundColor: forceLightMode ? '#f1f3f5' : 'var(--mantine-color-gray-1)',
borderTop: forceLightMode ? '1px solid #e9ecef' : '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',
color: forceLightMode ? '#495057' : undefined
}}>
<a
className="footer-link px-3"
id="survey"
target="_blank"
rel="noopener noreferrer"
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
>
{t('survey.nav', 'Survey')}
<div
style={{
height: "var(--footer-height)",
backgroundColor: forceLightMode ? "#f1f3f5" : "var(--mantine-color-gray-1)",
borderTop: forceLightMode ? "1px solid #e9ecef" : "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",
color: forceLightMode ? "#495057" : undefined,
}}
>
<a
className="footer-link px-3"
id="survey"
target="_blank"
rel="noopener noreferrer"
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
>
{t("survey.nav", "Survey")}
</a>
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href={finalPrivacyUrl}>
{t("legal.privacy", "Privacy Policy")}
</a>
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href={finalTermsUrl}>
{t("legal.terms", "Terms and Conditions")}
</a>
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href="https://discord.gg/Cn8pWhQRxZ">
{t("footer.discord", "Discord")}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Stirling-Tools/Stirling-PDF"
>
{t("footer.issues", "GitHub")}
</a>
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href={finalAccessibilityUrl}>
{t("legal.accessibility", "Accessibility")}
</a>
{isValidLink(finalCookiePolicy) && (
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href={finalCookiePolicy}>
{t("legal.cookie", "Cookie Policy")}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalPrivacyUrl}
>
{t('legal.privacy', 'Privacy Policy')}
)}
{isValidLink(finalImpressum) && (
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href={finalImpressum}>
{t("legal.impressum", "Impressum")}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalTermsUrl}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://discord.gg/Cn8pWhQRxZ"
>
{t('footer.discord', 'Discord')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Stirling-Tools/Stirling-PDF"
>
{t('footer.issues', 'GitHub')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalAccessibilityUrl}
>
{t('legal.accessibility', 'Accessibility')}
</a>
{isValidLink(finalCookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalCookiePolicy}
>
{t('legal.cookie', 'Cookie Policy')}
</a>
)}
{isValidLink(finalImpressum) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalImpressum}
>
{t('legal.impressum', 'Impressum')}
</a>
)}
{finalAnalyticsEnabled && (
<button
className="footer-link px-3"
id="cookieBanner"
onClick={showCookiePreferences}
>
{t('legal.showCookieBanner', 'Cookie Preferences')}
</button>
)}
</Flex>
)}
{finalAnalyticsEnabled && (
<button className="footer-link px-3" id="cookieBanner" onClick={showCookiePreferences}>
{t("legal.showCookieBanner", "Cookie Preferences")}
</button>
)}
</Flex>
</div>
);
}
@@ -1,7 +1,7 @@
import React from 'react';
import { ActionIcon, Tooltip } from '@mantine/core';
import styles from '@app/components/shared/HoverActionMenu.module.css';
import { Z_INDEX_HOVER_ACTION_MENU } from '@app/styles/zIndex';
import React from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import styles from "@app/components/shared/HoverActionMenu.module.css";
import { Z_INDEX_HOVER_ACTION_MENU } from "@app/styles/zIndex";
export interface HoverAction {
id: string;
@@ -16,17 +16,12 @@ export interface HoverAction {
interface HoverActionMenuProps {
show: boolean;
actions: HoverAction[];
position?: 'inside' | 'outside';
position?: "inside" | "outside";
className?: string;
}
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
show,
actions,
position = 'inside',
className = ''
}) => {
const visibleActions = actions.filter(action => !action.hidden);
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ show, actions, position = "inside", className = "" }) => {
const visibleActions = actions.filter((action) => !action.hidden);
if (visibleActions.length === 0) {
return null;
@@ -34,7 +29,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
return (
<div
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
className={`${styles.hoverMenu} ${position === "outside" ? styles.outside : styles.inside} ${className}`}
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
@@ -48,7 +43,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
disabled={action.disabled}
onClick={action.onClick}
c={action.color}
style={{ color: action.color || 'var(--right-rail-icon)' }}
style={{ color: action.color || "var(--right-rail-icon)" }}
>
{action.icon}
</ActionIcon>
@@ -1,8 +1,8 @@
import React, { ReactNode } from 'react';
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import React, { ReactNode } from "react";
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = 'info' | 'warning';
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
@@ -15,18 +15,18 @@ const toneStyles: Record<
}
> = {
info: {
background: 'var(--mantine-color-blue-0)',
border: 'var(--mantine-color-blue-2)',
text: 'var(--mantine-color-blue-9)',
icon: 'var(--mantine-color-blue-6)',
buttonColor: 'blue',
background: "var(--mantine-color-blue-0)",
border: "var(--mantine-color-blue-2)",
text: "var(--mantine-color-blue-9)",
icon: "var(--mantine-color-blue-6)",
buttonColor: "blue",
},
warning: {
background: 'var(--mantine-color-orange-0)',
border: 'var(--mantine-color-orange-3)',
text: 'var(--mantine-color-orange-9)',
icon: 'var(--mantine-color-orange-7)',
buttonColor: 'orange',
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--mantine-color-orange-9)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
};
@@ -47,7 +47,7 @@ interface InfoBannerProps {
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
minHeight?: number | string;
closeIconColor?: string;
}
@@ -60,19 +60,19 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
title,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
buttonIcon = "check-circle-rounded",
onButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = 'info',
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = 'light',
buttonVariant = "light",
minHeight = 56,
closeIconColor,
}) => {
@@ -93,11 +93,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
background: background ?? toneStyle.background,
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
}}
>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: "100%" }}>
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<LocalIcon
icon={icon}
@@ -111,12 +111,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size="sm"
style={{ color: textColor ?? toneStyle.text }}
lineClamp={2}
>
<Text fw={title ? 400 : 500} size="sm" style={{ color: textColor ?? toneStyle.text }} lineClamp={2}>
{message}
</Text>
</Stack>
@@ -137,7 +132,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
{dismissible && (
<ActionIcon
variant="subtle"
color={closeIconColor ? undefined : 'gray'}
color={closeIconColor ? undefined : "gray"}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
@@ -1,11 +1,11 @@
import React from 'react';
import { Button, Group, Tooltip, ActionIcon } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
import React from "react";
import { Button, Group, Tooltip, ActionIcon } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
type LandingActionsProps = {
fileInputRef: React.RefObject<HTMLInputElement | null>;
@@ -25,18 +25,24 @@ export function LandingActions({ fileInputRef, onUploadClick, onMobileUploadClic
<>
<Group gap="sm" justify="center" wrap="wrap" mb="xs">
<Button
classNames={{ root: 'landing-btn-primary' }}
leftSection={<LocalIcon icon={icons.uploadIconName} width="1rem" height="1rem" style={{ color: 'white' }} />}
onClick={(e) => { e.stopPropagation(); onUploadClick(); }}
classNames={{ root: "landing-btn-primary" }}
leftSection={<LocalIcon icon={icons.uploadIconName} width="1rem" height="1rem" style={{ color: "white" }} />}
onClick={(e) => {
e.stopPropagation();
onUploadClick();
}}
>
{terminology.uploadFromComputer}
</Button>
<Button
variant="default"
classNames={{ root: 'landing-btn-secondary' }}
classNames={{ root: "landing-btn-secondary" }}
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" className="text-[var(--accent-interactive)]" />}
onClick={(e) => { e.stopPropagation(); openFilesModal(); }}
onClick={(e) => {
e.stopPropagation();
openFilesModal();
}}
>
{terminology.addFiles}
</Button>
@@ -48,15 +54,18 @@ export function LandingActions({ fileInputRef, onUploadClick, onMobileUploadClic
variant="default"
radius="md"
aria-label={terminology.mobileUpload}
classNames={{ root: 'landing-btn-secondary landing-btn-icon' }}
onClick={(e) => { e.stopPropagation(); onMobileUploadClick(); }}
classNames={{ root: "landing-btn-secondary landing-btn-icon" }}
onClick={(e) => {
e.stopPropagation();
onMobileUploadClick();
}}
>
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Tooltip>
)}
</Group>
<input ref={fileInputRef} type="file" multiple onChange={onFileSelect} style={{ display: 'none' }} />
<input ref={fileInputRef} type="file" multiple onChange={onFileSelect} style={{ display: "none" }} />
</>
);
}
@@ -19,9 +19,9 @@ export function LandingDocumentStack() {
<div className="landing-sheet landing-sheet--front">
<div className="landing-sheet-header">
<div className="landing-sheet-dot" style={{ backgroundColor: 'rgba(255,255,255,0.35)' }} />
<div className="landing-sheet-dot" style={{ backgroundColor: 'rgba(255,255,255,0.25)' }} />
<div className="landing-sheet-dot" style={{ backgroundColor: 'rgba(255,255,255,0.15)' }} />
<div className="landing-sheet-dot" style={{ backgroundColor: "rgba(255,255,255,0.35)" }} />
<div className="landing-sheet-dot" style={{ backgroundColor: "rgba(255,255,255,0.25)" }} />
<div className="landing-sheet-dot" style={{ backgroundColor: "rgba(255,255,255,0.15)" }} />
</div>
<div className="landing-sheet-body">
<div className="landing-bar landing-bar--strong" style={bar(100, 10, 10)} />
@@ -1,14 +1,14 @@
import React, { useState } from 'react';
import { Container } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
import { LandingDocumentStack } from '@app/components/shared/LandingDocumentStack';
import { LandingActions } from '@app/components/shared/LandingActions';
import '@app/components/shared/LandingPage.css';
import React, { useState } from "react";
import { Container } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
import { LandingActions } from "@app/components/shared/LandingActions";
import "@app/components/shared/LandingPage.css";
const LandingPage = () => {
const { t } = useTranslation();
@@ -36,7 +36,7 @@ const LandingPage = () => {
if (files.length > 0) {
await addFiles(files);
}
event.target.value = '';
event.target.value = "";
};
const handleFilesReceivedFromMobile = async (files: File[]) => {
@@ -46,7 +46,7 @@ const LandingPage = () => {
};
return (
<Container size="70rem" p={0} h="100%" className="flex min-h-0 flex-col" style={{ position: 'relative' }}>
<Container size="70rem" p={0} h="100%" className="flex min-h-0 flex-col" style={{ position: "relative" }}>
<Dropzone
onDrop={handleFileDrop}
multiple
@@ -56,19 +56,19 @@ const LandingPage = () => {
className="flex min-h-0 flex-1 cursor-default flex-col items-center justify-center border-none bg-transparent px-4 py-8 shadow-none outline-none"
styles={{
root: {
border: 'none !important',
backgroundColor: 'transparent',
overflow: 'visible',
'&[data-accept]': { outline: '2px dashed var(--accent-interactive)', outlineOffset: 4 },
'&[data-reject]': { outline: '2px dashed var(--mantine-color-red-6)', outlineOffset: 4 },
border: "none !important",
backgroundColor: "transparent",
overflow: "visible",
"&[data-accept]": { outline: "2px dashed var(--accent-interactive)", outlineOffset: 4 },
"&[data-reject]": { outline: "2px dashed var(--mantine-color-red-6)", outlineOffset: 4 },
},
inner: { overflow: 'visible', display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' },
inner: { overflow: "visible", display: "flex", flexDirection: "column", alignItems: "center", width: "100%" },
}}
>
<LandingDocumentStack />
<h1 className="landing-title">{t('landing.heroTitle', 'Stirling PDF')}</h1>
<p className="landing-subtitle">{t('landing.heroSubtitle', 'Drop in or add an existing PDF to get started.')}</p>
<h1 className="landing-title">{t("landing.heroTitle", "Stirling PDF")}</h1>
<p className="landing-subtitle">{t("landing.heroSubtitle", "Drop in or add an existing PDF to get started.")}</p>
<LandingActions
fileInputRef={fileInputRef}
@@ -18,11 +18,11 @@
.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;
}
@@ -32,11 +32,11 @@
.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;
}
@@ -85,4 +85,4 @@
height: 100px;
opacity: 0;
}
}
}
@@ -1,15 +1,15 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ActionIcon } from '@mantine/core';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useTranslation } from 'react-i18next';
import { supportedLanguages, setUserLanguage } from '@app/i18n';
import LocalIcon from '@app/components/shared/LocalIcon';
import styles from '@app/components/shared/LanguageSelector.module.css';
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
import React, { useState, useEffect } from "react";
import { Menu, Button, ActionIcon } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import { useTranslation } from "react-i18next";
import { supportedLanguages, setUserLanguage } from "@app/i18n";
import LocalIcon from "@app/components/shared/LocalIcon";
import styles from "@app/components/shared/LanguageSelector.module.css";
import { Z_INDEX_CONFIG_MODAL } from "@app/styles/zIndex";
// Types
interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position'];
position?: React.ComponentProps<typeof Menu>["position"];
offset?: number;
compact?: boolean; // icon-only trigger
tooltip?: string; // tooltip text for compact mode
@@ -48,12 +48,12 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
rippleEffect,
pendingLanguage,
compact,
disabled = false
disabled = false,
}) => {
const { t } = useTranslation();
const labelText = option.label;
const comingSoonText = t('comingSoon', 'Coming soon');
const comingSoonText = t("comingSoon", "Coming soon");
const label = disabled ? (
<Tooltip content={comingSoonText} position="left" arrow>
@@ -68,7 +68,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
className={styles.languageItem}
style={{
opacity: animationTriggered ? 1 : 0,
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
transform: animationTriggered ? "translateY(0px)" : "translateY(8px)",
transition: `opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s, transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s`,
}}
>
@@ -81,40 +81,42 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
disabled={disabled}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
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',
? "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))'
? "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.12s 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)',
} : {}
? "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.12s 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',
fontSize: "13px",
fontWeight: isSelected ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
position: "relative",
zIndex: 2,
}
},
}}
>
{label}
@@ -122,16 +124,16 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
<div
key={rippleEffect.key}
style={{
position: 'absolute',
position: "absolute",
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-blue-4)',
borderRadius: "50%",
backgroundColor: "var(--mantine-color-blue-4)",
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
transform: "translate(-50%, -50%)",
animation: "ripple-expand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
zIndex: 1,
}}
/>
@@ -155,10 +157,10 @@ const RippleStyles: React.FC = () => (
// Main component
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
position = 'bottom-start',
position = "bottom-start",
offset = 8,
compact = false,
tooltip
tooltip,
}) => {
const { i18n, ready } = useTranslation();
const [opened, setOpened] = useState(false);
@@ -183,8 +185,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
// Get the filtered list of supported languages from i18n
// This respects server config (ui.languages) applied by AppConfigLoader
const allowedLanguages = (i18n.options.supportedLngs as string[] || [])
.filter(lang => lang !== 'cimode'); // Exclude i18next debug language
const allowedLanguages = ((i18n.options.supportedLngs as string[]) || []).filter((lang) => lang !== "cimode"); // Exclude i18next debug language
const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
.filter(([code]) => allowedLanguages.length === 0 || allowedLanguages.includes(code))
@@ -196,13 +197,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
// Calculate dropdown width and grid columns based on number of languages
// 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols
const dropdownWidth = languageOptions.length <= 4 ? 300
: languageOptions.length <= 9 ? 400
: 600;
const dropdownWidth = languageOptions.length <= 4 ? 300 : languageOptions.length <= 9 ? 400 : 600;
const gridColumns = languageOptions.length <= 4 ? 2
: languageOptions.length <= 9 ? 3
: 4;
const gridColumns = languageOptions.length <= 4 ? 2 : languageOptions.length <= 9 ? 3 : 4;
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
// Create ripple effect at click position (only for button mode)
@@ -229,16 +226,15 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
setTimeout(() => setRippleEffect(null), 50);
// Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
window.location.reload();
}
}, 150);
}, 100);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'] ||
'English'; // Fallback if supportedLanguages lookup fails
const currentLanguage =
supportedLanguages[i18n.language as keyof typeof supportedLanguages] || supportedLanguages["en-GB"] || "English"; // Fallback if supportedLanguages lookup fails
// Hide the language selector if there's only one language option
// (no point showing a selector when there's nothing to select)
@@ -258,9 +254,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
zIndex={Z_INDEX_CONFIG_MODAL}
withinPortal
transitionProps={{
transition: 'scale-y',
transition: "scale-y",
duration: 120,
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
timingFunction: "cubic-bezier(0.25, 0.46, 0.45, 0.94)",
}}
>
<Menu.Target>
@@ -272,11 +268,11 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
title={!opened && tooltip ? tooltip : undefined}
styles={{
root: {
color: 'var(--right-rail-icon)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
}
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" />
@@ -288,50 +284,45 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
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))',
}
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 }
label: { fontSize: "12px", fontWeight: 500 },
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
<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))',
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))",
}}
>
<div
className={styles.languageGrid}
style={{ gridTemplateColumns: `repeat(${gridColumns}, 1fr)` }}
>
{languageOptions.map((option, index) => (
<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={false}
/>
))}
<div className={styles.languageGrid} style={{ gridTemplateColumns: `repeat(${gridColumns}, 1fr)` }}>
{languageOptions.map((option, index) => (
<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={false}
/>
))}
</div>
</Menu.Dropdown>
</Menu>
@@ -1,6 +1,6 @@
import React from 'react';
import { addCollection, Icon } from '@iconify/react';
import iconSet from '../../../assets/material-symbols-icons.json'; // eslint-disable-line no-restricted-imports -- Outside app paths
import React from "react";
import { addCollection, Icon } from "@iconify/react";
import iconSet from "../../../assets/material-symbols-icons.json"; // eslint-disable-line no-restricted-imports -- Outside app paths
// Load icons synchronously at import time - guaranteed to be ready on first render
let iconsLoaded = false;
@@ -13,7 +13,7 @@ try {
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');
console.info("️ Local icons not available - using CDN fallback");
}
interface LocalIconProps {
@@ -30,17 +30,15 @@ interface LocalIconProps {
*/
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, width, height, style, ...props }) => {
// Convert our icon naming convention to the local collection format
const iconName = icon.startsWith('material-symbols:')
? icon
: `material-symbols:${icon}`;
const iconName = icon.startsWith("material-symbols:") ? icon : `material-symbols:${icon}`;
// Development logging (only in dev mode)
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === "development") {
const logKey = `icon-${iconName}`;
if (!sessionStorage.getItem(logKey)) {
const source = iconsLoaded ? 'local' : 'CDN';
const source = iconsLoaded ? "local" : "CDN";
console.debug(`🎯 Icon: ${iconName} (${source})`);
sessionStorage.setItem(logKey, 'logged');
sessionStorage.setItem(logKey, "logged");
}
}
@@ -48,10 +46,10 @@ export const LocalIcon: React.FC<LocalIconProps> = ({ icon, width, height, style
// Use width if provided, otherwise fall back to height
const size = width || height;
if (size && typeof size === 'string') {
if (size && typeof size === "string") {
// If it's a CSS unit string (like '1.5rem'), use it as fontSize
iconStyle.fontSize = size;
} else if (typeof size === 'number') {
} else if (typeof size === "number") {
// If it's a number, treat it as pixels
iconStyle.fontSize = `${size}px`;
}
@@ -1,16 +1,16 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core';
import { QRCodeSVG } from 'qrcode.react';
import { useTranslation } from 'react-i18next';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { withBasePath } from '@app/constants/app';
import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils';
import apiClient from '@app/services/apiClient';
import { useEffect, useCallback, useState, useRef } from "react";
import { Modal, Stack, Text, Badge, Box, Alert } from "@mantine/core";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import InfoRoundedIcon from "@mui/icons-material/InfoRounded";
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { withBasePath } from "@app/constants/app";
import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
import apiClient from "@app/services/apiClient";
interface MobileUploadModalProps {
opened: boolean;
@@ -21,9 +21,9 @@ interface MobileUploadModalProps {
// Generate a cryptographically secure UUID v4-like session ID
function generateSessionId(): string {
// Use Web Crypto API for cryptographically secure random values
const cryptoObj = typeof crypto !== 'undefined' ? crypto : (window as any).crypto;
const cryptoObj = typeof crypto !== "undefined" ? crypto : (window as any).crypto;
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
@@ -32,19 +32,19 @@ function generateSessionId(): string {
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
// Convert bytes to hex string in UUID format
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join(''),
].join('-');
hex.slice(0, 4).join(""),
hex.slice(4, 6).join(""),
hex.slice(6, 8).join(""),
hex.slice(8, 10).join(""),
hex.slice(10, 16).join(""),
].join("-");
}
// If Web Crypto is not available, fail fast rather than using insecure randomness
console.error('Web Crypto API not available. Cannot generate secure session ID.');
throw new Error('Web Crypto API not available. Cannot generate secure session ID.');
console.error("Web Crypto API not available. Cannot generate secure session ID.");
throw new Error("Web Crypto API not available. Cannot generate secure session ID.");
}
interface SessionInfo {
@@ -76,30 +76,37 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
// Use configured frontendUrl if set, otherwise use current origin
// Combine with base path and mobile-scanner route
const baseUrl = localStorage.getItem('server_url') || '';
const baseUrl = localStorage.getItem("server_url") || "";
const frontendUrl = baseUrl || config?.frontendUrl || window.location.origin;
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
const mobileUrl = `${frontendUrl}${withBasePath("/mobile-scanner")}?session=${sessionId}`;
// Create session on backend
const createSession = useCallback(async (newSessionId: string) => {
try {
const response = await apiClient.post<SessionInfo>(`/api/v1/mobile-scanner/create-session/${newSessionId}`, undefined, {
responseType: 'json',
});
const createSession = useCallback(
async (newSessionId: string) => {
try {
const response = await apiClient.post<SessionInfo>(
`/api/v1/mobile-scanner/create-session/${newSessionId}`,
undefined,
{
responseType: "json",
},
);
if (!response.status || response.status !== 200) {
throw new Error('Failed to create session');
if (!response.status || response.status !== 200) {
throw new Error("Failed to create session");
}
const data = response.data;
setSessionInfo(data);
setError(null);
console.log("[MobileUploadModal] Session created:", data);
} catch (err) {
console.error("[MobileUploadModal] Failed to create session:", err);
setError(t("mobileUpload.sessionCreateError", "Failed to create session"));
}
const data = response.data;
setSessionInfo(data);
setError(null);
console.log('[MobileUploadModal] Session created:', data);
} catch (err) {
console.error('[MobileUploadModal] Failed to create session:', err);
setError(t('mobileUpload.sessionCreateError', 'Failed to create session'));
}
}, [t]);
},
[t],
);
// Regenerate session (when expired or warned)
const regenerateSession = useCallback(() => {
@@ -117,7 +124,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
try {
const response = await apiClient.get(`/api/v1/mobile-scanner/files/${sessionId}`);
if (!response.status || response.status !== 200) {
throw new Error('Failed to check for files');
throw new Error("Failed to check for files");
}
const data = response.data;
@@ -130,28 +137,29 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
for (const fileMetadata of newFiles) {
try {
const downloadResponse = await apiClient.get(
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, {
responseType: 'blob',
}
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`,
{
responseType: "blob",
},
);
if (downloadResponse.status === 200) {
const blob = downloadResponse.data;
let file = new File([blob], fileMetadata.filename, {
type: fileMetadata.contentType || 'image/jpeg'
type: fileMetadata.contentType || "image/jpeg",
});
// Convert images to PDF if enabled
if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) {
try {
file = await convertImageToPdf(file, {
imageResolution: config?.mobileScannerImageResolution as 'full' | 'reduced' | undefined,
pageFormat: config?.mobileScannerPageFormat as 'keep' | 'A4' | 'letter' | undefined,
imageResolution: config?.mobileScannerImageResolution as "full" | "reduced" | undefined,
pageFormat: config?.mobileScannerPageFormat as "keep" | "A4" | "letter" | undefined,
stretchToFit: config?.mobileScannerStretchToFit,
});
console.log('[MobileUploadModal] Converted image to PDF:', file.name);
console.log("[MobileUploadModal] Converted image to PDF:", file.name);
} catch (convertError) {
console.warn('[MobileUploadModal] Failed to convert image to PDF, using original file:', convertError);
console.warn("[MobileUploadModal] Failed to convert image to PDF, using original file:", convertError);
// Continue with original image file if conversion fails
}
}
@@ -161,7 +169,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
onFilesReceived([file]);
}
} catch (err) {
console.error('[MobileUploadModal] Failed to download file:', fileMetadata.filename, err);
console.error("[MobileUploadModal] Failed to download file:", fileMetadata.filename, err);
}
}
@@ -169,14 +177,14 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
// This ensures files are only on server for ~1 second
try {
await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`);
console.log('[MobileUploadModal] Session cleaned up after file download');
console.log("[MobileUploadModal] Session cleaned up after file download");
} catch (cleanupErr) {
console.warn('[MobileUploadModal] Failed to cleanup session after download:', cleanupErr);
console.warn("[MobileUploadModal] Failed to cleanup session after download:", cleanupErr);
}
}
} catch (err) {
console.error('[MobileUploadModal] Error polling for files:', err);
setError(t('mobileUpload.pollingError', 'Error checking for files'));
console.error("[MobileUploadModal] Error polling for files:", err);
setError(t("mobileUpload.pollingError", "Error checking for files"));
}
}, [opened, sessionId, onFilesReceived, t]);
@@ -201,9 +209,10 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
processedFiles.current.clear();
return () => {
console.log('Cleaning up session on unmount/close:', sessionId);
apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`)
.catch(err => console.warn('[MobileUploadModal] Cleanup failed:', err));
console.log("Cleaning up session on unmount/close:", sessionId);
apiClient
.delete(`/api/v1/mobile-scanner/session/${sessionId}`)
.catch((err) => console.warn("[MobileUploadModal] Cleanup failed:", err));
};
}, [opened, sessionId, createSession]);
@@ -267,7 +276,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
<Modal
opened={opened}
onClose={onClose}
title={t('mobileUpload.title', 'Upload from Mobile')}
title={t("mobileUpload.title", "Upload from Mobile")}
centered
size="md"
radius="lg"
@@ -275,40 +284,33 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
overlayProps={{ opacity: 0.35, blur: 2 }}
styles={{
body: {
paddingTop: '1.5rem',
paddingTop: "1.5rem",
},
}}
>
<Stack gap="md">
<Alert
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
color="blue"
variant="light"
>
<Alert icon={<InfoRoundedIcon style={{ fontSize: "1rem" }} />} color="blue" variant="light">
<Text size="sm">
{config?.mobileScannerConvertToPdf !== false
? t(
'mobileUpload.description',
'Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.'
"mobileUpload.description",
"Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.",
)
: t(
'mobileUpload.descriptionNoConvert',
'Scan this QR code with your mobile device to upload photos.'
)}
: t("mobileUpload.descriptionNoConvert", "Scan this QR code with your mobile device to upload photos.")}
</Text>
</Alert>
{showExpiryWarning && timeRemaining !== null && (
<Alert
icon={<WarningRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')}
icon={<WarningRoundedIcon style={{ fontSize: "1rem" }} />}
title={t("mobileUpload.expiryWarning", "Session Expiring Soon")}
color="orange"
>
<Text size="sm">
{t(
'mobileUpload.expiryWarningMessage',
'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.',
{ seconds: Math.ceil(timeRemaining / 1000) }
"mobileUpload.expiryWarningMessage",
"This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.",
{ seconds: Math.ceil(timeRemaining / 1000) },
)}
</Text>
</Alert>
@@ -316,41 +318,41 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
{error && (
<Alert
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.error', 'Connection Error')}
icon={<ErrorRoundedIcon style={{ fontSize: "1rem" }} />}
title={t("mobileUpload.error", "Connection Error")}
color="red"
>
<Text size="sm">{error}</Text>
</Alert>
)}
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Box style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "1rem" }}>
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
padding: "1.5rem",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
</Box>
{filesReceived > 0 && (
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: "1rem" }} />}>
{t("mobileUpload.filesReceived", "{{count}} file(s) received", { count: filesReceived })}
</Badge>
)}
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: "300px" }}>
{config?.mobileScannerConvertToPdf !== false
? t(
'mobileUpload.instructions',
'Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.'
"mobileUpload.instructions",
"Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.",
)
: t(
'mobileUpload.instructionsNoConvert',
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
"mobileUpload.instructionsNoConvert",
"Open the camera app on your phone and scan this code. Files will be uploaded through the server.",
)}
</Text>
@@ -358,9 +360,9 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
size="xs"
c="dimmed"
style={{
wordBreak: 'break-all',
textAlign: 'center',
fontFamily: 'monospace',
wordBreak: "break-all",
textAlign: "center",
fontFamily: "monospace",
}}
>
{mobileUrl}
@@ -16,65 +16,43 @@ const MultiSelectControls = ({
onOpenInFileEditor,
onOpenInPageEditor,
onAddToUpload,
onDeleteAll
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 }}>
<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}
>
<Button size="xs" variant="light" onClick={onClearSelection}>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
{onAddToUpload && (
<Button
size="xs"
color="green"
onClick={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}
>
<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}
>
<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}
>
<Button size="xs" color="red" onClick={onDeleteAll}>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)}
@@ -86,33 +86,55 @@ const NavigationWarningModal = () => {
zIndex={Z_INDEX_TOAST}
>
<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 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" />}>
<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" />}>
<Button
variant="filled"
color="var(--mantine-color-red-9)"
onClick={handleDiscardChanges}
w={BUTTON_WIDTH}
leftSection={<DeleteOutlineIcon fontSize="small" />}
>
{t("discardChanges", "Discard Changes")}
</Button>
{hasApply && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
<Button
variant="filled"
onClick={handleApplyAndContinue}
w={BUTTON_WIDTH}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
{hasExport && (
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
<Button
variant="filled"
onClick={handleExportAndContinue}
w={BUTTON_WIDTH}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
@@ -121,19 +143,41 @@ const NavigationWarningModal = () => {
{/* 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" />}>
<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" />}>
<Button
variant="filled"
color="var(--mantine-color-red-9)"
onClick={handleDiscardChanges}
w={BUTTON_WIDTH}
leftSection={<DeleteOutlineIcon fontSize="small" />}
>
{t("discardChanges", "Discard Changes")}
</Button>
{hasApply && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
<Button
variant="filled"
onClick={handleApplyAndContinue}
w={BUTTON_WIDTH}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
{hasExport && (
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
<Button
variant="filled"
onClick={handleExportAndContinue}
w={BUTTON_WIDTH}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
@@ -1,5 +1,5 @@
import React from 'react';
import styles from '@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css';
import React from "react";
import styles from "@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css";
type ObscuredOverlayProps = {
obscured: boolean;
@@ -30,11 +30,7 @@ export default function ObscuredOverlay({
}}
>
<div className={styles.overlayContent}>
{overlayMessage && (
<div className={styles.overlayMessage}>
{overlayMessage}
</div>
)}
{overlayMessage && <div className={styles.overlayMessage}>{overlayMessage}</div>}
{buttonText && onButtonClick && (
<button type="button" onClick={onButtonClick} className={styles.overlayButton}>
{buttonText}
@@ -46,5 +42,3 @@ export default function ObscuredOverlay({
</div>
);
}
@@ -1,16 +1,16 @@
import React from 'react';
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import AddIcon from '@mui/icons-material/Add';
import FitText from '@app/components/shared/FitText';
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop';
import React from "react";
import { Menu, Loader, Group, Text, Checkbox } from "@mantine/core";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import AddIcon from "@mui/icons-material/Add";
import FitText from "@app/components/shared/FitText";
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileItemDragDrop } from "@app/components/shared/pageEditor/useFileItemDragDrop";
import { FileId } from '@app/types/file';
import { FileId } from "@app/types/file";
// Local interface for PageEditor file display
interface PageEditorFile {
@@ -28,50 +28,36 @@ interface FileMenuItemProps {
onReorder: (fromIndex: number, toIndex: number) => void;
}
const FileMenuItem: React.FC<FileMenuItemProps> = ({
file,
index,
colorIndex,
onToggleSelection,
onReorder,
}) => {
const {
itemRef,
isDragging,
isDragOver,
dropPosition,
movedRef,
onPointerDown,
onPointerMove,
onPointerUp,
} = useFileItemDragDrop({
fileId: file.fileId,
index,
onReorder,
});
const FileMenuItem: React.FC<FileMenuItemProps> = ({ file, index, colorIndex, onToggleSelection, onReorder }) => {
const { itemRef, isDragging, isDragOver, dropPosition, movedRef, onPointerDown, onPointerMove, onPointerUp } =
useFileItemDragDrop({
fileId: file.fileId,
index,
onReorder,
});
const itemName = file?.name || 'Untitled';
const itemName = file?.name || "Untitled";
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
return (
<div
style={{
position: 'relative',
marginBottom: '0.5rem',
position: "relative",
marginBottom: "0.5rem",
}}
>
{/* Drop indicator line */}
{isDragOver && (
<div
style={{
position: 'absolute',
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
position: "absolute",
...(dropPosition === "above" ? { top: "-2px" } : { bottom: "-2px" }),
left: 0,
right: 0,
height: '4px',
backgroundColor: 'rgb(59, 130, 246)',
borderRadius: '2px',
height: "4px",
backgroundColor: "rgb(59, 130, 246)",
borderRadius: "2px",
zIndex: 10,
}}
/>
@@ -87,34 +73,36 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
onToggleSelection(file.fileId);
}}
style={{
padding: '0.75rem 0.75rem',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
padding: "0.75rem 0.75rem",
cursor: isDragging ? "grabbing" : "grab",
backgroundColor: file.isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent",
borderLeft: `6px solid ${fileColorBorder}`,
opacity: isDragging ? 0.5 : 1,
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease',
userSelect: 'none',
transition: "opacity 0.2s ease-in-out, background-color 0.15s ease",
userSelect: "none",
}}
onMouseEnter={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
(e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(0, 0, 0, 0.05)";
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
}
}}
onMouseLeave={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected
? "rgba(0, 0, 0, 0.05)"
: "transparent";
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
}
}}
>
<Group gap="xs" style={{ width: '100%' }}>
<Group gap="xs" style={{ width: "100%" }}>
<div
style={{
cursor: 'grab',
display: 'flex',
alignItems: 'center',
color: 'var(--mantine-color-dimmed)',
cursor: "grab",
display: "flex",
alignItems: "center",
color: "var(--mantine-color-dimmed)",
}}
>
<DragIndicatorIcon fontSize="small" />
@@ -125,7 +113,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
onClick={(e) => e.stopPropagation()}
size="sm"
/>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<PrivateContent>
<FitText text={itemName} minimumFontScale={0.7} />
</PrivateContent>
@@ -167,24 +155,29 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
return (
<Menu trigger="click" position="bottom" width="40rem">
<Menu.Target>
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
<div className="ph-no-capture" style={{ ...viewOptionStyle, cursor: "pointer" }}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
)}
<span className="ph-no-capture">{selectedCount}/{totalCount} files selected</span>
<span className="ph-no-capture">
{selectedCount}/{totalCount} files selected
</span>
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
<Menu.Dropdown className="ph-no-capture" 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: '80vh',
overflowY: 'auto'
}}>
<Menu.Dropdown
className="ph-no-capture"
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: "80vh",
overflowY: "auto",
}}
>
{files.map((file, index) => {
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
@@ -207,23 +200,23 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
openFilesModal();
}}
style={{
padding: '0.75rem 0.75rem',
marginTop: '0.5rem',
cursor: 'pointer',
backgroundColor: 'transparent',
borderTop: '1px solid var(--border-subtle)',
transition: 'background-color 0.15s ease',
padding: "0.75rem 0.75rem",
marginTop: "0.5rem",
cursor: "pointer",
backgroundColor: "transparent",
borderTop: "1px solid var(--border-subtle)",
transition: "background-color 0.15s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)';
(e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(59, 130, 246, 0.25)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
(e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent";
}}
>
<Group gap="xs" style={{ width: '100%' }}>
<AddIcon fontSize="small" style={{ color: 'var(--mantine-color-text)' }} />
<Text size="sm" fw={500} style={{ color: 'var(--mantine-color-text)' }} className="ph-no-capture">
<Group gap="xs" style={{ width: "100%" }}>
<AddIcon fontSize="small" style={{ color: "var(--mantine-color-text)" }} />
<Text size="sm" fw={500} style={{ color: "var(--mantine-color-text)" }} className="ph-no-capture">
Add File
</Text>
</Group>
@@ -1,25 +1,25 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@mantine/core';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
import { parseSelectionWithDiagnostics } from "@app/utils/bulkselection/parseSelection";
interface PageSelectionSyntaxHintProps {
input: string;
/** Optional known page count; if not provided, a large max is used for syntax-only checks */
maxPages?: number;
/** panel = full bulk panel style, compact = inline tool style */
variant?: 'panel' | 'compact';
variant?: "panel" | "compact";
}
const FALLBACK_MAX_PAGES = 100000; // large upper bound for syntax validation without a document
const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSelectionSyntaxHintProps) => {
const PageSelectionSyntaxHint = ({ input, maxPages, variant = "panel" }: PageSelectionSyntaxHintProps) => {
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const { t } = useTranslation();
useEffect(() => {
const text = (input || '').trim();
const text = (input || "").trim();
if (!text) {
setSyntaxError(null);
return;
@@ -27,21 +27,23 @@ const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSel
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES);
setSyntaxError(warning ? t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.') : null);
setSyntaxError(
warning ? t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help.") : null,
);
} catch {
setSyntaxError(t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.'));
setSyntaxError(t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help."));
}
}, [input, maxPages]);
if (!syntaxError) return null;
return (
<div className={variant === 'panel' ? classes.selectedList : classes.errorCompact}>
<Text size="xs" className={variant === 'panel' ? classes.errorText : classes.errorTextClamp}>{syntaxError}</Text>
<div className={variant === "panel" ? classes.selectedList : classes.errorCompact}>
<Text size="xs" className={variant === "panel" ? classes.errorText : classes.errorTextClamp}>
{syntaxError}
</Text>
</div>
);
};
export default PageSelectionSyntaxHint;
@@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
@@ -23,14 +23,9 @@ interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
* <img src={thumbnail} alt="preview" />
* </PrivateContent>
*/
export const PrivateContent: React.FC<PrivateContentProps> = ({
children,
className = '',
style,
...props
}) => {
const combinedClassName = `ph-no-capture${className ? ` ${className}` : ''}`;
const combinedStyle = { display: 'contents' as const, ...style };
export const PrivateContent: React.FC<PrivateContentProps> = ({ children, className = "", style, ...props }) => {
const combinedClassName = `ph-no-capture${className ? ` ${className}` : ""}`;
const combinedStyle = { display: "contents" as const, ...style };
return (
<span className={combinedClassName} style={combinedStyle} {...props}>
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,12 @@
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';
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;
@@ -22,7 +22,7 @@ const RainbowThemeContext = createContext<RainbowThemeContextType | null>(null);
export function useRainbowThemeContext() {
const context = useContext(RainbowThemeContext);
if (!context) {
throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider');
throw new Error("useRainbowThemeContext must be used within RainbowThemeProvider");
}
return context;
}
@@ -35,19 +35,12 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const rainbowTheme = useRainbowTheme();
// Determine the Mantine color scheme
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
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' }}
>
<MantineProvider theme={mantineTheme} defaultColorScheme={mantineColorScheme} forceColorScheme={mantineColorScheme}>
<div className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ""} style={{ minHeight: "100vh" }}>
<ToastProvider>
<ToastPortalBinder />
{children}
@@ -1,40 +1,40 @@
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, useFileActions } from '@app/contexts/FileContext';
import { isStirlingFile } from '@app/types/fileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
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, useFileActions } from "@app/contexts/FileContext";
import { isStirlingFile } from "@app/types/fileContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
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 LocalIcon from '@app/components/shared/LocalIcon';
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
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 LocalIcon from "@app/components/shared/LocalIcon";
import { RightRailFooterExtensions } from "@app/components/rightRail/RightRailFooterExtensions";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { downloadFile } from '@app/services/downloadService';
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from "@app/types/rightRail";
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
import { downloadFile } from "@app/services/downloadService";
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
const SECTION_ORDER: RightRailSection[] = ["top", "middle", "bottom"];
function renderWithTooltip(
node: React.ReactNode,
tooltip: React.ReactNode | undefined,
position: 'left' | 'right',
offset: number
position: "left" | "right",
offset: number,
) {
if (!tooltip) return node;
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
const portalTarget = typeof document !== "undefined" ? document.body : undefined;
return (
<Tooltip content={tooltip} position={position} offset={offset} arrow portalTarget={portalTarget}>
@@ -54,7 +54,7 @@ export default function RightRail() {
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const { workbench: currentView } = useNavigationState();
@@ -66,24 +66,22 @@ export default function RightRail() {
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
const totalItems = useMemo(() => {
if (currentView === 'pageEditor') return pageEditorTotalPages;
if (currentView === "pageEditor") return pageEditorTotalPages;
return activeFiles.length;
}, [currentView, pageEditorTotalPages, activeFiles.length]);
const selectedCount = useMemo(() => {
if (currentView === 'pageEditor') {
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);
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(
@@ -110,20 +108,19 @@ export default function RightRail() {
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 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={isActive ? 'filled' : 'subtle'}
color={isActive ? 'blue' : undefined}
variant={isActive ? "filled" : "subtle"}
color={isActive ? "blue" : undefined}
radius="md"
className={className}
onClick={triggerAction}
disabled={disabled}
aria-label={ariaLabel}
aria-pressed={isActive ? true : undefined}
data-active={isActive ? 'true' : 'false'}
data-active={isActive ? "true" : "false"}
>
{btn.icon}
</ActionIcon>
@@ -131,12 +128,12 @@ export default function RightRail() {
return renderWithTooltip(buttonNode, btn.tooltip, tooltipPosition, tooltipOffset);
},
[actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset]
[actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset],
);
const handleExportAll = useCallback(
async (forceNewFile = false) => {
if (currentView === 'viewer') {
if (currentView === "viewer") {
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
if (!buffer) return;
const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
@@ -144,7 +141,7 @@ export default function RightRail() {
const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined;
try {
const result = await downloadFile({
data: new Blob([buffer], { type: 'application/pdf' }),
data: new Blob([buffer], { type: "application/pdf" }),
filename: fileToExport.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
});
@@ -155,12 +152,12 @@ export default function RightRail() {
});
}
} catch (error) {
console.error('[RightRail] Failed to export viewer file:', error);
console.error("[RightRail] Failed to export viewer file:", error);
}
return;
}
if (currentView === 'pageEditor') {
if (currentView === "pageEditor") {
pageEditorFunctions?.onExportAll?.();
return;
}
@@ -184,27 +181,19 @@ export default function RightRail() {
});
}
} catch (error) {
console.error('[RightRail] Failed to export file:', file.name, error);
console.error("[RightRail] Failed to export file:", file.name, error);
}
}
}
},
[
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
selectors,
fileActions,
]
[currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, selectors, fileActions],
);
const downloadTooltip = useMemo(() => {
if (currentView === 'pageEditor') {
return t('rightRail.exportAll', 'Export PDF');
if (currentView === "pageEditor") {
return t("rightRail.exportAll", "Export PDF");
}
if (currentView === 'viewer') {
if (currentView === "viewer") {
return terminology.download;
}
if (selectedCount > 0) {
@@ -223,11 +212,7 @@ export default function RightRail() {
const content = renderButton(btn);
if (!content) return null;
return (
<div
key={btn.id}
className="right-rail-button-wrapper"
style={{ animationDelay: `${index * 50}ms` }}
>
<div key={btn.id} className="right-rail-button-wrapper" style={{ animationDelay: `${index * 50}ms` }}>
{content}
</div>
);
@@ -236,31 +221,24 @@ export default function RightRail() {
<Divider className="right-rail-divider" />
</React.Fragment>
))}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }} data-tour="right-rail-settings">
<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}
>
{themeMode === 'dark' ? (
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
<ActionIcon variant="subtle" radius="md" className="right-rail-icon" onClick={toggleTheme}>
{themeMode === "dark" ? (
<LightModeIcon sx={{ fontSize: "1.5rem" }} />
) : (
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
<DarkModeIcon sx={{ fontSize: "1.5rem" }} />
)}
</ActionIcon>,
t('rightRail.toggleTheme', 'Toggle Theme'),
t("rightRail.toggleTheme", "Toggle Theme"),
tooltipPosition,
tooltipOffset
tooltipOffset,
)}
<LanguageSelector
position="left-start"
offset={6}
compact
tooltip={t('rightRail.language', 'Language')}
/>
<LanguageSelector position="left-start" offset={6} compact tooltip={t("rightRail.language", "Language")} />
{renderWithTooltip(
<ActionIcon
@@ -268,16 +246,13 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => handleExportAll()}
disabled={
disableForFullscreen ||
(currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled))
}
disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))}
>
<LocalIcon icon={icons.downloadIconName} width="1.5rem" height="1.5rem" />
</ActionIcon>,
downloadTooltip,
tooltipPosition,
tooltipOffset
tooltipOffset,
)}
{icons.saveAsIconName &&
renderWithTooltip(
@@ -286,16 +261,13 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => handleExportAll(true)}
disabled={
disableForFullscreen ||
(currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled))
}
disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))}
>
<LocalIcon icon={icons.saveAsIconName} width="1.5rem" height="1.5rem" />
</ActionIcon>,
t('rightRail.saveAs', 'Save As'),
t("rightRail.saveAs", "Save As"),
tooltipPosition,
tooltipOffset
tooltipOffset,
)}
</div>
@@ -1,19 +1,19 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core';
import LinkIcon from '@mui/icons-material/Link';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import { useTranslation } from 'react-i18next';
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core";
import LinkIcon from "@mui/icons-material/Link";
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
import { useTranslation } from "react-i18next";
import apiClient from '@app/services/apiClient';
import { absoluteWithBasePath } from '@app/constants/app';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { StirlingFileStub } from '@app/types/fileContext';
import { uploadHistoryChain } from '@app/services/serverStorageUpload';
import { fileStorage } from '@app/services/fileStorage';
import { useFileActions } from '@app/contexts/FileContext';
import type { FileId } from '@app/types/file';
import apiClient from "@app/services/apiClient";
import { absoluteWithBasePath } from "@app/constants/app";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
interface ShareFileModalProps {
opened: boolean;
@@ -22,12 +22,7 @@ interface ShareFileModalProps {
onUploaded?: () => Promise<void> | void;
}
const ShareFileModal: React.FC<ShareFileModalProps> = ({
opened,
onClose,
file,
onUploaded,
}) => {
const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file, onUploaded }) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { actions } = useFileActions();
@@ -35,7 +30,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
const [isWorking, setIsWorking] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [shareToken, setShareToken] = useState<string | null>(null);
const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor');
const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">("editor");
useEffect(() => {
if (!opened) {
@@ -47,18 +42,18 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
useEffect(() => {
if (opened) {
setShareRole('editor');
setShareRole("editor");
}
}, [opened]);
const shareUrl = useMemo(() => {
if (!shareToken) return '';
const frontendUrl = (config?.frontendUrl || '').trim();
if (!shareToken) return "";
const frontendUrl = (config?.frontendUrl || "").trim();
if (frontendUrl) {
try {
const parsed = new URL(frontendUrl);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl;
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
return `${normalized}/share/${shareToken}`;
}
} catch {
@@ -68,18 +63,21 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
return absoluteWithBasePath(`/share/${shareToken}`);
}, [config?.frontendUrl, shareToken]);
const createShareLink = useCallback(async (storedFileId: number) => {
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
accessRole: shareRole,
});
return response.data as { token?: string };
}, [shareRole]);
const createShareLink = useCallback(
async (storedFileId: number) => {
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
accessRole: shareRole,
});
return response.data as { token?: string };
},
[shareRole],
);
const handleGenerateLink = useCallback(async () => {
if (!shareLinksEnabled) {
alert({
alertType: 'warning',
title: t('storageShare.linksDisabled', 'Share links are disabled.'),
alertType: "warning",
title: t("storageShare.linksDisabled", "Share links are disabled."),
expandable: false,
durationMs: 2500,
});
@@ -101,10 +99,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
if (!isUpToDate) {
const originalFileId = (file.originalFileId || file.id) as FileId;
const remoteId = file.remoteStorageId;
const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain(
originalFileId,
remoteId
);
const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId);
storedId = newStoredId;
for (const stub of chain) {
@@ -124,14 +119,14 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
}
if (!storedId) {
throw new Error('Missing stored file ID for sharing.');
throw new Error("Missing stored file ID for sharing.");
}
const shareResponse = await createShareLink(storedId);
setShareToken(shareResponse.token ?? null);
alert({
alertType: 'success',
title: t('storageShare.generated', 'Share link generated'),
alertType: "success",
title: t("storageShare.generated", "Share link generated"),
expandable: false,
durationMs: 3000,
});
@@ -143,10 +138,8 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
await onUploaded();
}
} catch (error: any) {
console.error('Failed to generate share link:', error);
setErrorMessage(
t('storageShare.failure', 'Unable to generate a share link. Please try again.')
);
console.error("Failed to generate share link:", error);
setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again."));
} finally {
setIsWorking(false);
}
@@ -157,16 +150,16 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
try {
await navigator.clipboard.writeText(shareUrl);
alert({
alertType: 'success',
title: t('storageShare.copied', 'Link copied to clipboard'),
alertType: "success",
title: t("storageShare.copied", "Link copied to clipboard"),
expandable: false,
durationMs: 2000,
});
} catch (error) {
console.error('Failed to copy share link:', error);
console.error("Failed to copy share link:", error);
alert({
alertType: 'warning',
title: t('storageShare.copyFailed', 'Copy failed'),
alertType: "warning",
title: t("storageShare.copyFailed", "Copy failed"),
expandable: false,
durationMs: 2500,
});
@@ -178,7 +171,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
opened={opened}
onClose={onClose}
centered
title={t('storageShare.title', 'Share File')}
title={t("storageShare.title", "Share File")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
size="lg"
overlayProps={{ blur: 6 }}
@@ -188,24 +181,24 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
<Stack gap={4}>
<Text size="sm">
{t(
'storageShare.description',
'Create a share link for this file. Signed-in users with the link can access it.'
"storageShare.description",
"Create a share link for this file. Signed-in users with the link can access it.",
)}
</Text>
<Text size="sm" c="dimmed">
{t('storageShare.fileLabel', 'File')}: {file.name}
{t("storageShare.fileLabel", "File")}: {file.name}
</Text>
</Stack>
</Paper>
{errorMessage && (
<Alert color="red" title={t('storageShare.errorTitle', 'Share failed')}>
<Alert color="red" title={t("storageShare.errorTitle", "Share failed")}>
{errorMessage}
</Alert>
)}
{!shareLinksEnabled && (
<Alert color="yellow" title={t('storageShare.linksDisabled', 'Share links are disabled.')}>
{t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')}
<Alert color="yellow" title={t("storageShare.linksDisabled", "Share links are disabled.")}>
{t("storageShare.linksDisabledBody", "Share links are disabled by your server settings.")}
</Alert>
)}
@@ -215,7 +208,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
<TextInput
readOnly
value={shareUrl}
label={t('storageShare.linkLabel', 'Share link')}
label={t("storageShare.linkLabel", "Share link")}
rightSection={
<Button
variant="subtle"
@@ -223,7 +216,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
onClick={handleCopyLink}
>
{t('storageShare.copy', 'Copy')}
{t("storageShare.copy", "Copy")}
</Button>
}
/>
@@ -234,22 +227,22 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
<Paper withBorder radius="md" p="md">
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('storageShare.linkAccessTitle', 'Share link access')}
{t("storageShare.linkAccessTitle", "Share link access")}
</Text>
<Select
label={t('storageShare.roleLabel', 'Role')}
label={t("storageShare.roleLabel", "Role")}
value={shareRole}
onChange={(value) => setShareRole((value as typeof shareRole) || 'editor')}
onChange={(value) => setShareRole((value as typeof shareRole) || "editor")}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
data={[
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
{ value: "editor", label: t("storageShare.roleEditor", "Editor") },
{ value: "commenter", label: t("storageShare.roleCommenter", "Commenter") },
{ value: "viewer", label: t("storageShare.roleViewer", "Viewer") },
]}
/>
{shareRole === 'commenter' && (
{shareRole === "commenter" && (
<Text size="xs" c="dimmed">
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
{t("storageShare.commenterHint", "Commenting is coming soon.")}
</Text>
)}
</Stack>
@@ -257,7 +250,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={isWorking}>
{t('cancel', 'Cancel')}
{t("cancel", "Cancel")}
</Button>
<Button
leftSection={<LinkIcon style={{ fontSize: 18 }} />}
@@ -265,7 +258,7 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({
loading={isWorking}
disabled={!shareLinksEnabled}
>
{t('storageShare.generate', 'Generate Link')}
{t("storageShare.generate", "Generate Link")}
</Button>
</Group>
</Stack>
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Modal,
Stack,
@@ -12,21 +12,21 @@ import {
SimpleGrid,
ScrollArea,
Select,
} from '@mantine/core';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import DeleteIcon from '@mui/icons-material/Delete';
import HistoryIcon from '@mui/icons-material/History';
import LinkIcon from '@mui/icons-material/Link';
import { useTranslation } from 'react-i18next';
} from "@mantine/core";
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
import DeleteIcon from "@mui/icons-material/Delete";
import HistoryIcon from "@mui/icons-material/History";
import LinkIcon from "@mui/icons-material/Link";
import { useTranslation } from "react-i18next";
import apiClient from '@app/services/apiClient';
import { absoluteWithBasePath } from '@app/constants/app';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { StirlingFileStub } from '@app/types/fileContext';
import { fileStorage } from '@app/services/fileStorage';
import { useFileActions } from '@app/contexts/FileContext';
import apiClient from "@app/services/apiClient";
import { absoluteWithBasePath } from "@app/constants/app";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
interface ShareLinkResponse {
token: string;
@@ -53,11 +53,7 @@ interface ShareManagementModalProps {
file: StirlingFileStub;
}
const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
opened,
onClose,
file,
}) => {
const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onClose, file }) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const sharingEnabled = config?.storageSharingEnabled === true;
@@ -68,8 +64,8 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
const [shareLinks, setShareLinks] = useState<ShareLinkResponse[]>([]);
const [activityMap, setActivityMap] = useState<Record<string, ShareLinkAccessResponse[]>>({});
const [sharedUsers, setSharedUsers] = useState<Array<{ username: string; accessRole?: string | null }>>([]);
const [shareUsername, setShareUsername] = useState('');
const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor');
const [shareUsername, setShareUsername] = useState("");
const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">("editor");
const [showEmailWarning, setShowEmailWarning] = useState(false);
const [selectedActivityToken, setSelectedActivityToken] = useState<string | null>(null);
const [confirmRevokeToken, setConfirmRevokeToken] = useState<string | null>(null);
@@ -79,32 +75,27 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
const lowerShareUsername = normalizedShareUsername.toLowerCase();
const isEmailInput = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedShareUsername);
const isSimpleUsername = /^[A-Za-z0-9@._+-]{3,50}$/.test(normalizedShareUsername);
const isReservedUsername =
lowerShareUsername === 'all_users' || lowerShareUsername === 'anonymoususer';
const isValidShareUsername =
normalizedShareUsername.length > 0 && !isReservedUsername && (isEmailInput || isSimpleUsername);
const isReservedUsername = lowerShareUsername === "all_users" || lowerShareUsername === "anonymoususer";
const isValidShareUsername = normalizedShareUsername.length > 0 && !isReservedUsername && (isEmailInput || isSimpleUsername);
const shareUsernameError =
normalizedShareUsername.length > 0 && !isValidShareUsername
? t(
'storageShare.invalidUsername',
'Enter a valid username or email address.'
)
? t("storageShare.invalidUsername", "Enter a valid username or email address.")
: null;
const shareBaseUrl = useMemo(() => {
const frontendUrl = (config?.frontendUrl || '').trim();
const frontendUrl = (config?.frontendUrl || "").trim();
if (frontendUrl) {
try {
const parsed = new URL(frontendUrl);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl;
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
return `${normalized}/share/`;
}
} catch {
// invalid URL — fall through to default
}
}
return absoluteWithBasePath('/share/');
return absoluteWithBasePath("/share/");
}, [config?.frontendUrl]);
const loadShareLinks = useCallback(async () => {
@@ -112,24 +103,21 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
setIsLoading(true);
setErrorMessage(null);
try {
const response = await apiClient.get<StoredFileResponse>(
`/api/v1/storage/files/${file.remoteStorageId}`,
{ suppressErrorToast: true }
);
const response = await apiClient.get<StoredFileResponse>(`/api/v1/storage/files/${file.remoteStorageId}`, {
suppressErrorToast: true,
});
const links = response.data?.shareLinks ?? [];
const users =
response.data?.sharedUsers ??
(response.data?.sharedWithUsers ?? []).map((username) => ({
username,
accessRole: 'editor',
accessRole: "editor",
}));
setShareLinks(links);
setSharedUsers(users);
} catch (error) {
console.error('Failed to load share links:', error);
setErrorMessage(
t('storageShare.manageLoadFailed', 'Unable to load share links.')
);
console.error("Failed to load share links:", error);
setErrorMessage(t("storageShare.manageLoadFailed", "Unable to load share links."));
} finally {
setIsLoading(false);
}
@@ -139,7 +127,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
if (opened) {
loadShareLinks();
setActivityMap({});
setShareRole('editor');
setShareRole("editor");
setSelectedActivityToken(null);
}
}, [opened, loadShareLinks]);
@@ -158,117 +146,120 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
}
}, [opened, selectedActivityToken, shareLinks]);
const createShareLink = useCallback(
async () => {
const createShareLink = useCallback(async () => {
if (!file.remoteStorageId) return;
setIsLoading(true);
setErrorMessage(null);
try {
const response = await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/links`, {
accessRole: shareRole,
});
const token = response.data?.token as string | undefined;
if (token) {
setShareLinks((prev) => [
...prev,
{
token,
accessRole: shareRole,
createdAt: new Date().toISOString(),
},
]);
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true });
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true });
alert({
alertType: "success",
title: t("storageShare.generated", "Share link generated"),
expandable: false,
durationMs: 2500,
});
}
} catch (error: any) {
console.error("Failed to create share link:", error);
setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again."));
} finally {
setIsLoading(false);
}
}, [actions, file.id, file.remoteStorageId, shareRole, t]);
const handleCopyLink = useCallback(
async (token: string) => {
try {
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
alert({
alertType: "success",
title: t("storageShare.copied", "Link copied to clipboard"),
expandable: false,
durationMs: 2000,
});
} catch (error) {
console.error("Failed to copy share link:", error);
alert({
alertType: "warning",
title: t("storageShare.copyFailed", "Copy failed"),
expandable: false,
durationMs: 2500,
});
}
},
[shareBaseUrl, t],
);
const handleRevokeLink = useCallback(
async (token: string) => {
if (!file.remoteStorageId) return;
setIsLoading(true);
setErrorMessage(null);
setConfirmRevokeToken(null);
try {
const response = await apiClient.post(
`/api/v1/storage/files/${file.remoteStorageId}/shares/links`,
{ accessRole: shareRole }
);
const token = response.data?.token as string | undefined;
if (token) {
setShareLinks((prev) => [
...prev,
{
token,
accessRole: shareRole,
createdAt: new Date().toISOString(),
},
]);
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true });
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true });
alert({
alertType: 'success',
title: t('storageShare.generated', 'Share link generated'),
expandable: false,
durationMs: 2500,
});
}
} catch (error: any) {
console.error('Failed to create share link:', error);
setErrorMessage(
t('storageShare.failure', 'Unable to generate a share link. Please try again.')
);
await apiClient.delete(`/api/v1/storage/files/${file.remoteStorageId}/shares/links/${token}`);
// Compute before setShareLinks so we don't read stale closure state after the update
const nextHasLinks = shareLinks.filter((link) => link.token !== token).length > 0;
setShareLinks((prev) => prev.filter((link) => link.token !== token));
setActivityMap((prev) => {
const updated = { ...prev };
delete updated[token];
return updated;
});
setSelectedActivityToken((prev) => (prev === token ? null : prev));
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: nextHasLinks });
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: nextHasLinks });
alert({
alertType: "success",
title: t("storageShare.revoked", "Share link removed"),
expandable: false,
durationMs: 2500,
});
} catch (error) {
console.error("Failed to revoke share link:", error);
setErrorMessage(t("storageShare.revokeFailed", "Unable to remove the share link."));
} finally {
setIsLoading(false);
}
},
[actions, file.id, file.remoteStorageId, shareRole, t]
[actions, file.remoteStorageId, shareLinks, t],
);
const handleCopyLink = useCallback(async (token: string) => {
try {
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
alert({
alertType: 'success',
title: t('storageShare.copied', 'Link copied to clipboard'),
expandable: false,
durationMs: 2000,
});
} catch (error) {
console.error('Failed to copy share link:', error);
alert({
alertType: 'warning',
title: t('storageShare.copyFailed', 'Copy failed'),
expandable: false,
durationMs: 2500,
});
}
}, [shareBaseUrl, t]);
const handleRevokeLink = useCallback(async (token: string) => {
if (!file.remoteStorageId) return;
setIsLoading(true);
setConfirmRevokeToken(null);
try {
await apiClient.delete(`/api/v1/storage/files/${file.remoteStorageId}/shares/links/${token}`);
// Compute before setShareLinks so we don't read stale closure state after the update
const nextHasLinks = shareLinks.filter((link) => link.token !== token).length > 0;
setShareLinks((prev) => prev.filter((link) => link.token !== token));
setActivityMap((prev) => {
const updated = { ...prev };
delete updated[token];
return updated;
});
setSelectedActivityToken((prev) => (prev === token ? null : prev));
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: nextHasLinks });
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: nextHasLinks });
alert({
alertType: 'success',
title: t('storageShare.revoked', 'Share link removed'),
expandable: false,
durationMs: 2500,
});
} catch (error) {
console.error('Failed to revoke share link:', error);
setErrorMessage(t('storageShare.revokeFailed', 'Unable to remove the share link.'));
} finally {
setIsLoading(false);
}
}, [actions, file.remoteStorageId, shareLinks, t]);
const handleLoadActivity = useCallback(async (token: string) => {
if (!file.remoteStorageId) return;
setIsLoading(true);
try {
const response = await apiClient.get<ShareLinkAccessResponse[]>(
`/api/v1/storage/files/${file.remoteStorageId}/shares/links/${token}/accesses`,
{ suppressErrorToast: true }
);
setActivityMap((prev) => ({
...prev,
[token]: response.data ?? [],
}));
} catch (error) {
console.error('Failed to load share activity:', error);
setErrorMessage(t('storageShare.accessFailed', 'Unable to load activity.'));
} finally {
setIsLoading(false);
}
}, [file.remoteStorageId, t]);
const handleLoadActivity = useCallback(
async (token: string) => {
if (!file.remoteStorageId) return;
setIsLoading(true);
try {
const response = await apiClient.get<ShareLinkAccessResponse[]>(
`/api/v1/storage/files/${file.remoteStorageId}/shares/links/${token}/accesses`,
{ suppressErrorToast: true },
);
setActivityMap((prev) => ({
...prev,
[token]: response.data ?? [],
}));
} catch (error) {
console.error("Failed to load share activity:", error);
setErrorMessage(t("storageShare.accessFailed", "Unable to load activity."));
} finally {
setIsLoading(false);
}
},
[file.remoteStorageId, t],
);
useEffect(() => {
if (!selectedActivityToken) return;
@@ -277,54 +268,51 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
}
}, [activityMap, handleLoadActivity, selectedActivityToken]);
const handleAddUser = useCallback(async (forceEmailConfirm = false) => {
if (!file.remoteStorageId) return;
const trimmed = shareUsername.trim();
if (!trimmed) return;
if (!isValidShareUsername) {
return;
}
if (isEmailInput && !forceEmailConfirm) {
setShowEmailWarning(true);
return;
}
setIsLoading(true);
setErrorMessage(null);
try {
await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/users`, {
username: trimmed,
accessRole: shareRole,
});
setSharedUsers((prev) => {
if (prev.some((user) => user.username === trimmed)) {
return prev.map((user) =>
user.username === trimmed ? { ...user, accessRole: shareRole } : user
);
}
return [...prev, { username: trimmed, accessRole: shareRole }].sort((a, b) =>
a.username.localeCompare(b.username)
);
});
setShareUsername('');
setShowEmailWarning(false);
alert({
alertType: 'success',
title: t('storageShare.userAdded', 'User added to shared list.'),
expandable: false,
durationMs: 2500,
});
} catch (error) {
console.error('Failed to share with user:', error);
setErrorMessage(
t('storageShare.userAddFailed', 'Unable to share with that user.')
);
} finally {
setIsLoading(false);
}
}, [file.remoteStorageId, isEmailInput, isValidShareUsername, shareRole, shareUsername, t]);
const handleAddUser = useCallback(
async (forceEmailConfirm = false) => {
if (!file.remoteStorageId) return;
const trimmed = shareUsername.trim();
if (!trimmed) return;
if (!isValidShareUsername) {
return;
}
if (isEmailInput && !forceEmailConfirm) {
setShowEmailWarning(true);
return;
}
setIsLoading(true);
setErrorMessage(null);
try {
await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/users`, {
username: trimmed,
accessRole: shareRole,
});
setSharedUsers((prev) => {
if (prev.some((user) => user.username === trimmed)) {
return prev.map((user) => (user.username === trimmed ? { ...user, accessRole: shareRole } : user));
}
return [...prev, { username: trimmed, accessRole: shareRole }].sort((a, b) => a.username.localeCompare(b.username));
});
setShareUsername("");
setShowEmailWarning(false);
alert({
alertType: "success",
title: t("storageShare.userAdded", "User added to shared list."),
expandable: false,
durationMs: 2500,
});
} catch (error) {
console.error("Failed to share with user:", error);
setErrorMessage(t("storageShare.userAddFailed", "Unable to share with that user."));
} finally {
setIsLoading(false);
}
},
[file.remoteStorageId, isEmailInput, isValidShareUsername, shareRole, shareUsername, t],
);
const handleUpdateUserRole = useCallback(
async (username: string, nextRole: 'editor' | 'commenter' | 'viewer') => {
async (username: string, nextRole: "editor" | "commenter" | "viewer") => {
if (!file.remoteStorageId) return;
setIsLoading(true);
setErrorMessage(null);
@@ -333,70 +321,57 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
username,
accessRole: nextRole,
});
setSharedUsers((prev) =>
prev.map((user) =>
user.username === username ? { ...user, accessRole: nextRole } : user
)
);
setSharedUsers((prev) => prev.map((user) => (user.username === username ? { ...user, accessRole: nextRole } : user)));
alert({
alertType: 'success',
title: t('storageShare.userAdded', 'User added to shared list.'),
alertType: "success",
title: t("storageShare.userAdded", "User added to shared list."),
expandable: false,
durationMs: 2500,
});
} catch (error) {
console.error('Failed to update shared user role:', error);
setErrorMessage(
t('storageShare.userAddFailed', 'Unable to share with that user.')
);
console.error("Failed to update shared user role:", error);
setErrorMessage(t("storageShare.userAddFailed", "Unable to share with that user."));
} finally {
setIsLoading(false);
}
},
[file.remoteStorageId, t]
[file.remoteStorageId, t],
);
const handleRemoveUser = useCallback(async (username: string) => {
if (!file.remoteStorageId) return;
setIsLoading(true);
setErrorMessage(null);
setConfirmRemoveUser(null);
try {
await apiClient.delete(
`/api/v1/storage/files/${file.remoteStorageId}/shares/users/${encodeURIComponent(
username
)}`
);
setSharedUsers((prev) => prev.filter((user) => user.username !== username));
alert({
alertType: 'success',
title: t('storageShare.userRemoved', 'User removed from shared list.'),
expandable: false,
durationMs: 2500,
});
} catch (error) {
console.error('Failed to remove shared user:', error);
setErrorMessage(
t('storageShare.userRemoveFailed', 'Unable to remove that user.')
);
} finally {
setIsLoading(false);
}
}, [file.remoteStorageId, t]);
const handleRemoveUser = useCallback(
async (username: string) => {
if (!file.remoteStorageId) return;
setIsLoading(true);
setErrorMessage(null);
setConfirmRemoveUser(null);
try {
await apiClient.delete(`/api/v1/storage/files/${file.remoteStorageId}/shares/users/${encodeURIComponent(username)}`);
setSharedUsers((prev) => prev.filter((user) => user.username !== username));
alert({
alertType: "success",
title: t("storageShare.userRemoved", "User removed from shared list."),
expandable: false,
durationMs: 2500,
});
} catch (error) {
console.error("Failed to remove shared user:", error);
setErrorMessage(t("storageShare.userRemoveFailed", "Unable to remove that user."));
} finally {
setIsLoading(false);
}
},
[file.remoteStorageId, t],
);
const selectedActivity = selectedActivityToken
? activityMap[selectedActivityToken]
: undefined;
const selectedLink = selectedActivityToken
? shareLinks.find((link) => link.token === selectedActivityToken)
: undefined;
const selectedActivity = selectedActivityToken ? activityMap[selectedActivityToken] : undefined;
const selectedLink = selectedActivityToken ? shareLinks.find((link) => link.token === selectedActivityToken) : undefined;
return (
<Modal
opened={opened}
onClose={onClose}
centered
title={t('storageShare.manageTitle', 'Manage Sharing')}
title={t("storageShare.manageTitle", "Manage Sharing")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
size="xl"
overlayProps={{ blur: 8 }}
@@ -404,21 +379,24 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Stack gap="lg">
<Stack gap={4}>
<Text size="sm" c="dimmed">
{t('storageShare.manageDescription', 'Create and manage links to share this file.')}
{t("storageShare.manageDescription", "Create and manage links to share this file.")}
</Text>
<Text size="sm">
{t('storageShare.fileLabel', 'File')}: <Text span fw={600}>{file.name}</Text>
{t("storageShare.fileLabel", "File")}:{" "}
<Text span fw={600}>
{file.name}
</Text>
</Text>
</Stack>
{errorMessage && (
<Alert color="red" title={t('storageShare.errorTitle', 'Sharing error')}>
<Alert color="red" title={t("storageShare.errorTitle", "Sharing error")}>
{errorMessage}
</Alert>
)}
{!sharingEnabled && (
<Alert color="yellow" title={t('storageShare.sharingDisabled', 'Sharing is disabled.')}>
{t('storageShare.sharingDisabledBody', 'Sharing has been disabled by your server settings.')}
<Alert color="yellow" title={t("storageShare.sharingDisabled", "Sharing is disabled.")}>
{t("storageShare.sharingDisabledBody", "Sharing has been disabled by your server settings.")}
</Alert>
)}
@@ -429,23 +407,23 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('storageShare.linkAccessTitle', 'Share link access')}
{t("storageShare.linkAccessTitle", "Share link access")}
</Text>
</Group>
<Select
label={t('storageShare.roleLabel', 'Role')}
label={t("storageShare.roleLabel", "Role")}
value={shareRole}
onChange={(value) => setShareRole((value as typeof shareRole) || 'editor')}
onChange={(value) => setShareRole((value as typeof shareRole) || "editor")}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
data={[
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
{ value: "editor", label: t("storageShare.roleEditor", "Editor") },
{ value: "commenter", label: t("storageShare.roleCommenter", "Commenter") },
{ value: "viewer", label: t("storageShare.roleViewer", "Viewer") },
]}
/>
{shareRole === 'commenter' && (
{shareRole === "commenter" && (
<Text size="xs" c="dimmed">
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
{t("storageShare.commenterHint", "Commenting is coming soon.")}
</Text>
)}
<Group justify="flex-end" gap="sm">
@@ -454,7 +432,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
onClick={() => createShareLink()}
loading={isLoading}
>
{t('storageShare.generate', 'Generate Link')}
{t("storageShare.generate", "Generate Link")}
</Button>
</Group>
</Stack>
@@ -464,19 +442,19 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('storageShare.sharedUsersTitle', 'Shared users')}
{t("storageShare.sharedUsersTitle", "Shared users")}
</Text>
<Group align="flex-end" gap="sm">
<TextInput
label={t('storageShare.usernameLabel', 'Username or email')}
placeholder={t('storageShare.usernamePlaceholder', 'Enter a username or email')}
label={t("storageShare.usernameLabel", "Username or email")}
placeholder={t("storageShare.usernamePlaceholder", "Enter a username or email")}
value={shareUsername}
onChange={(event) => {
setShareUsername(event.currentTarget.value);
setShowEmailWarning(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.key === "Enter") {
event.preventDefault();
void handleAddUser();
}
@@ -488,31 +466,24 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
onClick={() => handleAddUser()}
disabled={!sharingEnabled || isLoading || !normalizedShareUsername || !!shareUsernameError}
>
{t('storageShare.addUser', 'Add')}
{t("storageShare.addUser", "Add")}
</Button>
</Group>
{showEmailWarning && (
<Alert color="yellow" title={t('storageShare.emailWarningTitle', 'Email address')} variant="light">
<Alert color="yellow" title={t("storageShare.emailWarningTitle", "Email address")} variant="light">
<Stack gap="xs">
<Text size="sm">
{t(
'storageShare.emailWarningBody',
'This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.'
"storageShare.emailWarningBody",
"This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setShowEmailWarning(false)}
disabled={isLoading}
>
{t('cancel', 'Cancel')}
<Button variant="default" onClick={() => setShowEmailWarning(false)} disabled={isLoading}>
{t("cancel", "Cancel")}
</Button>
<Button
onClick={() => handleAddUser(true)}
loading={isLoading}
>
{t('storageShare.emailWarningConfirm', 'Share anyway')}
<Button onClick={() => handleAddUser(true)} loading={isLoading}>
{t("storageShare.emailWarningConfirm", "Share anyway")}
</Button>
</Group>
</Stack>
@@ -520,7 +491,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
)}
{sharedUsers.length === 0 ? (
<Text size="sm" c="dimmed">
{t('storageShare.noSharedUsers', 'No users have access yet.')}
{t("storageShare.noSharedUsers", "No users have access yet.")}
</Text>
) : (
<Stack gap="xs">
@@ -528,24 +499,24 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Group key={user.username} justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="sm">{user.username}</Text>
{user.accessRole === 'commenter' && (
{user.accessRole === "commenter" && (
<Text size="xs" c="dimmed">
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
{t("storageShare.commenterHint", "Commenting is coming soon.")}
</Text>
)}
</Stack>
<Group gap="xs" align="center">
<Select
value={user.accessRole ?? 'editor'}
value={user.accessRole ?? "editor"}
onChange={(value) => {
const nextRole = (value as 'editor' | 'commenter' | 'viewer') || 'editor';
const nextRole = (value as "editor" | "commenter" | "viewer") || "editor";
void handleUpdateUserRole(user.username, nextRole);
}}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
data={[
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
{ value: "editor", label: t("storageShare.roleEditor", "Editor") },
{ value: "commenter", label: t("storageShare.roleCommenter", "Commenter") },
{ value: "viewer", label: t("storageShare.roleViewer", "Viewer") },
]}
size="xs"
/>
@@ -555,17 +526,15 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
variant="filled"
size="xs"
color="red"
onClick={() => { void handleRemoveUser(user.username); }}
onClick={() => {
void handleRemoveUser(user.username);
}}
loading={isLoading}
>
{t('confirm', 'Confirm')}
{t("confirm", "Confirm")}
</Button>
<Button
variant="default"
size="xs"
onClick={() => setConfirmRemoveUser(null)}
>
{t('cancel', 'Cancel')}
<Button variant="default" size="xs" onClick={() => setConfirmRemoveUser(null)}>
{t("cancel", "Cancel")}
</Button>
</Group>
) : (
@@ -577,7 +546,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
onClick={() => setConfirmRemoveUser(user.username)}
disabled={isLoading}
>
{t('storageShare.removeUser', 'Remove')}
{t("storageShare.removeUser", "Remove")}
</Button>
)}
</Group>
@@ -593,7 +562,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('storageShare.linkLabel', 'Share link')}
{t("storageShare.linkLabel", "Share link")}
</Text>
{shareLinks.length > 0 && (
<Badge variant="light" color="blue">
@@ -604,14 +573,14 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
{shareLinks.length === 0 && !isLoading && (
<Text size="sm" c="dimmed">
{t('storageShare.noLinks', 'No active share links yet.')}
{t("storageShare.noLinks", "No active share links yet.")}
</Text>
)}
{shareLinks.map((link) => {
const activity = activityMap[link.token];
const viewCount = activity?.filter((entry) => entry.accessType === 'VIEW').length ?? 0;
const downloadCount = activity?.filter((entry) => entry.accessType === 'DOWNLOAD').length ?? 0;
const viewCount = activity?.filter((entry) => entry.accessType === "VIEW").length ?? 0;
const downloadCount = activity?.filter((entry) => entry.accessType === "DOWNLOAD").length ?? 0;
const lastAccessedAt = activity?.[0]?.accessedAt;
const isSelected = selectedActivityToken === link.token;
return (
@@ -620,7 +589,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<TextInput
readOnly
value={`${shareBaseUrl}${link.token}`}
label={t('storageShare.linkLabel', 'Share link')}
label={t("storageShare.linkLabel", "Share link")}
rightSection={
<Button
variant="subtle"
@@ -628,7 +597,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
onClick={() => handleCopyLink(link.token)}
>
{t('storageShare.copy', 'Copy')}
{t("storageShare.copy", "Copy")}
</Button>
}
/>
@@ -637,40 +606,39 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Group gap="xs">
{link.accessRole && (
<Badge variant="light" color="gray">
{link.accessRole === 'editor'
? t('storageShare.roleEditor', 'Editor')
: link.accessRole === 'commenter'
? t('storageShare.roleCommenter', 'Commenter')
: t('storageShare.roleViewer', 'Viewer')}
{link.accessRole === "editor"
? t("storageShare.roleEditor", "Editor")
: link.accessRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
: t("storageShare.roleViewer", "Viewer")}
</Badge>
)}
</Group>
<Group gap="sm" align="center">
<Text size="xs" c="dimmed">
{t('storageShare.viewsCount', 'Views: {{count}}', { count: viewCount })}
{t("storageShare.viewsCount", "Views: {{count}}", { count: viewCount })}
</Text>
<Text size="xs" c="dimmed">
{t('storageShare.downloadsCount', 'Downloads: {{count}}', { count: downloadCount })}
{t("storageShare.downloadsCount", "Downloads: {{count}}", { count: downloadCount })}
</Text>
{lastAccessedAt && (
<Text size="xs" c="dimmed">
{t('storageShare.lastAccessed', 'Last accessed')}: {new Date(lastAccessedAt).toLocaleString()}
{t("storageShare.lastAccessed", "Last accessed")}:{" "}
{new Date(lastAccessedAt).toLocaleString()}
</Text>
)}
</Group>
</Stack>
<Group gap="xs">
<Button
variant={isSelected ? 'filled' : 'light'}
variant={isSelected ? "filled" : "light"}
size="xs"
leftSection={<HistoryIcon style={{ fontSize: 16 }} />}
onClick={() =>
setSelectedActivityToken((prev) => (prev === link.token ? null : link.token))
}
onClick={() => setSelectedActivityToken((prev) => (prev === link.token ? null : link.token))}
>
{isSelected
? t('storageShare.hideActivity', 'Hide activity')
: t('storageShare.viewActivity', 'View activity')}
? t("storageShare.hideActivity", "Hide activity")
: t("storageShare.viewActivity", "View activity")}
</Button>
{confirmRevokeToken === link.token ? (
<Group gap="xs">
@@ -678,17 +646,15 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
variant="filled"
size="xs"
color="red"
onClick={() => { void handleRevokeLink(link.token); }}
onClick={() => {
void handleRevokeLink(link.token);
}}
loading={isLoading}
>
{t('confirm', 'Confirm')}
{t("confirm", "Confirm")}
</Button>
<Button
variant="default"
size="xs"
onClick={() => setConfirmRevokeToken(null)}
>
{t('cancel', 'Cancel')}
<Button variant="default" size="xs" onClick={() => setConfirmRevokeToken(null)}>
{t("cancel", "Cancel")}
</Button>
</Group>
) : (
@@ -700,7 +666,7 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
onClick={() => setConfirmRevokeToken(link.token)}
disabled={isLoading}
>
{t('storageShare.removeLink', 'Remove link')}
{t("storageShare.removeLink", "Remove link")}
</Button>
)}
</Group>
@@ -719,21 +685,21 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('storageShare.viewActivity', 'View activity')}
{t("storageShare.viewActivity", "View activity")}
</Text>
{selectedLink && selectedLink.accessRole && (
<Badge variant="light" color="gray">
{selectedLink.accessRole === 'editor'
? t('storageShare.roleEditor', 'Editor')
: selectedLink.accessRole === 'commenter'
? t('storageShare.roleCommenter', 'Commenter')
: t('storageShare.roleViewer', 'Viewer')}
{selectedLink.accessRole === "editor"
? t("storageShare.roleEditor", "Editor")
: selectedLink.accessRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
: t("storageShare.roleViewer", "Viewer")}
</Badge>
)}
</Group>
{!selectedActivityToken && (
<Text size="sm" c="dimmed">
{t('storageShare.noActivity', 'No activity yet.')}
{t("storageShare.noActivity", "No activity yet.")}
</Text>
)}
{selectedActivityToken && (
@@ -745,27 +711,23 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({
<Group justify="space-between">
<Stack gap={2}>
<Text size="xs" c="dimmed">
{entry.accessedAt
? new Date(entry.accessedAt).toLocaleString()
: t('unknown', 'Unknown')}
</Text>
<Text size="sm">
{entry.username || t('storageShare.unknownUser', 'Unknown user')}
{entry.accessedAt ? new Date(entry.accessedAt).toLocaleString() : t("unknown", "Unknown")}
</Text>
<Text size="sm">{entry.username || t("storageShare.unknownUser", "Unknown user")}</Text>
</Stack>
<Badge size="sm" variant="light">
{entry.accessType === 'VIEW'
? t('storageShare.viewed', 'Viewed')
: entry.accessType === 'DOWNLOAD'
? t('storageShare.downloaded', 'Downloaded')
: t('storageShare.accessed', 'Accessed')}
{entry.accessType === "VIEW"
? t("storageShare.viewed", "Viewed")
: entry.accessType === "DOWNLOAD"
? t("storageShare.downloaded", "Downloaded")
: t("storageShare.accessed", "Accessed")}
</Badge>
</Group>
</Paper>
))
) : (
<Text size="sm" c="dimmed">
{t('storageShare.noActivity', 'No activity yet.')}
{t("storageShare.noActivity", "No activity yet.")}
</Text>
)}
</Stack>
@@ -1,8 +1,8 @@
import React from 'react';
import { Box, Group, Stack } from '@mantine/core';
import React from "react";
import { Box, Group, Stack } from "@mantine/core";
interface SkeletonLoaderProps {
type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer' | 'block';
type: "pageGrid" | "fileGrid" | "controls" | "viewer" | "block";
count?: number;
animated?: boolean;
width?: number | string;
@@ -10,37 +10,32 @@ interface SkeletonLoaderProps {
radius?: number | string;
}
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
type,
count = 8,
animated = true,
width,
height,
radius = 8,
}) => {
const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({ type, count = 8, animated = true, width, height, radius = 8 }) => {
const animationStyle = animated ? { animation: "pulse 2s infinite" } : {};
// Generic block skeleton for inline text/inputs/etc.
const renderBlock = () => (
<Box
w={typeof width === 'number' ? `${width}px` : width}
h={typeof height === 'number' ? `${height}px` : height}
w={typeof width === "number" ? `${width}px` : width}
h={typeof height === "number" ? `${height}px` : height}
bg="gray.1"
style={{
borderRadius: radius,
display: 'inline-block',
verticalAlign: 'middle',
...animationStyle
display: "inline-block",
verticalAlign: "middle",
...animationStyle,
}}
/>
);
const renderPageGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '1rem'
}}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: "1rem",
}}
>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
@@ -48,9 +43,9 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
h={240}
bg="gray.1"
style={{
borderRadius: '8px',
borderRadius: "8px",
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
animationDelay: animated ? `${i * 0.1}s` : undefined,
}}
/>
))}
@@ -58,11 +53,13 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
);
const renderFileGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: "1rem",
}}
>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
@@ -70,9 +67,9 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
h={280}
bg="gray.1"
style={{
borderRadius: '8px',
borderRadius: "8px",
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
animationDelay: animated ? `${i * 0.1}s` : undefined,
}}
/>
))}
@@ -101,23 +98,23 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
flex={1}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle
borderRadius: "8px",
...animationStyle,
}}
/>
</Stack>
);
switch (type) {
case 'block':
case "block":
return renderBlock();
case 'pageGrid':
case "pageGrid":
return renderPageGridSkeleton();
case 'fileGrid':
case "fileGrid":
return renderFileGridSkeleton();
case 'controls':
case "controls":
return renderControlsSkeleton();
case 'viewer':
case "viewer":
return renderViewerSkeleton();
default:
return null;
@@ -1,7 +1,7 @@
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';
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
@@ -34,86 +34,88 @@ export interface TextInputProps {
/** Whether the input is read-only (default: false) */
readOnly?: boolean;
/** Accessibility label */
'aria-label'?: string;
"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();
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 handleClear = () => {
if (onClear) {
onClear();
} else {
onChange("");
}
};
const shouldShowClearButton = showClearButton && value.trim().length > 0 && !disabled && !readOnly;
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>
);
});
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';
TextInput.displayName = "TextInput";
@@ -3,63 +3,66 @@
* 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';
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';
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)'
maxWidth = "100%",
displayStyle = "text",
size = "xs",
color = "var(--mantine-color-blue-7)",
}) => {
const { t } = useTranslation();
if (!toolChain || toolChain.length === 0) return null;
const toolIds = toolChain.map(tool => tool.toolId);
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>
);
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 };
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])}`,
text: `${getToolName(toolIds[0])} → +${toolIds.length - 2}${getToolName(toolIds[toolIds.length - 1])}`,
isTruncated: true,
};
}
@@ -68,7 +71,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
const { text: truncatedText, isTruncated } = getTruncatedDisplay();
// Compact style for very small spaces
if (displayStyle === 'compact') {
if (displayStyle === "compact") {
const compactText = toolIds.length === 1 ? getToolName(toolIds[0]) : `${toolIds.length} tools`;
const isCompactTruncated = toolIds.length > 1;
@@ -78,11 +81,11 @@ const ToolChain: React.FC<ToolChainProps> = ({
style={{
color,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: `${maxWidth}`,
cursor: isCompactTruncated ? 'help' : 'default'
cursor: isCompactTruncated ? "help" : "default",
}}
>
{compactText}
@@ -93,15 +96,17 @@ const ToolChain: React.FC<ToolChainProps> = ({
<Tooltip label={fullChainDisplay} multiline withinPortal>
{compactElement}
</Tooltip>
) : compactElement;
) : (
compactElement
);
}
// Badge style for file details
if (displayStyle === 'badges') {
if (displayStyle === "badges") {
const isBadgesTruncated = toolChain.length > 3;
const badgesElement = (
<div style={{ maxWidth: `${maxWidth}`, overflow: 'hidden' }}>
<div style={{ maxWidth: `${maxWidth}`, overflow: "hidden" }}>
<Group gap="2px" wrap="nowrap">
{toolChain.slice(0, 3).map((tool, index) => (
<React.Fragment key={`${tool.toolId}-${index}`}>
@@ -109,13 +114,17 @@ const ToolChain: React.FC<ToolChainProps> = ({
{getToolName(tool.toolId)}
</Badge>
{index < Math.min(toolChain.length - 1, 2) && (
<Text size="xs" c="dimmed"></Text>
<Text size="xs" c="dimmed">
</Text>
)}
</React.Fragment>
))}
{toolChain.length > 3 && (
<>
<Text size="xs" c="dimmed">...</Text>
<Text size="xs" c="dimmed">
...
</Text>
<Badge size={size} variant="light" color="blue">
{getToolName(toolChain[toolChain.length - 1].toolId)}
</Badge>
@@ -126,10 +135,12 @@ const ToolChain: React.FC<ToolChainProps> = ({
);
return isBadgesTruncated ? (
<Tooltip label={`${toolIds.map(getToolName).join('')}`} withinPortal>
<Tooltip label={`${toolIds.map(getToolName).join("")}`} withinPortal>
{badgesElement}
</Tooltip>
) : badgesElement;
) : (
badgesElement
);
}
// Text style (default) for file list items
@@ -139,11 +150,11 @@ const ToolChain: React.FC<ToolChainProps> = ({
style={{
color,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: `${maxWidth}`,
cursor: isTruncated ? 'help' : 'default'
cursor: isTruncated ? "help" : "default",
}}
>
{truncatedText}
@@ -154,7 +165,9 @@ const ToolChain: React.FC<ToolChainProps> = ({
<Tooltip label={fullChainDisplay} withinPortal>
{textElement}
</Tooltip>
) : textElement;
) : (
textElement
);
};
export default ToolChain;
@@ -15,7 +15,7 @@ export const ToolIcon: React.FC<ToolIconProps> = ({
icon,
opacity = 1,
color = "var(--tools-text-and-icon-color)",
marginRight = "0.5rem"
marginRight = "0.5rem",
}) => {
return (
<div
@@ -25,7 +25,7 @@ export const ToolIcon: React.FC<ToolIconProps> = ({
marginRight,
transform: "scale(0.8)",
transformOrigin: "center",
opacity
opacity,
}}
>
{icon}
+71 -69
View File
@@ -1,18 +1,18 @@
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 { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/shared/tooltip/Tooltip.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
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 { useLogoAssets } from "@app/hooks/useLogoAssets";
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';
position?: "right" | "left" | "top" | "bottom";
content?: React.ReactNode;
tips?: TooltipTip[];
children: React.ReactElement;
@@ -73,8 +73,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
// Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target
const isDomNode = (value: unknown): value is Node =>
typeof Node !== 'undefined' && value instanceof Node;
const isDomNode = (value: unknown): value is Node => typeof Node !== "undefined" && value instanceof Node;
const clearTimers = useCallback(() => {
if (openTimeoutRef.current) {
@@ -92,14 +91,14 @@ export const Tooltip: React.FC<TooltipProps> = ({
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const allowAutoClose = !manualCloseOnly;
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
const isRTL = htmlDir === 'rtl';
const base = position ?? 'right';
if (!isRTL) return base as NonNullable<TooltipProps['position']>;
if (base === 'left') return 'right';
if (base === 'right') return 'left';
return base as NonNullable<TooltipProps['position']>;
const resolvedPosition: NonNullable<TooltipProps["position"]> = useMemo(() => {
const htmlDir = typeof document !== "undefined" ? document.documentElement.dir : "ltr";
const isRTL = htmlDir === "rtl";
const base = position ?? "right";
if (!isRTL) return base as NonNullable<TooltipProps["position"]>;
if (base === "left") return "right";
if (base === "right") return "left";
return base as NonNullable<TooltipProps["position"]>;
}, [position]);
const setOpen = useCallback(
@@ -109,7 +108,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
else setInternalOpen(newOpen);
if (!newOpen) setIsPinned(false);
},
[isControlled, onOpenChange, open]
[isControlled, onOpenChange, open],
);
const { coords, positionReady } = useTooltipPosition({
@@ -146,13 +145,13 @@ export const Tooltip: React.FC<TooltipProps> = ({
setOpen(false);
}
},
[isPinned, closeOnOutside, setOpen, allowAutoClose]
[isPinned, closeOnOutside, setOpen, allowAutoClose],
);
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);
return addEventListenerWithCleanup(document, "click", handleDocumentClick as EventListener);
}
}, [open, isPinned, handleDocumentClick]);
@@ -160,11 +159,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
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',
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[resolvedPosition] || map.right;
}, [resolvedPosition, sidebarTooltip]);
@@ -173,8 +172,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
(key: string) =>
styles[key as keyof typeof styles] ||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
'',
[]
"",
[],
);
// === Trigger handlers ===
@@ -189,7 +188,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (!isPinned && !disabled) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled]
[isPinned, openWithDelay, children.props, disabled],
);
const handlePointerLeave = useCallback(
@@ -198,7 +197,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
// Moving into the tooltip → keep open
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
@@ -213,7 +211,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props, allowAutoClose]
[clearTimers, isPinned, setOpen, children.props, allowAutoClose],
);
const handleMouseDown = useCallback(
@@ -221,7 +219,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
},
[children.props]
[children.props],
);
const handleMouseUp = useCallback(
@@ -230,7 +228,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
queueMicrotask(() => (clickPendingRef.current = false));
(children.props as any)?.onMouseUp?.(e);
},
[children.props]
[children.props],
);
const handleClick = useCallback(
@@ -247,7 +245,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props]
[clearTimers, pinOnClick, open, setOpen, children.props],
);
// Keyboard / focus accessibility
@@ -256,7 +254,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (!isPinned && !disabled && openOnFocus) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
[isPinned, openWithDelay, children.props, disabled, openOnFocus],
);
const handleBlur = useCallback(
@@ -270,13 +268,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props, allowAutoClose, clearTimers]
[isPinned, setOpen, children.props, allowAutoClose, clearTimers],
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (manualCloseOnly) return;
if (e.key === 'Escape') setOpen(false);
}, [setOpen, manualCloseOnly]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (manualCloseOnly) return;
if (e.key === "Escape") setOpen(false);
},
[setOpen, manualCloseOnly],
);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => {
@@ -289,7 +290,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (allowAutoClose && !isPinned) setOpen(false);
},
[isPinned, setOpen, allowAutoClose]
[isPinned, setOpen, allowAutoClose],
);
// Enhance child with handlers and ref
@@ -297,10 +298,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
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;
if (typeof originalRef === "function") originalRef(node);
else if (originalRef && typeof originalRef === "object") (originalRef as any).current = node;
},
'aria-describedby': open ? tooltipIdRef.current : undefined,
"aria-describedby": open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
@@ -323,23 +324,30 @@ export const Tooltip: React.FC<TooltipProps> = ({
onPointerEnter={handleTooltipPointerEnter}
onPointerLeave={handleTooltipPointerLeave}
style={{
position: 'fixed',
position: "fixed",
top: coords.top,
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
width: maxWidth !== undefined ? maxWidth : sidebarTooltip ? ("25rem" as const) : undefined,
minWidth,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
visibility: positionReady ? 'visible' : 'hidden',
visibility: positionReady ? "visible" : "hidden",
opacity: positionReady ? 1 : 0,
color: 'var(--text-primary)',
color: "var(--text-primary)",
...containerStyle,
}}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
className={`${styles["tooltip-container"]} ${isPinned ? styles.pinned : ""}`}
onClick={
pinOnClick
? (e) => {
e.stopPropagation();
setIsPinned(true);
}
: undefined
}
>
{shouldShowCloseButton && (
<button
className={styles['tooltip-pin-button']}
className={styles["tooltip-pin-button"]}
onClick={(e) => {
e.stopPropagation();
setIsPinned(false);
@@ -353,26 +361,22 @@ export const Tooltip: React.FC<TooltipProps> = ({
)}
{arrow && !sidebarTooltip && (
<div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
className={`${styles["tooltip-arrow"]} ${getArrowStyleClass(arrowClass!)}`}
style={
coords.arrowOffset !== null
? { [resolvedPosition === 'top' || resolvedPosition === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
? { [resolvedPosition === "top" || resolvedPosition === "bottom" ? "left" : "top"]: coords.arrowOffset }
: undefined
}
/>
)}
{header && (
<div className={styles['tooltip-header']}>
<div className={styles['tooltip-logo']}>
<div className={styles["tooltip-header"]}>
<div className={styles["tooltip-logo"]}>
{header.logo || (
<img
src={tooltipLogo}
alt="Stirling PDF"
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
/>
<img src={tooltipLogo} alt="Stirling PDF" style={{ width: "1.4rem", height: "1.4rem", display: "block" }} />
)}
</div>
<span className={styles['tooltip-title']}>{header.title}</span>
<span className={styles["tooltip-title"]}>{header.title}</span>
</div>
)}
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
@@ -383,11 +387,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
<>
{childWithHandlers}
{(() => {
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
const defaultTarget = typeof document !== "undefined" ? document.body : null;
const target = portalTarget ?? defaultTarget;
return tooltipElement && target
? createPortal(tooltipElement, target)
: tooltipElement;
return tooltipElement && target ? createPortal(tooltipElement, target) : tooltipElement;
})()}
</>
);
@@ -1,26 +1,28 @@
import React, { useState, useCallback, useMemo } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import rainbowStyles from '@app/styles/rainbow.module.css';
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import rainbowStyles from "@app/styles/rainbow.module.css";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import GridViewIcon from "@mui/icons-material/GridView";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
import { FileId } from '@app/types/file';
import { WorkbenchType, isValidWorkbench } from "@app/types/workbench";
import { PageEditorFileDropdown } from "@app/components/shared/PageEditorFileDropdown";
import type { CustomWorkbenchViewInstance } from "@app/contexts/ToolWorkflowContext";
import { FileDropdownMenu } from "@app/components/shared/FileDropdownMenu";
import {
usePageEditorDropdownState,
PageEditorDropdownState,
} from "@app/components/pageEditor/hooks/usePageEditorDropdownState";
import { FileId } from "@app/types/file";
const viewOptionStyle: React.CSSProperties = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: '0.5rem',
justifyContent: 'center',
padding: '2px 1rem',
display: "inline-flex",
flexDirection: "row",
alignItems: "center",
gap: "0.5rem",
justifyContent: "center",
padding: "2px 1rem",
};
// Helper function to create view options for SegmentedControl
@@ -32,13 +34,13 @@ const createViewOptions = (
onFileSelect?: (index: number) => void,
onFileRemove?: (fileId: FileId) => void,
pageEditorState?: PageEditorDropdownState,
customViews?: CustomWorkbenchViewInstance[]
customViews?: CustomWorkbenchViewInstance[],
) => {
// Viewer dropdown logic
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || '';
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
const isInViewer = currentView === "viewer";
const fileName = currentFile?.name || "";
const viewerDisplayName = isInViewer && fileName ? fileName : "Viewer";
const showViewerDropdown = isInViewer;
const viewerOption = {
@@ -54,18 +56,14 @@ const createViewOptions = (
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "viewer" ? (
<Loader size="sm" />
) : (
<InsertDriveFileIcon fontSize="medium" />
)}
{switchingTo === "viewer" ? <Loader size="sm" /> : <InsertDriveFileIcon fontSize="medium" />}
</div>
),
value: "viewer",
};
// Page Editor dropdown logic
const isInPageEditor = currentView === 'pageEditor';
const isInPageEditor = currentView === "pageEditor";
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
@@ -83,11 +81,7 @@ const createViewOptions = (
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "pageEditor" ? (
<Loader size="sm" />
) : (
<GridViewIcon fontSize="medium" />
)}
{switchingTo === "pageEditor" ? <Loader size="sm" /> : <GridViewIcon fontSize="medium" />}
</div>
),
value: "pageEditor",
@@ -102,22 +96,14 @@ const createViewOptions = (
value: "fileEditor",
};
const baseOptions = [
viewerOption,
pageEditorOption,
fileEditorOption,
];
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="sm" />
) : (
view.icon || <PictureAsPdfIcon fontSize="medium" />
)}
{switchingTo === view.workbenchId ? <Loader size="sm" /> : view.icon || <PictureAsPdfIcon fontSize="medium" />}
<span>{view.label}</span>
</div>
),
@@ -151,87 +137,92 @@ const TopControls = ({
const pageEditorState = usePageEditorDropdownState();
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
return;
}
const handleViewChange = useCallback(
(view: string) => {
if (!isValidWorkbench(view)) {
return;
}
const workbench = view;
const workbench = view;
// Show immediate feedback
setSwitchingTo(workbench);
// 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
// Defer the heavy view change to next frame so spinner can render
requestAnimationFrame(() => {
setCurrentView(workbench);
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(workbench);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
});
}, [setCurrentView]);
},
[setCurrentView],
);
// Memoize view options to prevent SegmentedControl re-renders
const viewOptions = useMemo(() => createViewOptions(
currentView,
switchingTo,
activeFiles,
currentFileIndex,
onFileSelect,
onFileRemove,
pageEditorState,
customViews
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, onFileRemove, pageEditorState, customViews]);
const viewOptions = useMemo(
() =>
createViewOptions(
currentView,
switchingTo,
activeFiles,
currentFileIndex,
onFileSelect,
onFileRemove,
pageEditorState,
customViews,
),
[currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, onFileRemove, pageEditorState, customViews],
);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center">
<SegmentedControl
data-tour="view-switcher"
data={viewOptions}
value={currentView}
onChange={handleViewChange}
color="blue"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ""}
style={{
transition: 'all 0.2s ease',
transition: "all 0.2s ease",
opacity: switchingTo ? 0.8 : 1,
pointerEvents: 'auto'
pointerEvents: "auto",
}}
styles={{
root: {
borderRadius: '0 0 16px 16px',
height: '1.8rem',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderTop: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
outline: '1px solid rgba(0, 0, 0, 0.1)',
outlineOffset: '-1px',
padding: '0 0',
gap: '0',
borderRadius: "0 0 16px 16px",
height: "1.8rem",
backgroundColor: "var(--bg-toolbar)",
border: "1px solid var(--border-default)",
borderTop: "none",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
outline: "1px solid rgba(0, 0, 0, 0.1)",
outlineOffset: "-1px",
padding: "0 0",
gap: "0",
},
control: {
borderRadius: '0 0 16px 16px',
padding: '0',
border: 'none',
borderRadius: "0 0 16px 16px",
padding: "0",
border: "none",
},
indicator: {
borderRadius: '0 0 16px 16px',
height: '100%',
top: '0rem',
margin: '0',
border: 'none',
borderRadius: "0 0 16px 16px",
height: "100%",
top: "0rem",
margin: "0",
border: "none",
},
label: {
paddingTop: '0',
paddingBottom: '0',
}
paddingTop: "0",
paddingBottom: "0",
},
}}
/>
</div>
@@ -1,14 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadIcon from '@mui/icons-material/Download';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import React, { useState, useEffect } from "react";
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from "@app/services/updateService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DownloadIcon from "@mui/icons-material/Download";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
interface UpdateModalProps {
opened: boolean;
@@ -18,13 +18,7 @@ interface UpdateModalProps {
machineInfo: MachineInfo;
}
const UpdateModal: React.FC<UpdateModalProps> = ({
opened,
onClose,
currentVersion,
updateSummary,
machineInfo,
}) => {
const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersion, updateSummary, machineInfo }) => {
const { t } = useTranslation();
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
@@ -55,22 +49,22 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
const getPriorityColor = (priority: string): string => {
switch (priority?.toLowerCase()) {
case 'urgent':
return 'red';
case 'normal':
return 'blue';
case 'minor':
return 'cyan';
case 'low':
return 'gray';
case "urgent":
return "red";
case "normal":
return "blue";
case "minor":
return "cyan";
case "low":
return "gray";
default:
return 'gray';
return "gray";
}
};
const getPriorityLabel = (priority: string): string => {
const key = priority?.toLowerCase();
return t(`update.priority.${key}`, priority || 'Normal');
return t(`update.priority.${key}`, priority || "Normal");
};
const downloadUrl = updateService.getDownloadUrl(machineInfo);
@@ -81,7 +75,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
onClose={onClose}
title={
<Text fw={600} size="lg">
{t('update.modalTitle', 'Update Available')}
{t("update.modalTitle", "Update Available")}
</Text>
}
centered
@@ -89,8 +83,8 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {
maxHeight: '75vh',
overflowY: 'auto',
maxHeight: "75vh",
overflowY: "auto",
},
}}
>
@@ -100,7 +94,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
<Stack gap={4} style={{ flex: 1 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.current', 'Current Version')}
{t("update.current", "Current Version")}
</Text>
<Text fw={600} size="xl">
{currentVersion}
@@ -109,13 +103,13 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Stack gap={4} style={{ flex: 1 }} ta="center">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.priorityLabel', 'Priority')}
{t("update.priorityLabel", "Priority")}
</Text>
<Badge
color={getPriorityColor(updateSummary.max_priority)}
size="lg"
variant="filled"
style={{ alignSelf: 'center' }}
style={{ alignSelf: "center" }}
>
{getPriorityLabel(updateSummary.max_priority)}
</Badge>
@@ -123,7 +117,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Stack gap={4} style={{ flex: 1 }} ta="right">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.latest', 'Latest Version')}
{t("update.latest", "Latest Version")}
</Text>
<Text fw={600} size="xl" c="blue">
{updateSummary.latest_version}
@@ -134,15 +128,15 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
{updateSummary.latest_stable_version && (
<Box
style={{
background: 'var(--mantine-color-green-0)',
padding: '10px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-green-2)',
background: "var(--mantine-color-green-0)",
padding: "10px 16px",
borderRadius: "8px",
border: "1px solid var(--mantine-color-green-2)",
}}
>
<Group gap="xs" justify="center">
<Text size="sm" fw={500}>
{t('update.latestStable', 'Latest Stable')}:
{t("update.latestStable", "Latest Stable")}:
</Text>
<Text size="sm" fw={600} c="green">
{updateSummary.latest_stable_version}
@@ -156,21 +150,19 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
{updateSummary.recommended_action && (
<Box
style={{
background: 'var(--mantine-color-blue-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-blue-outline)',
background: "var(--mantine-color-blue-light)",
padding: "12px 16px",
borderRadius: "8px",
border: "1px solid var(--mantine-color-blue-outline)",
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<InfoOutlinedIcon style={{ fontSize: 18, color: 'var(--mantine-color-blue-filled)', marginTop: 2 }} />
<InfoOutlinedIcon style={{ fontSize: 18, color: "var(--mantine-color-blue-filled)", marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.recommendedAction', 'Recommended Action')}
</Text>
<Text size="sm">
{updateSummary.recommended_action}
{t("update.recommendedAction", "Recommended Action")}
</Text>
<Text size="sm">{updateSummary.recommended_action}</Text>
</Box>
</Group>
</Box>
@@ -180,22 +172,22 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
{updateSummary.any_breaking && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-orange-outline)',
background: "var(--mantine-color-orange-light)",
padding: "12px 16px",
borderRadius: "8px",
border: "1px solid var(--mantine-color-orange-outline)",
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<WarningAmberIcon style={{ fontSize: 18, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<WarningAmberIcon style={{ fontSize: 18, color: "var(--mantine-color-orange-filled)", marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.breakingChangesDetected', 'Breaking Changes Detected')}
{t("update.breakingChangesDetected", "Breaking Changes Detected")}
</Text>
<Text size="sm">
{t(
'update.breakingChangesMessage',
'Some versions contain breaking changes. Please review the migration guides below before updating.'
"update.breakingChangesMessage",
"Some versions contain breaking changes. Please review the migration guides below before updating.",
)}
</Text>
</Box>
@@ -209,22 +201,22 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Divider />
<Stack gap="xs">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.migrationGuides', 'Migration Guides')}
{t("update.migrationGuides", "Migration Guides")}
</Text>
{updateSummary.migration_guides.map((guide, idx) => (
<Box
key={idx}
style={{
border: '1px solid var(--mantine-color-gray-3)',
padding: '12px 16px',
borderRadius: '8px',
background: 'var(--mantine-color-gray-0)',
border: "1px solid var(--mantine-color-gray-3)",
padding: "12px 16px",
borderRadius: "8px",
background: "var(--mantine-color-gray-0)",
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t('update.version', 'Version')} {guide.version}
{t("update.version", "Version")} {guide.version}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{guide.notes}
@@ -238,7 +230,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.viewGuide', 'View Guide')}
{t("update.viewGuide", "View Guide")}
</Button>
</Group>
</Box>
@@ -254,7 +246,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Stack align="center" gap="sm">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('update.loadingDetailedInfo', 'Loading detailed information...')}
{t("update.loadingDetailedInfo", "Loading detailed information...")}
</Text>
</Stack>
</Center>
@@ -262,10 +254,10 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.availableUpdates', 'Available Updates')}
{t("update.availableUpdates", "Available Updates")}
</Text>
<Badge variant="light" color="gray">
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'}
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? "version" : "versions"}
</Badge>
</Group>
<Stack gap="xs">
@@ -275,9 +267,9 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Box
key={index}
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '8px',
overflow: 'hidden',
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "8px",
overflow: "hidden",
}}
>
<Group
@@ -285,16 +277,16 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
align="center"
p="md"
style={{
cursor: 'pointer',
background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent',
transition: 'background 0.15s ease',
cursor: "pointer",
background: isExpanded ? "var(--mantine-color-gray-0)" : "transparent",
transition: "background 0.15s ease",
}}
onClick={() => toggleVersion(index)}
>
<Group gap="md" style={{ flex: 1 }}>
<Box>
<Text fw={600} size="sm" c="dimmed" mb={2}>
{t('update.version', 'Version')}
{t("update.version", "Version")}
</Text>
<Text fw={700} size="lg">
{version.version}
@@ -314,18 +306,18 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
onClick={(e) => e.stopPropagation()}
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.releaseNotes', 'Release Notes')}
{t("update.releaseNotes", "Release Notes")}
</Button>
{isExpanded ? (
<ExpandLessIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
<ExpandLessIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
) : (
<ExpandMoreIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
<ExpandMoreIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
)}
</Group>
</Group>
<Collapse in={isExpanded}>
<Box p="md" pt={0} style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}>
<Box p="md" pt={0} style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<Stack gap="md" mt="md">
<Box>
<Text fw={600} size="sm" mb={6}>
@@ -339,21 +331,23 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
{version.compatibility.breaking_changes && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px',
borderRadius: '6px',
border: '1px solid var(--mantine-color-orange-outline)',
background: "var(--mantine-color-orange-light)",
padding: "12px",
borderRadius: "6px",
border: "1px solid var(--mantine-color-orange-outline)",
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
<WarningAmberIcon style={{ fontSize: 16, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<WarningAmberIcon
style={{ fontSize: 16, color: "var(--mantine-color-orange-filled)", marginTop: 2 }}
/>
<Text size="xs" fw={600} tt="uppercase">
{t('update.breakingChanges', 'Breaking Changes')}
{t("update.breakingChanges", "Breaking Changes")}
</Text>
</Group>
<Text size="sm" mb="xs">
{version.compatibility.breaking_description ||
t('update.breakingChangesDefault', 'This version contains breaking changes.')}
t("update.breakingChangesDefault", "This version contains breaking changes.")}
</Text>
{version.compatibility.migration_guide_url && (
<Button
@@ -365,7 +359,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.migrationGuide', 'Migration Guide')}
{t("update.migrationGuide", "Migration Guide")}
</Button>
)}
</Box>
@@ -384,7 +378,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('update.close', 'Close')}
{t("update.close", "Close")}
</Button>
<Button
variant="light"
@@ -393,7 +387,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
target="_blank"
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{t('update.viewAllReleases', 'View All Releases')}
{t("update.viewAllReleases", "View All Releases")}
</Button>
{downloadUrl && (
<Button
@@ -403,7 +397,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
color="green"
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
>
{t('update.downloadLatest', 'Download Latest')}
{t("update.downloadLatest", "Download Latest")}
</Button>
)}
</Group>
@@ -1,15 +1,15 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { useTranslation } from 'react-i18next';
import React, { useCallback, useEffect, useState } from "react";
import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { useTranslation } from "react-i18next";
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import type { StirlingFileStub } from '@app/types/fileContext';
import { uploadHistoryChain } from '@app/services/serverStorageUpload';
import { fileStorage } from '@app/services/fileStorage';
import { useFileActions } from '@app/contexts/FileContext';
import type { FileId } from '@app/types/file';
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import type { StirlingFileStub } from "@app/types/fileContext";
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import type { FileId } from "@app/types/file";
interface UploadToServerModalProps {
opened: boolean;
@@ -18,12 +18,7 @@ interface UploadToServerModalProps {
onUploaded?: () => Promise<void> | void;
}
const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
opened,
onClose,
file,
onUploaded,
}) => {
const UploadToServerModal: React.FC<UploadToServerModalProps> = ({ opened, onClose, file, onUploaded }) => {
const { t } = useTranslation();
const { actions } = useFileActions();
const [isUploading, setIsUploading] = useState(false);
@@ -43,10 +38,7 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
try {
const originalFileId = (file.originalFileId || file.id) as FileId;
const remoteId = file.remoteStorageId;
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(
originalFileId,
remoteId
);
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId);
for (const stub of chain) {
actions.updateStirlingFileStub(stub.id, {
@@ -62,8 +54,8 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
}
alert({
alertType: 'success',
title: t('storageUpload.success', 'Uploaded to server'),
alertType: "success",
title: t("storageUpload.success", "Uploaded to server"),
expandable: false,
durationMs: 3000,
});
@@ -72,10 +64,8 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
}
onClose();
} catch (error) {
console.error('Failed to upload file to server:', error);
setErrorMessage(
t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.')
);
console.error("Failed to upload file to server:", error);
setErrorMessage(t("storageUpload.failure", "Upload failed. Please check your login and storage settings."));
} finally {
setIsUploading(false);
}
@@ -86,44 +76,34 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({
opened={opened}
onClose={onClose}
centered
title={t('storageUpload.title', 'Upload to Server')}
title={t("storageUpload.title", "Upload to Server")}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Stack gap="sm">
<Text size="sm">
{t(
'storageUpload.description',
'This uploads the current file to server storage for your own access.'
)}
{t("storageUpload.description", "This uploads the current file to server storage for your own access.")}
</Text>
<Text size="sm" c="dimmed">
{t('storageUpload.fileLabel', 'File')}: {file.name}
{t("storageUpload.fileLabel", "File")}: {file.name}
</Text>
<Text size="xs" c="dimmed">
{t(
'storageUpload.hint',
'Public links and access modes are controlled by your server settings.'
)}
{t("storageUpload.hint", "Public links and access modes are controlled by your server settings.")}
</Text>
{errorMessage && (
<Alert color="red" title={t('storageUpload.errorTitle', 'Upload failed')}>
<Alert color="red" title={t("storageUpload.errorTitle", "Upload failed")}>
{errorMessage}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={isUploading}>
{t('cancel', 'Cancel')}
{t("cancel", "Cancel")}
</Button>
<Button
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
onClick={handleUpload}
loading={isUploading}
>
<Button leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />} onClick={handleUpload} loading={isUploading}>
{file.remoteStorageId
? t('storageUpload.updateButton', 'Update on Server')
: t('storageUpload.uploadButton', 'Upload to Server')}
? t("storageUpload.updateButton", "Update on Server")
: t("storageUpload.uploadButton", "Upload to Server")}
</Button>
</Group>
</Stack>
@@ -1,25 +1,25 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MultiSelect, Loader, Text, Button, Stack } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { alert } from '@app/components/toast';
import { UserSummary } from '@app/types/signingSession';
import apiClient from '@app/services/apiClient';
import { useAuth } from '@app/auth/UseSession';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MultiSelect, Loader, Text, Button, Stack } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import { alert } from "@app/components/toast";
import { UserSummary } from "@app/types/signingSession";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
interface UserSelectorProps {
value: number[];
onChange: (userIds: number[]) => void;
placeholder?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
size?: "xs" | "sm" | "md" | "lg" | "xl";
disabled?: boolean;
}
type SelectItem = { value: string; label: string };
type GroupedData = { group: string; items: SelectItem[] };
const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = false }: UserSelectorProps) => {
const UserSelector = ({ value, onChange, placeholder, size = "sm", disabled = false }: UserSelectorProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const navigate = useNavigate();
@@ -30,8 +30,8 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiClient.get('/api/v1/user/users');
console.log('Users API response:', response.data);
const response = await apiClient.get("/api/v1/user/users");
console.log("Users API response:", response.data);
const fetchedUsers = response.data || [];
// Process selectData inside useEffect - group by team
@@ -41,16 +41,15 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa
fetchedUsers
.filter((u: UserSummary) => u && u.userId && u.username)
.filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user
.filter((u: UserSummary) => u.teamName?.toLowerCase() !== 'internal') // Exclude internal users
.filter((u: UserSummary) => u.teamName?.toLowerCase() !== "internal") // Exclude internal users
.forEach((user: UserSummary) => {
const teamName = user.teamName || t('certSign.collab.userSelector.noTeam', 'No Team');
const teamName = user.teamName || t("certSign.collab.userSelector.noTeam", "No Team");
if (!usersByTeam[teamName]) {
usersByTeam[teamName] = [];
}
const displayName = user.displayName || user.username || 'Unknown';
const username = user.username || 'unknown';
const label =
displayName !== username ? `${displayName} (@${username})` : displayName;
const displayName = user.displayName || user.username || "Unknown";
const username = user.username || "unknown";
const label = displayName !== username ? `${displayName} (@${username})` : displayName;
usersByTeam[teamName].push({
value: String(user.userId),
label,
@@ -63,14 +62,14 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}));
console.log('Processed selectData:', processed);
console.log("Processed selectData:", processed);
setSelectData(processed);
} catch (error) {
console.error('Failed to load users:', error);
console.error("Failed to load users:", error);
alert({
alertType: 'error',
title: t('common.error'),
body: t('certSign.collab.userSelector.loadError', 'Failed to load users'),
alertType: "error",
title: t("common.error"),
body: t("certSign.collab.userSelector.loadError", "Failed to load users"),
});
} finally {
setLoading(false);
@@ -83,8 +82,8 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa
// Process stringValue when value prop changes
useEffect(() => {
const safeValue = Array.isArray(value) ? value : [];
const result = safeValue.map((id) => (id != null ? id.toString() : '')).filter(Boolean);
console.log('stringValue for MultiSelect:', result);
const result = safeValue.map((id) => (id != null ? id.toString() : "")).filter(Boolean);
console.log("stringValue for MultiSelect:", result);
setStringValue(result);
}, [value]);
@@ -97,10 +96,10 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa
return (
<Stack gap="xs" align="flex-start">
<Text size="sm" c="dimmed">
{t('certSign.collab.userSelector.noUsers', 'No other users found.')}
{t("certSign.collab.userSelector.noUsers", "No other users found.")}
</Text>
<Button size="xs" variant="light" onClick={() => navigate('/settings/people')}>
{t('certSign.collab.userSelector.inviteUsers', 'Add Users')}
<Button size="xs" variant="light" onClick={() => navigate("/settings/people")}>
{t("certSign.collab.userSelector.inviteUsers", "Add Users")}
</Button>
</Stack>
);
@@ -111,12 +110,10 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa
data={selectData}
value={stringValue}
onChange={(selectedIds) => {
const parsedIds = selectedIds
.map((id) => parseInt(id, 10))
.filter((id) => !isNaN(id));
const parsedIds = selectedIds.map((id) => parseInt(id, 10)).filter((id) => !isNaN(id));
onChange(parsedIds);
}}
placeholder={placeholder || t('certSign.collab.userSelector.placeholder', 'Select users...')}
placeholder={placeholder || t("certSign.collab.userSelector.placeholder", "Select users...")}
searchable
clearable
size={size}
@@ -15,9 +15,9 @@ interface ZipWarningModalProps {
const WARNING_ICON_STYLE: CSSProperties = {
fontSize: 36,
display: 'block',
margin: '0 auto 8px',
color: 'var(--mantine-color-blue-6)'
display: "block",
margin: "0 auto 8px",
color: "var(--mantine-color-blue-6)",
};
const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => {
@@ -41,7 +41,7 @@ const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }
<Text size="lg" fw={500}>
{t("zipWarning.message", {
count: fileCount,
defaultValue: "This ZIP contains {{count}} files. Extract anyway?"
defaultValue: "This ZIP contains {{count}} files. Extract anyway?",
})}
</Text>
</Stack>
@@ -1,6 +1,6 @@
import { Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
interface LoginRequiredBannerProps {
show: boolean;
@@ -18,20 +18,26 @@ export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps)
return (
<Alert
icon={<LocalIcon icon="lock" width={20} height={20} />}
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
title={t("admin.settings.loginDisabled.title", "Login Mode Required")}
color="blue"
variant="light"
styles={{
root: {
borderLeft: '4px solid var(--mantine-color-blue-6)'
}
borderLeft: "4px solid var(--mantine-color-blue-6)",
},
}}
>
<Text size="sm">
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
{t(
"admin.settings.loginDisabled.message",
"Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
)}
</Text>
<Text size="sm" fw={600} mt="xs" c="dimmed">
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
{t(
"admin.settings.loginDisabled.readOnly",
"The settings below show example values for reference. Enable login mode to view and edit actual configuration.",
)}
</Text>
</Alert>
);
@@ -1,14 +1,16 @@
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
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 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.')}
{t("config.overview.description", "Current application settings and configuration details.")}
</Text>
</div>
);
@@ -1,22 +1,22 @@
import { Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface PendingBadgeProps {
show: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
size?: "xs" | "sm" | "md" | "lg";
}
/**
* Badge to show when a setting has been saved but requires restart to take effect.
*/
export default function PendingBadge({ show, size = 'xs' }: PendingBadgeProps) {
export default function PendingBadge({ show, size = "xs" }: PendingBadgeProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Badge color="orange" size={size} variant="light">
{t('admin.settings.restartRequired', 'Restart Required')}
{t("admin.settings.restartRequired", "Restart Required")}
</Badge>
);
}
@@ -1,8 +1,8 @@
import { Modal, Text, Group, Button, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import RefreshIcon from '@mui/icons-material/Refresh';
import ScheduleIcon from '@mui/icons-material/Schedule';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { Modal, Text, Group, Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import RefreshIcon from "@mui/icons-material/Refresh";
import ScheduleIcon from "@mui/icons-material/Schedule";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface RestartConfirmationModalProps {
opened: boolean;
@@ -10,11 +10,7 @@ interface RestartConfirmationModalProps {
onRestart: () => void;
}
export default function RestartConfirmationModal({
opened,
onClose,
onRestart,
}: RestartConfirmationModalProps) {
export default function RestartConfirmationModal({ opened, onClose, onRestart }: RestartConfirmationModalProps) {
const { t } = useTranslation();
return (
@@ -23,7 +19,7 @@ export default function RestartConfirmationModal({
onClose={onClose}
title={
<Text fw={600} size="lg">
{t('admin.settings.restart.title', 'Restart Required')}
{t("admin.settings.restart.title", "Restart Required")}
</Text>
}
centered
@@ -34,32 +30,21 @@ export default function RestartConfirmationModal({
<Stack gap="lg">
<Text size="sm">
{t(
'admin.settings.restart.message',
'Settings have been saved successfully. A server restart is required for the changes to take effect.'
"admin.settings.restart.message",
"Settings have been saved successfully. A server restart is required for the changes to take effect.",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
'admin.settings.restart.question',
'Would you like to restart the server now or later?'
)}
{t("admin.settings.restart.question", "Would you like to restart the server now or later?")}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
leftSection={<ScheduleIcon style={{ fontSize: 16 }} />}
onClick={onClose}
>
{t('admin.settings.restart.later', 'Restart Later')}
<Button variant="default" leftSection={<ScheduleIcon style={{ fontSize: 16 }} />} onClick={onClose}>
{t("admin.settings.restart.later", "Restart Later")}
</Button>
<Button
color="blue"
leftSection={<RefreshIcon style={{ fontSize: 16 }} />}
onClick={onRestart}
>
{t('admin.settings.restart.now', 'Restart Now')}
<Button color="blue" leftSection={<RefreshIcon style={{ fontSize: 16 }} />} onClick={onRestart}>
{t("admin.settings.restart.now", "Restart Now")}
</Button>
</Group>
</Stack>
@@ -1,10 +1,10 @@
import React, { useMemo, useState, useCallback } from 'react';
import { Select, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections';
import React, { useMemo, useState, useCallback } from "react";
import { Select, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import type { ConfigNavSection, ConfigNavItem } from "@app/components/shared/config/configNavSections";
interface SettingsSearchBarProps {
configNavSections: ConfigNavSection[];
@@ -22,35 +22,35 @@ interface SettingsSearchOption {
}
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
general: ['settings.general'],
hotkeys: ['settings.hotkeys'],
account: ['account'],
people: ['settings.workspace'],
teams: ['settings.workspace', 'settings.team'],
'api-keys': ['settings.developer'],
connectionMode: ['settings.connection'],
planBilling: ['settings.planBilling'],
adminGeneral: ['admin.settings.general'],
adminFeatures: ['admin.settings.features'],
adminEndpoints: ['admin.settings.endpoints'],
adminDatabase: ['admin.settings.database'],
adminAdvanced: ['admin.settings.advanced'],
adminSecurity: ['admin.settings.security'],
general: ["settings.general"],
hotkeys: ["settings.hotkeys"],
account: ["account"],
people: ["settings.workspace"],
teams: ["settings.workspace", "settings.team"],
"api-keys": ["settings.developer"],
connectionMode: ["settings.connection"],
planBilling: ["settings.planBilling"],
adminGeneral: ["admin.settings.general"],
adminFeatures: ["admin.settings.features"],
adminEndpoints: ["admin.settings.endpoints"],
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminConnections: [
'admin.settings.connections',
'admin.settings.mail',
'admin.settings.security',
'admin.settings.telegram',
'admin.settings.premium',
'admin.settings.general',
'settings.securityAuth',
'settings.connection',
"admin.settings.connections",
"admin.settings.mail",
"admin.settings.security",
"admin.settings.telegram",
"admin.settings.premium",
"admin.settings.general",
"settings.securityAuth",
"settings.connection",
],
adminPlan: ['settings.planBilling', 'admin.settings.premium', 'settings.licensingAnalytics'],
adminAudit: ['settings.licensingAnalytics'],
adminUsage: ['settings.licensingAnalytics'],
adminLegal: ['admin.settings.legal'],
adminPrivacy: ['admin.settings.privacy'],
adminPlan: ["settings.planBilling", "admin.settings.premium", "settings.licensingAnalytics"],
adminAudit: ["settings.licensingAnalytics"],
adminUsage: ["settings.licensingAnalytics"],
adminLegal: ["admin.settings.legal"],
adminPrivacy: ["admin.settings.privacy"],
};
const getTranslationPrefixesForNavKey = (key: string): string[] => {
@@ -58,8 +58,8 @@ const getTranslationPrefixesForNavKey = (key: string): string[] => {
const inferredPrefixes: string[] = [];
if (key.startsWith('admin')) {
const adminSuffix = key.replace(/^admin/, '');
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix = adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
@@ -70,7 +70,7 @@ const getTranslationPrefixesForNavKey = (key: string): string[] => {
};
const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === 'string') {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
@@ -79,7 +79,7 @@ const flattenTranslationStrings = (value: unknown): string[] => {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === 'object') {
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(flattenTranslationStrings);
}
@@ -102,19 +102,15 @@ const buildMatchSnippet = (text: string, query: string): string => {
const snippet = text.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? '…' : ''}${snippet}${end < text.length ? '…' : ''}`;
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
}
return `${start > 0 ? '…' : ''}${snippet.slice(0, maxLength)}${end < text.length ? '…' : ''}`;
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
};
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
configNavSections,
onNavigate,
isMobile,
}) => {
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({ configNavSections, onNavigate, isMobile }) => {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState('');
const [searchValue, setSearchValue] = useState("");
// Build a global index from every accessible settings tab in the modal navigation.
// This does not render section components, so API calls still happen only when a tab is opened.
@@ -125,16 +121,11 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
.map((item: ConfigNavItem) => {
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
const translationContent = translationPrefixes.flatMap((prefix) =>
flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any))
flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any)),
);
const searchableContent = Array.from(
new Set([
item.label,
section.title,
`/settings/${item.key}`,
...translationContent,
])
new Set([item.label, section.title, `/settings/${item.key}`, ...translationContent]),
);
return {
@@ -144,7 +135,7 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
destinationPath: `/settings/${item.key}`,
searchableContent,
};
})
}),
);
}, [configNavSections, t]);
@@ -157,9 +148,7 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
const normalizedQuery = query.toLocaleLowerCase();
return searchableSections.reduce<SettingsSearchOption[]>((accumulator, option) => {
const matchedEntry = option.searchableContent.find((entry) =>
entry.toLocaleLowerCase().includes(normalizedQuery)
);
const matchedEntry = option.searchableContent.find((entry) => entry.toLocaleLowerCase().includes(normalizedQuery));
if (!matchedEntry) {
return accumulator;
@@ -174,12 +163,15 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
}, []);
}, [searchValue, searchableSections]);
const handleSearchNavigation = useCallback(async (value: string | null) => {
if (!value) return;
if (!VALID_NAV_KEYS.includes(value as NavKey)) return;
await onNavigate(value as NavKey);
setSearchValue('');
}, [onNavigate]);
const handleSearchNavigation = useCallback(
async (value: string | null) => {
if (!value) return;
if (!VALID_NAV_KEYS.includes(value as NavKey)) return;
await onNavigate(value as NavKey);
setSearchValue("");
},
[onNavigate],
);
return (
<Select
@@ -189,10 +181,10 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
searchValue={searchValue}
onSearchChange={setSearchValue}
onChange={handleSearchNavigation}
placeholder={t('settings.search.placeholder', 'Search settings pages...')}
placeholder={t("settings.search.placeholder", "Search settings pages...")}
leftSection={<LocalIcon icon="search-rounded" width={16} height={16} />}
aria-label={t('navbar.search', 'Search')}
nothingFoundMessage={t('search.noResults', 'No results found')}
aria-label={t("navbar.search", "Search")}
nothingFoundMessage={t("search.noResults", "No results found")}
searchable
clearable={false}
w={isMobile ? 170 : 320}
@@ -201,7 +193,9 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
const searchOption = option as unknown as SettingsSearchOption;
return (
<div className="settings-search-option">
<Text size="sm" fw={600}>{searchOption.label}</Text>
<Text size="sm" fw={600}>
{searchOption.label}
</Text>
<Text size="xs" c="dimmed">
{searchOption.sectionTitle} · {searchOption.matchedContext || searchOption.destinationPath}
</Text>
@@ -1,5 +1,5 @@
import { Button, Group, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface SettingsStickyFooterProps {
isDirty: boolean;
@@ -9,13 +9,7 @@ interface SettingsStickyFooterProps {
onDiscard: () => void;
}
export function SettingsStickyFooter({
isDirty,
saving,
loginEnabled,
onSave,
onDiscard,
}: SettingsStickyFooterProps) {
export function SettingsStickyFooter({ isDirty, saving, loginEnabled, onSave, onDiscard }: SettingsStickyFooterProps) {
const { t } = useTranslation();
if (!isDirty || !loginEnabled) {
@@ -26,14 +20,14 @@ export function SettingsStickyFooter({
<div className="settings-sticky-footer">
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">
{t('admin.settings.unsavedChanges.hint', 'You have unsaved changes')}
{t("admin.settings.unsavedChanges.hint", "You have unsaved changes")}
</Text>
<Group gap="sm">
<Button variant="default" onClick={onDiscard} size="sm">
{t('admin.settings.discard', 'Discard')}
{t("admin.settings.discard", "Discard")}
</Button>
<Button onClick={onSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
{t("admin.settings.save", "Save Changes")}
</Button>
</Group>
</Group>
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
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';
import React from "react";
import { useTranslation } from "react-i18next";
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;
@@ -33,25 +33,25 @@ export interface ConfigColors {
export const useConfigNavSections = (
_isAdmin: boolean = false,
_runningEE: boolean = false,
_loginEnabled: boolean = false
_loginEnabled: boolean = false,
): ConfigNavSection[] => {
const { t } = useTranslation();
const sections: ConfigNavSection[] = [
{
title: t('settings.preferences.title', 'Preferences'),
title: t("settings.preferences.title", "Preferences"),
items: [
{
key: 'general',
label: t('settings.general.title', 'General'),
icon: 'settings-rounded',
component: <GeneralSection />
key: "general",
label: t("settings.general.title", "General"),
icon: "settings-rounded",
component: <GeneralSection />,
},
{
key: 'hotkeys',
label: t('settings.hotkeys.title', 'Keyboard Shortcuts'),
icon: 'keyboard-rounded',
component: <HotkeysSection />
key: "hotkeys",
label: t("settings.hotkeys.title", "Keyboard Shortcuts"),
icon: "keyboard-rounded",
component: <HotkeysSection />,
},
],
},
@@ -64,24 +64,24 @@ export const useConfigNavSections = (
export const createConfigNavSections = (
_isAdmin: boolean = false,
_runningEE: boolean = false,
_loginEnabled: boolean = false
_loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn('createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.');
console.warn("createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.");
const sections: ConfigNavSection[] = [
{
title: 'Preferences',
title: "Preferences",
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
key: "general",
label: "General",
icon: "settings-rounded",
component: <GeneralSection />,
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />
key: "hotkeys",
label: "Keyboard Shortcuts",
icon: "keyboard-rounded",
component: <HotkeysSection />,
},
],
},
@@ -36,7 +36,11 @@ interface GeneralSectionProps {
hideAdminBanner?: boolean;
}
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false, hideUpdateSection = false, hideAdminBanner = false }) => {
const GeneralSection: React.FC<GeneralSectionProps> = ({
hideTitle = false,
hideUpdateSection = false,
hideAdminBanner = false,
}) => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
@@ -1,25 +1,25 @@
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 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',
display: "flex",
flexDirection: "column",
gap: "0.5rem",
};
const rowHeaderStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
};
const HotkeysSection: React.FC = () => {
@@ -28,18 +28,19 @@ const HotkeysSection: React.FC = () => {
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 [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)
return tools.filter(
([toolId, tool]) =>
tool.name.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query) ||
toolId.toLowerCase().includes(query),
);
}, [tools, searchQuery]);
@@ -59,7 +60,7 @@ const HotkeysSection: React.FC = () => {
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setEditingTool(null);
@@ -72,21 +73,19 @@ const HotkeysSection: React.FC = () => {
const binding = eventToBinding(event as KeyboardEvent);
if (!binding) {
const osKey = isMac ? 'mac' : 'windows';
const osKey = isMac ? "mac" : "windows";
setError(t(`settings.hotkeys.errorModifier.${osKey}`));
return;
}
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
toolId !== editingTool && bindingEquals(existing, binding)
));
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 }));
const conflictTool = conflictKey in toolRegistry ? toolRegistry[conflictKey as ToolId]?.name : conflictKey;
setError(t("settings.hotkeys.errorConflict", "Shortcut already used by {{tool}}.", { tool: conflictTool }));
return;
}
@@ -95,9 +94,9 @@ const HotkeysSection: React.FC = () => {
setError(null);
};
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener("keydown", handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener("keydown", handleKeyDown, true);
};
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
@@ -109,14 +108,19 @@ const HotkeysSection: React.FC = () => {
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.hotkeys.title', 'Keyboard Shortcuts')}</Text>
<Text fw={600} size="lg">
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
</Text>
<Text size="sm" c="dimmed">
{t('settings.hotkeys.description', 'Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.')}
{t(
"settings.hotkeys.description",
'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...')}
placeholder={t("settings.hotkeys.searchPlaceholder", "Search tools...")}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
size="md"
@@ -127,70 +131,69 @@ const HotkeysSection: React.FC = () => {
<Stack gap="md">
{filteredTools.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('toolPicker.noToolsFound', 'No tools found')}
{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');
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>
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>
<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>
{isEditing && error && (
<Alert color="red" radius="sm" variant="filled">
{error}
</Alert>
)}
</Box>
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})
)}
</Stack>
</Paper>
@@ -1,33 +1,35 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { OverviewHeader } from '@app/components/shared/config/OverviewHeader';
import React from "react";
import { Stack, Text, Code, Group, Badge, Alert, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { OverviewHeader } from "@app/components/shared/config/OverviewHeader";
const Overview: React.FC = () => {
const { t } = useTranslation();
const { config, loading, error } = useAppConfig();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
if (!data || typeof data !== "object") return null;
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<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'}
{typeof value === "boolean" ? (
<Badge color={value ? "green" : "red"} size="sm">
{value ? "true" : "false"}
</Badge>
) : typeof value === 'object' ? (
) : typeof value === "object" ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
String(value) || "null"
)}
</Group>
))}
@@ -36,38 +38,48 @@ const Overview: React.FC = () => {
);
};
const basicConfig = config ? {
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const basicConfig = config
? {
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
}
: null;
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const securityConfig = config
? {
enableLogin: config.enableLogin,
}
: null;
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const systemConfig = config
? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
}
: null;
const integrationConfig = config ? {
SSOAutoLogin: config.SSOAutoLogin,
} : null;
const integrationConfig = config
? {
SSOAutoLogin: config.SSOAutoLogin,
}
: null;
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">{t('config.overview.loading', 'Loading configuration...')}</Text>
<Text size="sm" c="dimmed">
{t("config.overview.loading", "Loading configuration...")}
</Text>
</Stack>
);
}
if (error) {
return (
<Alert color="red" title={t('config.overview.error', 'Error')}>
<Alert color="red" title={t("config.overview.error", "Error")}>
{error}
</Alert>
);
@@ -79,13 +91,13 @@ const Overview: React.FC = () => {
{config && (
<>
{renderConfigSection(t('config.overview.sections.basic', 'Basic Configuration'), basicConfig)}
{renderConfigSection(t('config.overview.sections.security', 'Security Configuration'), securityConfig)}
{renderConfigSection(t('config.overview.sections.system', 'System Configuration'), systemConfig)}
{renderConfigSection(t('config.overview.sections.integration', 'Integration Configuration'), integrationConfig)}
{renderConfigSection(t("config.overview.sections.basic", "Basic Configuration"), basicConfig)}
{renderConfigSection(t("config.overview.sections.security", "Security Configuration"), securityConfig)}
{renderConfigSection(t("config.overview.sections.system", "System Configuration"), systemConfig)}
{renderConfigSection(t("config.overview.sections.integration", "Integration Configuration"), integrationConfig)}
{config.error && (
<Alert color="yellow" title={t('config.overview.warning', 'Configuration Warning')}>
<Alert color="yellow" title={t("config.overview.warning", "Configuration Warning")}>
{config.error}
</Alert>
)}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
import {
Paper,
Group,
@@ -12,11 +12,11 @@ import {
NumberInput,
TagsInput,
Anchor,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import EditableSecretField from '@app/components/shared/EditableSecretField';
import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions';
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import EditableSecretField from "@app/components/shared/EditableSecretField";
import { Provider, ProviderField } from "@app/components/shared/config/configSections/providerDefinitions";
interface ProviderCardProps {
provider: Provider;
@@ -83,15 +83,19 @@ export default function ProviderCard({
};
const renderField = (field: ProviderField) => {
const value = localSettings[field.key] ?? field.defaultValue ?? '';
const value = localSettings[field.key] ?? field.defaultValue ?? "";
switch (field.type) {
case 'switch':
case "switch":
return (
<div key={field.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div key={field.key} style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">{field.label}</Text>
<Text size="xs" c="dimmed" mt={4}>{field.description}</Text>
<Text fw={500} size="sm">
{field.label}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{field.description}
</Text>
</div>
<Switch
checked={value || false}
@@ -101,7 +105,7 @@ export default function ProviderCard({
</div>
);
case 'password':
case "password":
return (
<EditableSecretField
key={field.key}
@@ -114,7 +118,7 @@ export default function ProviderCard({
/>
);
case 'textarea':
case "textarea":
return (
<Textarea
key={field.key}
@@ -127,7 +131,7 @@ export default function ProviderCard({
/>
);
case 'number':
case "number":
return (
<NumberInput
key={field.key}
@@ -141,7 +145,7 @@ export default function ProviderCard({
/>
);
case 'tags': {
case "tags": {
const tagValue = Array.isArray(value) ? value.map((val) => `${val}`) : [];
return (
@@ -174,14 +178,8 @@ export default function ProviderCard({
const renderProviderIcon = () => {
// If icon starts with '/', it's a path to an SVG file
if (provider.icon.startsWith('/')) {
return (
<img
src={provider.icon}
alt={provider.name}
style={{ width: '1.5rem', height: '1.5rem' }}
/>
);
if (provider.icon.startsWith("/")) {
return <img src={provider.icon} alt={provider.name} style={{ width: "1.5rem", height: "1.5rem" }} />;
}
// Otherwise use LocalIcon for iconify icons
return <LocalIcon icon={provider.icon} width="1.5rem" height="1.5rem" />;
@@ -195,8 +193,12 @@ export default function ProviderCard({
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
{renderProviderIcon()}
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="sm">{provider.name}</Text>
<Text size="xs" c="dimmed" truncate>{provider.scope}</Text>
<Text fw={600} size="sm">
{provider.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{provider.scope}
</Text>
</div>
</Group>
@@ -207,24 +209,19 @@ export default function ProviderCard({
onClick={isConfigured ? () => setExpanded(!expanded) : handleConnectToggle}
rightSection={
expanded ? (
<LocalIcon
icon="close-rounded"
width="1rem"
height="1rem"
/>
) : (isConfigured ? (
<LocalIcon
icon="expand-more-rounded"
width="1rem"
height="1rem"
/>
) : undefined)
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
) : isConfigured ? (
<LocalIcon icon="expand-more-rounded" width="1rem" height="1rem" />
) : undefined
}
>
{isConfigured
? (expanded ? t('admin.close', 'Close') : t('admin.expand', 'Expand'))
: (expanded ? t('admin.close', 'Close') : t('admin.settings.connections.connect', 'Connect'))
}
? expanded
? t("admin.close", "Close")
: t("admin.expand", "Expand")
: expanded
? t("admin.close", "Close")
: t("admin.settings.connections.connect", "Connect")}
</Button>
</Group>
</Group>
@@ -234,13 +231,8 @@ export default function ProviderCard({
<Stack gap="md" mt="xs">
{/* Documentation Link */}
{provider.documentationUrl && (
<Anchor
href={provider.documentationUrl}
target="_blank"
size="xs"
c="blue"
>
{t('admin.settings.connections.documentation', 'View documentation')}
<Anchor href={provider.documentationUrl} target="_blank" size="xs" c="blue">
{t("admin.settings.connections.documentation", "View documentation")}
</Anchor>
)}
@@ -249,19 +241,13 @@ export default function ProviderCard({
{!readOnly && (onSave || onDisconnect) && (
<Group justify="flex-end" mt="sm">
{onDisconnect && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
<Button variant="outline" color="red" size="sm" onClick={onDisconnect} disabled={disabled}>
{t("admin.settings.connections.disconnect", "Disconnect")}
</Button>
)}
{onSave && (
<Button size="sm" onClick={handleSave} disabled={disabled}>
{t('admin.settings.save', 'Save Changes')}
{t("admin.settings.save", "Save Changes")}
</Button>
)}
</Group>
@@ -1,10 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
export type ProviderType = 'oauth2' | 'saml2' | 'telegram' | 'googledrive';
export type ProviderType = "oauth2" | "saml2" | "telegram" | "googledrive";
export interface ProviderField {
key: string;
type: 'text' | 'password' | 'switch' | 'textarea' | 'number' | 'tags';
type: "text" | "password" | "switch" | "textarea" | "number" | "tags";
label: string;
description: string;
placeholder?: string;
@@ -26,39 +26,45 @@ const useGoogleProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'google',
name: 'Google',
icon: '/Login/google.svg',
type: 'oauth2',
scope: t('provider.oauth2.google.scope', 'Sign-in authentication'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
id: "google",
name: "Google",
icon: "/Login/google.svg",
type: "oauth2",
scope: t("provider.oauth2.google.scope", "Sign-in authentication"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration",
fields: [
{
key: 'clientId',
type: 'text',
label: t('provider.oauth2.google.clientId.label', 'Client ID'),
description: t('provider.oauth2.google.clientId.description', 'The OAuth2 client ID from Google Cloud Console'),
placeholder: 'your-client-id.apps.googleusercontent.com',
key: "clientId",
type: "text",
label: t("provider.oauth2.google.clientId.label", "Client ID"),
description: t("provider.oauth2.google.clientId.description", "The OAuth2 client ID from Google Cloud Console"),
placeholder: "your-client-id.apps.googleusercontent.com",
},
{
key: 'clientSecret',
type: 'password',
label: t('provider.oauth2.google.clientSecret.label', 'Client Secret'),
description: t('provider.oauth2.google.clientSecret.description', 'The OAuth2 client secret from Google Cloud Console'),
key: "clientSecret",
type: "password",
label: t("provider.oauth2.google.clientSecret.label", "Client Secret"),
description: t(
"provider.oauth2.google.clientSecret.description",
"The OAuth2 client secret from Google Cloud Console",
),
},
{
key: 'scopes',
type: 'text',
label: t('provider.oauth2.google.scopes.label', 'Scopes'),
description: t('provider.oauth2.google.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'email, profile',
key: "scopes",
type: "text",
label: t("provider.oauth2.google.scopes.label", "Scopes"),
description: t("provider.oauth2.google.scopes.description", "Comma-separated OAuth2 scopes"),
defaultValue: "email, profile",
},
{
key: 'useAsUsername',
type: 'text',
label: t('provider.oauth2.google.useAsUsername.label', 'Use as Username'),
description: t('provider.oauth2.google.useAsUsername.description', 'Field to use as username (email, name, given_name, family_name)'),
defaultValue: 'email',
key: "useAsUsername",
type: "text",
label: t("provider.oauth2.google.useAsUsername.label", "Use as Username"),
description: t(
"provider.oauth2.google.useAsUsername.description",
"Field to use as username (email, name, given_name, family_name)",
),
defaultValue: "email",
},
],
};
@@ -68,38 +74,41 @@ const useGitHubProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'github',
name: 'GitHub',
icon: '/Login/github.svg',
type: 'oauth2',
scope: t('provider.oauth2.github.scope', 'Sign-in authentication'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
id: "github",
name: "GitHub",
icon: "/Login/github.svg",
type: "oauth2",
scope: t("provider.oauth2.github.scope", "Sign-in authentication"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration",
fields: [
{
key: 'clientId',
type: 'text',
label: t('provider.oauth2.github.clientId.label', 'Client ID'),
description: t('provider.oauth2.github.clientId.description', 'The OAuth2 client ID from GitHub Developer Settings'),
key: "clientId",
type: "text",
label: t("provider.oauth2.github.clientId.label", "Client ID"),
description: t("provider.oauth2.github.clientId.description", "The OAuth2 client ID from GitHub Developer Settings"),
},
{
key: 'clientSecret',
type: 'password',
label: t('provider.oauth2.github.clientSecret.label', 'Client Secret'),
description: t('provider.oauth2.github.clientSecret.description', 'The OAuth2 client secret from GitHub Developer Settings'),
key: "clientSecret",
type: "password",
label: t("provider.oauth2.github.clientSecret.label", "Client Secret"),
description: t(
"provider.oauth2.github.clientSecret.description",
"The OAuth2 client secret from GitHub Developer Settings",
),
},
{
key: 'scopes',
type: 'text',
label: t('provider.oauth2.github.scopes.label', 'Scopes'),
description: t('provider.oauth2.github.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'read:user',
key: "scopes",
type: "text",
label: t("provider.oauth2.github.scopes.label", "Scopes"),
description: t("provider.oauth2.github.scopes.description", "Comma-separated OAuth2 scopes"),
defaultValue: "read:user",
},
{
key: 'useAsUsername',
type: 'text',
label: t('provider.oauth2.github.useAsUsername.label', 'Use as Username'),
description: t('provider.oauth2.github.useAsUsername.description', 'Field to use as username (email, login, name)'),
defaultValue: 'login',
key: "useAsUsername",
type: "text",
label: t("provider.oauth2.github.useAsUsername.label", "Use as Username"),
description: t("provider.oauth2.github.useAsUsername.description", "Field to use as username (email, login, name)"),
defaultValue: "login",
},
],
};
@@ -109,46 +118,52 @@ const useKeycloakProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'keycloak',
name: 'Keycloak',
icon: 'key-rounded',
type: 'oauth2',
scope: t('provider.oauth2.keycloak.scope', 'SSO'),
id: "keycloak",
name: "Keycloak",
icon: "key-rounded",
type: "oauth2",
scope: t("provider.oauth2.keycloak.scope", "SSO"),
businessTier: false,
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration",
fields: [
{
key: 'issuer',
type: 'text',
label: t('provider.oauth2.keycloak.issuer.label', 'Issuer URL'),
description: t('provider.oauth2.keycloak.issuer.description', "URL of the Keycloak realm's OpenID Connect Discovery endpoint"),
placeholder: 'https://keycloak.example.com/realms/myrealm',
key: "issuer",
type: "text",
label: t("provider.oauth2.keycloak.issuer.label", "Issuer URL"),
description: t(
"provider.oauth2.keycloak.issuer.description",
"URL of the Keycloak realm's OpenID Connect Discovery endpoint",
),
placeholder: "https://keycloak.example.com/realms/myrealm",
},
{
key: 'clientId',
type: 'text',
label: t('provider.oauth2.keycloak.clientId.label', 'Client ID'),
description: t('provider.oauth2.keycloak.clientId.description', 'The OAuth2 client ID from Keycloak'),
key: "clientId",
type: "text",
label: t("provider.oauth2.keycloak.clientId.label", "Client ID"),
description: t("provider.oauth2.keycloak.clientId.description", "The OAuth2 client ID from Keycloak"),
},
{
key: 'clientSecret',
type: 'password',
label: t('provider.oauth2.keycloak.clientSecret.label', 'Client Secret'),
description: t('provider.oauth2.keycloak.clientSecret.description', 'The OAuth2 client secret from Keycloak'),
key: "clientSecret",
type: "password",
label: t("provider.oauth2.keycloak.clientSecret.label", "Client Secret"),
description: t("provider.oauth2.keycloak.clientSecret.description", "The OAuth2 client secret from Keycloak"),
},
{
key: 'scopes',
type: 'text',
label: t('provider.oauth2.keycloak.scopes.label', 'Scopes'),
description: t('provider.oauth2.keycloak.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'openid, profile, email',
key: "scopes",
type: "text",
label: t("provider.oauth2.keycloak.scopes.label", "Scopes"),
description: t("provider.oauth2.keycloak.scopes.description", "Comma-separated OAuth2 scopes"),
defaultValue: "openid, profile, email",
},
{
key: 'useAsUsername',
type: 'text',
label: t('provider.oauth2.keycloak.useAsUsername.label', 'Use as Username'),
description: t('provider.oauth2.keycloak.useAsUsername.description', 'Field to use as username (email, name, given_name, family_name, preferred_username)'),
defaultValue: 'preferred_username',
key: "useAsUsername",
type: "text",
label: t("provider.oauth2.keycloak.useAsUsername.label", "Use as Username"),
description: t(
"provider.oauth2.keycloak.useAsUsername.description",
"Field to use as username (email, name, given_name, family_name, preferred_username)",
),
defaultValue: "preferred_username",
},
],
};
@@ -158,73 +173,82 @@ const useGenericOAuth2Provider = (): Provider => {
const { t } = useTranslation();
return {
id: 'oauth2-generic',
name: t('provider.oauth2.generic.name', 'Generic OAuth2'),
icon: 'link-rounded',
type: 'oauth2',
scope: t('provider.oauth2.generic.scope', 'SSO'),
id: "oauth2-generic",
name: t("provider.oauth2.generic.name", "Generic OAuth2"),
icon: "link-rounded",
type: "oauth2",
scope: t("provider.oauth2.generic.scope", "SSO"),
businessTier: false,
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration',
documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration",
fields: [
{
key: 'enabled',
type: 'switch',
label: t('provider.oauth2.generic.enabled.label', 'Enable Generic OAuth2'),
description: t('provider.oauth2.generic.enabled.description', 'Enable authentication using a custom OAuth2 provider'),
key: "enabled",
type: "switch",
label: t("provider.oauth2.generic.enabled.label", "Enable Generic OAuth2"),
description: t("provider.oauth2.generic.enabled.description", "Enable authentication using a custom OAuth2 provider"),
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: t('provider.oauth2.generic.provider.label', 'Provider Name'),
description: t('provider.oauth2.generic.provider.description', 'The name of your OAuth2 provider (e.g., Azure AD, Okta)'),
placeholder: 'azure-ad',
key: "provider",
type: "text",
label: t("provider.oauth2.generic.provider.label", "Provider Name"),
description: t(
"provider.oauth2.generic.provider.description",
"The name of your OAuth2 provider (e.g., Azure AD, Okta)",
),
placeholder: "azure-ad",
},
{
key: 'issuer',
type: 'text',
label: t('provider.oauth2.generic.issuer.label', 'Issuer URL'),
description: t('provider.oauth2.generic.issuer.description', 'Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration)'),
placeholder: 'https://login.microsoftonline.com/{tenant-id}/v2.0',
key: "issuer",
type: "text",
label: t("provider.oauth2.generic.issuer.label", "Issuer URL"),
description: t(
"provider.oauth2.generic.issuer.description",
"Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration)",
),
placeholder: "https://login.microsoftonline.com/{tenant-id}/v2.0",
},
{
key: 'clientId',
type: 'text',
label: t('provider.oauth2.generic.clientId.label', 'Client ID'),
description: t('provider.oauth2.generic.clientId.description', 'The OAuth2 client ID from your provider'),
key: "clientId",
type: "text",
label: t("provider.oauth2.generic.clientId.label", "Client ID"),
description: t("provider.oauth2.generic.clientId.description", "The OAuth2 client ID from your provider"),
},
{
key: 'clientSecret',
type: 'password',
label: t('provider.oauth2.generic.clientSecret.label', 'Client Secret'),
description: t('provider.oauth2.generic.clientSecret.description', 'The OAuth2 client secret from your provider'),
key: "clientSecret",
type: "password",
label: t("provider.oauth2.generic.clientSecret.label", "Client Secret"),
description: t("provider.oauth2.generic.clientSecret.description", "The OAuth2 client secret from your provider"),
},
{
key: 'scopes',
type: 'text',
label: t('provider.oauth2.generic.scopes.label', 'Scopes'),
description: t('provider.oauth2.generic.scopes.description', 'Comma-separated OAuth2 scopes'),
defaultValue: 'openid, profile, email',
key: "scopes",
type: "text",
label: t("provider.oauth2.generic.scopes.label", "Scopes"),
description: t("provider.oauth2.generic.scopes.description", "Comma-separated OAuth2 scopes"),
defaultValue: "openid, profile, email",
},
{
key: 'useAsUsername',
type: 'text',
label: t('provider.oauth2.generic.useAsUsername.label', 'Use as Username'),
description: t('provider.oauth2.generic.useAsUsername.description', 'Field to use as username'),
defaultValue: 'email',
key: "useAsUsername",
type: "text",
label: t("provider.oauth2.generic.useAsUsername.label", "Use as Username"),
description: t("provider.oauth2.generic.useAsUsername.description", "Field to use as username"),
defaultValue: "email",
},
{
key: 'autoCreateUser',
type: 'switch',
label: t('provider.oauth2.generic.autoCreateUser.label', 'Auto Create Users'),
description: t('provider.oauth2.generic.autoCreateUser.description', 'Automatically create user accounts on first OAuth2 login'),
key: "autoCreateUser",
type: "switch",
label: t("provider.oauth2.generic.autoCreateUser.label", "Auto Create Users"),
description: t(
"provider.oauth2.generic.autoCreateUser.description",
"Automatically create user accounts on first OAuth2 login",
),
defaultValue: true,
},
{
key: 'blockRegistration',
type: 'switch',
label: t('provider.oauth2.generic.blockRegistration.label', 'Block Registration'),
description: t('provider.oauth2.generic.blockRegistration.description', 'Prevent new user registration via OAuth2'),
key: "blockRegistration",
type: "switch",
label: t("provider.oauth2.generic.blockRegistration.label", "Block Registration"),
description: t("provider.oauth2.generic.blockRegistration.description", "Prevent new user registration via OAuth2"),
defaultValue: false,
},
],
@@ -235,53 +259,53 @@ const useSMTPProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'smtp',
name: t('provider.smtp.name', 'SMTP Mail'),
icon: 'mail-rounded',
type: 'oauth2',
scope: t('provider.smtp.scope', 'Email Notifications'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/System%20and%20Security/#email-configuration',
id: "smtp",
name: t("provider.smtp.name", "SMTP Mail"),
icon: "mail-rounded",
type: "oauth2",
scope: t("provider.smtp.scope", "Email Notifications"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/System%20and%20Security/#email-configuration",
fields: [
{
key: 'enabled',
type: 'switch',
label: t('provider.smtp.enabled.label', 'Enable Mail'),
description: t('provider.smtp.enabled.description', 'Enable email notifications and SMTP functionality'),
key: "enabled",
type: "switch",
label: t("provider.smtp.enabled.label", "Enable Mail"),
description: t("provider.smtp.enabled.description", "Enable email notifications and SMTP functionality"),
defaultValue: false,
},
{
key: 'host',
type: 'text',
label: t('provider.smtp.host.label', 'SMTP Host'),
description: t('provider.smtp.host.description', 'The hostname or IP address of your SMTP server'),
placeholder: 'smtp.example.com',
key: "host",
type: "text",
label: t("provider.smtp.host.label", "SMTP Host"),
description: t("provider.smtp.host.description", "The hostname or IP address of your SMTP server"),
placeholder: "smtp.example.com",
},
{
key: 'port',
type: 'number',
label: t('provider.smtp.port.label', 'SMTP Port'),
description: t('provider.smtp.port.description', 'The port number for SMTP connection (typically 25, 465, or 587)'),
placeholder: '587',
defaultValue: '587',
key: "port",
type: "number",
label: t("provider.smtp.port.label", "SMTP Port"),
description: t("provider.smtp.port.description", "The port number for SMTP connection (typically 25, 465, or 587)"),
placeholder: "587",
defaultValue: "587",
},
{
key: 'username',
type: 'text',
label: t('provider.smtp.username.label', 'SMTP Username'),
description: t('provider.smtp.username.description', 'Username for SMTP authentication'),
key: "username",
type: "text",
label: t("provider.smtp.username.label", "SMTP Username"),
description: t("provider.smtp.username.description", "Username for SMTP authentication"),
},
{
key: 'password',
type: 'password',
label: t('provider.smtp.password.label', 'SMTP Password'),
description: t('provider.smtp.password.description', 'Password for SMTP authentication'),
key: "password",
type: "password",
label: t("provider.smtp.password.label", "SMTP Password"),
description: t("provider.smtp.password.description", "Password for SMTP authentication"),
},
{
key: 'from',
type: 'text',
label: t('provider.smtp.from.label', 'From Address'),
description: t('provider.smtp.from.description', 'The email address to use as the sender'),
placeholder: '[email protected]',
key: "from",
type: "text",
label: t("provider.smtp.from.label", "From Address"),
description: t("provider.smtp.from.description", "The email address to use as the sender"),
placeholder: "[email protected]",
},
],
};
@@ -290,206 +314,191 @@ const useTelegramProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'telegram',
name: t('admin.settings.telegram.title', 'Telegram Bot'),
icon: 'send-rounded',
type: 'telegram',
id: "telegram",
name: t("admin.settings.telegram.title", "Telegram Bot"),
icon: "send-rounded",
type: "telegram",
scope: t(
'admin.settings.telegram.description',
'Configure Telegram bot connectivity, access controls, and feedback behavior.'
"admin.settings.telegram.description",
"Configure Telegram bot connectivity, access controls, and feedback behavior.",
),
fields: [
{
key: 'enabled',
type: 'switch',
label: t('admin.settings.telegram.enabled.label', 'Enable Telegram Bot'),
key: "enabled",
type: "switch",
label: t("admin.settings.telegram.enabled.label", "Enable Telegram Bot"),
description: t(
'admin.settings.telegram.enabled.description',
'Allow users to interact with Stirling PDF through your configured Telegram bot.'
"admin.settings.telegram.enabled.description",
"Allow users to interact with Stirling PDF through your configured Telegram bot.",
),
defaultValue: false,
},
{
key: 'botUsername',
type: 'text',
label: t('admin.settings.telegram.botUsername.label', 'Bot Username'),
description: t(
'admin.settings.telegram.botUsername.description',
'The public username of your Telegram bot.'
),
placeholder: 'my_pdf_bot',
key: "botUsername",
type: "text",
label: t("admin.settings.telegram.botUsername.label", "Bot Username"),
description: t("admin.settings.telegram.botUsername.description", "The public username of your Telegram bot."),
placeholder: "my_pdf_bot",
},
{
key: 'botToken',
type: 'password',
label: t('admin.settings.telegram.botToken.label', 'Bot Token'),
key: "botToken",
type: "password",
label: t("admin.settings.telegram.botToken.label", "Bot Token"),
description: t(
'admin.settings.telegram.botToken.description',
'API token provided by BotFather for your Telegram bot.'
"admin.settings.telegram.botToken.description",
"API token provided by BotFather for your Telegram bot.",
),
placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
placeholder: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
},
{
key: 'pipelineInboxFolder',
type: 'text',
label: t('admin.settings.telegram.pipelineInboxFolder.label', 'Inbox Folder'),
key: "pipelineInboxFolder",
type: "text",
label: t("admin.settings.telegram.pipelineInboxFolder.label", "Inbox Folder"),
description: t(
'admin.settings.telegram.pipelineInboxFolder.description',
'Folder under the pipeline directory where incoming Telegram files are stored.'
"admin.settings.telegram.pipelineInboxFolder.description",
"Folder under the pipeline directory where incoming Telegram files are stored.",
),
placeholder: 'telegram',
placeholder: "telegram",
},
{
key: 'customFolderSuffix',
type: 'switch',
label: t('admin.settings.telegram.customFolderSuffix.label', 'Use Custom Folder Suffix'),
key: "customFolderSuffix",
type: "switch",
label: t("admin.settings.telegram.customFolderSuffix.label", "Use Custom Folder Suffix"),
description: t(
'admin.settings.telegram.customFolderSuffix.description',
'Append the chat ID to incoming file folders to isolate uploads per chat.'
"admin.settings.telegram.customFolderSuffix.description",
"Append the chat ID to incoming file folders to isolate uploads per chat.",
),
defaultValue: false,
},
{
key: 'enableAllowUserIDs',
type: 'switch',
label: t('admin.settings.telegram.enableAllowUserIDs.label', 'Allow Specific User IDs'),
key: "enableAllowUserIDs",
type: "switch",
label: t("admin.settings.telegram.enableAllowUserIDs.label", "Allow Specific User IDs"),
description: t(
'admin.settings.telegram.enableAllowUserIDs.description',
'When enabled, only listed user IDs can use the bot.'
"admin.settings.telegram.enableAllowUserIDs.description",
"When enabled, only listed user IDs can use the bot.",
),
defaultValue: false,
},
{
key: 'allowUserIDs',
type: 'tags',
label: t('admin.settings.telegram.allowUserIDs.label', 'Allowed User IDs'),
key: "allowUserIDs",
type: "tags",
label: t("admin.settings.telegram.allowUserIDs.label", "Allowed User IDs"),
description: t(
'admin.settings.telegram.allowUserIDs.description',
'Enter Telegram user IDs allowed to interact with the bot.'
"admin.settings.telegram.allowUserIDs.description",
"Enter Telegram user IDs allowed to interact with the bot.",
),
placeholder: t('admin.settings.telegram.allowUserIDs.placeholder', 'Add user ID and press enter'),
placeholder: t("admin.settings.telegram.allowUserIDs.placeholder", "Add user ID and press enter"),
defaultValue: [],
},
{
key: 'enableAllowChannelIDs',
type: 'switch',
label: t('admin.settings.telegram.enableAllowChannelIDs.label', 'Allow Specific Channel IDs'),
key: "enableAllowChannelIDs",
type: "switch",
label: t("admin.settings.telegram.enableAllowChannelIDs.label", "Allow Specific Channel IDs"),
description: t(
'admin.settings.telegram.enableAllowChannelIDs.description',
'When enabled, only listed channel IDs can use the bot.'
"admin.settings.telegram.enableAllowChannelIDs.description",
"When enabled, only listed channel IDs can use the bot.",
),
defaultValue: false,
},
{
key: 'allowChannelIDs',
type: 'tags',
label: t('admin.settings.telegram.allowChannelIDs.label', 'Allowed Channel IDs'),
key: "allowChannelIDs",
type: "tags",
label: t("admin.settings.telegram.allowChannelIDs.label", "Allowed Channel IDs"),
description: t(
'admin.settings.telegram.allowChannelIDs.description',
'Enter Telegram channel IDs allowed to interact with the bot.'
"admin.settings.telegram.allowChannelIDs.description",
"Enter Telegram channel IDs allowed to interact with the bot.",
),
placeholder: t('admin.settings.telegram.allowChannelIDs.placeholder', 'Add channel ID and press enter'),
placeholder: t("admin.settings.telegram.allowChannelIDs.placeholder", "Add channel ID and press enter"),
defaultValue: [],
},
{
key: 'processingTimeoutSeconds',
type: 'number',
label: t(
'admin.settings.telegram.processingTimeoutSeconds.label',
'Processing Timeout (seconds)'
),
key: "processingTimeoutSeconds",
type: "number",
label: t("admin.settings.telegram.processingTimeoutSeconds.label", "Processing Timeout (seconds)"),
description: t(
'admin.settings.telegram.processingTimeoutSeconds.description',
'Maximum time to wait for a processing job before reporting an error.'
"admin.settings.telegram.processingTimeoutSeconds.description",
"Maximum time to wait for a processing job before reporting an error.",
),
defaultValue: 180,
},
{
key: 'pollingIntervalMillis',
type: 'number',
label: t('admin.settings.telegram.pollingIntervalMillis.label', 'Polling Interval (ms)'),
key: "pollingIntervalMillis",
type: "number",
label: t("admin.settings.telegram.pollingIntervalMillis.label", "Polling Interval (ms)"),
description: t(
'admin.settings.telegram.pollingIntervalMillis.description',
'Interval between checks for new Telegram updates.'
"admin.settings.telegram.pollingIntervalMillis.description",
"Interval between checks for new Telegram updates.",
),
defaultValue: 2000,
},
{
key: 'feedback.general.enabled',
type: 'switch',
label: t('admin.settings.telegram.feedback.general.enabled.label', 'Enable Feedback'),
key: "feedback.general.enabled",
type: "switch",
label: t("admin.settings.telegram.feedback.general.enabled.label", "Enable Feedback"),
description: t(
'admin.settings.telegram.feedback.general.enabled.description',
'Control whether the bot sends feedback messages at all.'
"admin.settings.telegram.feedback.general.enabled.description",
"Control whether the bot sends feedback messages at all.",
),
defaultValue: true,
},
{
key: 'feedback.channel.noValidDocument',
type: 'switch',
label: t(
'admin.settings.telegram.feedback.channel.noValidDocument.label',
'Show "No valid document" (Channel)'
),
key: "feedback.channel.noValidDocument",
type: "switch",
label: t("admin.settings.telegram.feedback.channel.noValidDocument.label", 'Show "No valid document" (Channel)'),
description: t(
'admin.settings.telegram.feedback.channel.noValidDocument.description',
'Suppress the no valid document response for channel uploads.'
"admin.settings.telegram.feedback.channel.noValidDocument.description",
"Suppress the no valid document response for channel uploads.",
),
defaultValue: false,
},
{
key: 'feedback.channel.errorProcessing',
type: 'switch',
label: t(
'admin.settings.telegram.feedback.channel.errorProcessing.label',
'Show processing errors (Channel)'
),
key: "feedback.channel.errorProcessing",
type: "switch",
label: t("admin.settings.telegram.feedback.channel.errorProcessing.label", "Show processing errors (Channel)"),
description: t(
'admin.settings.telegram.feedback.channel.errorProcessing.description',
'Send processing error messages to channels.'
"admin.settings.telegram.feedback.channel.errorProcessing.description",
"Send processing error messages to channels.",
),
defaultValue: false,
},
{
key: 'feedback.channel.errorMessage',
type: 'switch',
label: t(
'admin.settings.telegram.feedback.channel.errorMessage.label',
'Show error messages (Channel)'
),
key: "feedback.channel.errorMessage",
type: "switch",
label: t("admin.settings.telegram.feedback.channel.errorMessage.label", "Show error messages (Channel)"),
description: t(
'admin.settings.telegram.feedback.channel.errorMessage.description',
'Show detailed error messages for channels.'
"admin.settings.telegram.feedback.channel.errorMessage.description",
"Show detailed error messages for channels.",
),
defaultValue: false,
},
{
key: 'feedback.user.noValidDocument',
type: 'switch',
label: t('admin.settings.telegram.feedback.user.noValidDocument.label', 'Show "No valid document" (User)'),
key: "feedback.user.noValidDocument",
type: "switch",
label: t("admin.settings.telegram.feedback.user.noValidDocument.label", 'Show "No valid document" (User)'),
description: t(
'admin.settings.telegram.feedback.user.noValidDocument.description',
'Suppress the no valid document response for user uploads.'
"admin.settings.telegram.feedback.user.noValidDocument.description",
"Suppress the no valid document response for user uploads.",
),
defaultValue: false,
},
{
key: 'feedback.user.errorProcessing',
type: 'switch',
label: t('admin.settings.telegram.feedback.user.errorProcessing.label', 'Show processing errors (User)'),
key: "feedback.user.errorProcessing",
type: "switch",
label: t("admin.settings.telegram.feedback.user.errorProcessing.label", "Show processing errors (User)"),
description: t(
'admin.settings.telegram.feedback.user.errorProcessing.description',
'Send processing error messages to users.'
"admin.settings.telegram.feedback.user.errorProcessing.description",
"Send processing error messages to users.",
),
defaultValue: false,
},
{
key: 'feedback.user.errorMessage',
type: 'switch',
label: t('admin.settings.telegram.feedback.user.errorMessage.label', 'Show error messages (User)'),
key: "feedback.user.errorMessage",
type: "switch",
label: t("admin.settings.telegram.feedback.user.errorMessage.label", "Show error messages (User)"),
description: t(
'admin.settings.telegram.feedback.user.errorMessage.description',
'Show detailed error messages for users.'
"admin.settings.telegram.feedback.user.errorMessage.description",
"Show detailed error messages for users.",
),
defaultValue: false,
},
@@ -501,94 +510,94 @@ const useSAML2Provider = (): Provider => {
const { t } = useTranslation();
return {
id: 'saml2',
name: t('provider.saml2.name', 'SAML2'),
icon: 'verified-user-rounded',
type: 'saml2',
scope: t('provider.saml2.scope', 'SSO (SAML)'),
id: "saml2",
name: t("provider.saml2.name", "SAML2"),
icon: "verified-user-rounded",
type: "saml2",
scope: t("provider.saml2.scope", "SSO (SAML)"),
businessTier: true,
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/SAML%20SSO%20Configuration/',
documentationUrl: "https://docs.stirlingpdf.com/Configuration/SAML%20SSO%20Configuration/",
fields: [
{
key: 'enabled',
type: 'switch',
label: t('provider.saml2.enabled.label', 'Enable SAML2'),
description: t('provider.saml2.enabled.description', 'Enable SAML2 authentication (Enterprise only)'),
key: "enabled",
type: "switch",
label: t("provider.saml2.enabled.label", "Enable SAML2"),
description: t("provider.saml2.enabled.description", "Enable SAML2 authentication (Enterprise only)"),
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: t('provider.saml2.provider.label', 'Provider Name'),
description: t('provider.saml2.provider.description', 'The name of your SAML2 provider'),
key: "provider",
type: "text",
label: t("provider.saml2.provider.label", "Provider Name"),
description: t("provider.saml2.provider.description", "The name of your SAML2 provider"),
},
{
key: 'registrationId',
type: 'text',
label: t('provider.saml2.registrationId.label', 'Registration ID'),
description: t('provider.saml2.registrationId.description', 'The name of your Service Provider (SP) app name'),
defaultValue: 'stirling',
key: "registrationId",
type: "text",
label: t("provider.saml2.registrationId.label", "Registration ID"),
description: t("provider.saml2.registrationId.description", "The name of your Service Provider (SP) app name"),
defaultValue: "stirling",
},
{
key: 'idpMetadataUri',
type: 'text',
label: t('provider.saml2.idpMetadataUri.label', 'IDP Metadata URI'),
description: t('provider.saml2.idpMetadataUri.description', 'The URI for your provider\'s metadata'),
placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata',
key: "idpMetadataUri",
type: "text",
label: t("provider.saml2.idpMetadataUri.label", "IDP Metadata URI"),
description: t("provider.saml2.idpMetadataUri.description", "The URI for your provider's metadata"),
placeholder: "https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata",
},
{
key: 'idpSingleLoginUrl',
type: 'text',
label: t('provider.saml2.idpSingleLoginUrl.label', 'IDP Single Login URL'),
description: t('provider.saml2.idpSingleLoginUrl.description', 'The URL for initiating SSO'),
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml',
key: "idpSingleLoginUrl",
type: "text",
label: t("provider.saml2.idpSingleLoginUrl.label", "IDP Single Login URL"),
description: t("provider.saml2.idpSingleLoginUrl.description", "The URL for initiating SSO"),
placeholder: "https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml",
},
{
key: 'idpSingleLogoutUrl',
type: 'text',
label: t('provider.saml2.idpSingleLogoutUrl.label', 'IDP Single Logout URL'),
description: t('provider.saml2.idpSingleLogoutUrl.description', 'The URL for initiating SLO'),
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml',
key: "idpSingleLogoutUrl",
type: "text",
label: t("provider.saml2.idpSingleLogoutUrl.label", "IDP Single Logout URL"),
description: t("provider.saml2.idpSingleLogoutUrl.description", "The URL for initiating SLO"),
placeholder: "https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml",
},
{
key: 'idpIssuer',
type: 'text',
label: t('provider.saml2.idpIssuer.label', 'IDP Issuer'),
description: t('provider.saml2.idpIssuer.description', 'The ID of your provider'),
key: "idpIssuer",
type: "text",
label: t("provider.saml2.idpIssuer.label", "IDP Issuer"),
description: t("provider.saml2.idpIssuer.description", "The ID of your provider"),
},
{
key: 'idpCert',
type: 'text',
label: t('provider.saml2.idpCert.label', 'IDP Certificate'),
description: t('provider.saml2.idpCert.description', 'The certificate path (e.g., classpath:okta.cert)'),
placeholder: 'classpath:okta.cert',
key: "idpCert",
type: "text",
label: t("provider.saml2.idpCert.label", "IDP Certificate"),
description: t("provider.saml2.idpCert.description", "The certificate path (e.g., classpath:okta.cert)"),
placeholder: "classpath:okta.cert",
},
{
key: 'privateKey',
type: 'text',
label: t('provider.saml2.privateKey.label', 'Private Key'),
description: t('provider.saml2.privateKey.description', 'Your private key path'),
placeholder: 'classpath:saml-private-key.key',
key: "privateKey",
type: "text",
label: t("provider.saml2.privateKey.label", "Private Key"),
description: t("provider.saml2.privateKey.description", "Your private key path"),
placeholder: "classpath:saml-private-key.key",
},
{
key: 'spCert',
type: 'text',
label: t('provider.saml2.spCert.label', 'SP Certificate'),
description: t('provider.saml2.spCert.description', 'Your signing certificate path'),
placeholder: 'classpath:saml-public-cert.crt',
key: "spCert",
type: "text",
label: t("provider.saml2.spCert.label", "SP Certificate"),
description: t("provider.saml2.spCert.description", "Your signing certificate path"),
placeholder: "classpath:saml-public-cert.crt",
},
{
key: 'autoCreateUser',
type: 'switch',
label: t('provider.saml2.autoCreateUser.label', 'Auto Create Users'),
description: t('provider.saml2.autoCreateUser.description', 'Automatically create user accounts on first SAML2 login'),
key: "autoCreateUser",
type: "switch",
label: t("provider.saml2.autoCreateUser.label", "Auto Create Users"),
description: t("provider.saml2.autoCreateUser.description", "Automatically create user accounts on first SAML2 login"),
defaultValue: true,
},
{
key: 'blockRegistration',
type: 'switch',
label: t('provider.saml2.blockRegistration.label', 'Block Registration'),
description: t('provider.saml2.blockRegistration.description', 'Prevent new user registration via SAML2'),
key: "blockRegistration",
type: "switch",
label: t("provider.saml2.blockRegistration.label", "Block Registration"),
description: t("provider.saml2.blockRegistration.description", "Prevent new user registration via SAML2"),
defaultValue: false,
},
],
@@ -599,40 +608,43 @@ const useGoogleDriveProvider = (): Provider => {
const { t } = useTranslation();
return {
id: 'googledrive',
name: t('provider.googledrive.name', 'Google Drive'),
icon: '/images/google-drive.svg',
type: 'googledrive',
scope: t('provider.googledrive.scope', 'File Import'),
documentationUrl: 'https://docs.stirlingpdf.com/Configuration/Google%20Drive%20File%20Picker/',
id: "googledrive",
name: t("provider.googledrive.name", "Google Drive"),
icon: "/images/google-drive.svg",
type: "googledrive",
scope: t("provider.googledrive.scope", "File Import"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/Google%20Drive%20File%20Picker/",
fields: [
{
key: 'enabled',
type: 'switch',
label: t('provider.googledrive.enabled.label', 'Enable Google Drive File Picker'),
description: t('provider.googledrive.enabled.description', 'Allow users to import files directly from Google Drive'),
key: "enabled",
type: "switch",
label: t("provider.googledrive.enabled.label", "Enable Google Drive File Picker"),
description: t("provider.googledrive.enabled.description", "Allow users to import files directly from Google Drive"),
defaultValue: false,
},
{
key: 'clientId',
type: 'text',
label: t('provider.googledrive.clientId.label', 'Client ID'),
description: t('provider.googledrive.clientId.description', 'Google OAuth 2.0 Client ID from Google Cloud Console'),
placeholder: 'xxx.apps.googleusercontent.com',
key: "clientId",
type: "text",
label: t("provider.googledrive.clientId.label", "Client ID"),
description: t("provider.googledrive.clientId.description", "Google OAuth 2.0 Client ID from Google Cloud Console"),
placeholder: "xxx.apps.googleusercontent.com",
},
{
key: 'apiKey',
type: 'text',
label: t('provider.googledrive.apiKey.label', 'API Key'),
description: t('provider.googledrive.apiKey.description', 'Google API Key for Google Picker API from Google Cloud Console'),
placeholder: 'AIza...',
key: "apiKey",
type: "text",
label: t("provider.googledrive.apiKey.label", "API Key"),
description: t(
"provider.googledrive.apiKey.description",
"Google API Key for Google Picker API from Google Cloud Console",
),
placeholder: "AIza...",
},
{
key: 'appId',
type: 'text',
label: t('provider.googledrive.appId.label', 'App ID'),
description: t('provider.googledrive.appId.description', 'Google Drive App ID from Google Cloud Console'),
placeholder: 'xxxxxxxxxxxxx',
key: "appId",
type: "text",
label: t("provider.googledrive.appId.label", "App ID"),
description: t("provider.googledrive.appId.description", "Google Drive App ID from Google Cloud Console"),
placeholder: "xxxxxxxxxxxxx",
},
],
};
@@ -1,38 +1,38 @@
// Single source of truth for all valid nav keys
export const VALID_NAV_KEYS = [
'preferences',
'notifications',
'connections',
'account',
'general',
'people',
'teams',
'security',
'identity',
'plan',
'payments',
'requests',
'developer',
'api-keys',
'hotkeys',
'adminGeneral',
'adminSecurity',
'adminConnections',
'adminTelegram',
'adminPrivacy',
'adminDatabase',
'adminAdvanced',
'adminLegal',
'adminPremium',
'adminFeatures',
'adminPlan',
'adminAudit',
'adminUsage',
'adminEndpoints',
'adminStorageSharing',
"preferences",
"notifications",
"connections",
"account",
"general",
"people",
"teams",
"security",
"identity",
"plan",
"payments",
"requests",
"developer",
"api-keys",
"hotkeys",
"adminGeneral",
"adminSecurity",
"adminConnections",
"adminTelegram",
"adminPrivacy",
"adminDatabase",
"adminAdvanced",
"adminLegal",
"adminPremium",
"adminFeatures",
"adminPlan",
"adminAudit",
"adminUsage",
"adminEndpoints",
"adminStorageSharing",
] as const;
// Derive the type from the array
export type NavKey = typeof VALID_NAV_KEYS[number];
export type NavKey = (typeof VALID_NAV_KEYS)[number];
// some of these are not used yet, but appear in figma designs
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
import apiClient from '@app/services/apiClient';
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import apiClient from "@app/services/apiClient";
export function useRestartServer() {
const { t } = useTranslation();
@@ -18,33 +18,28 @@ export function useRestartServer() {
const restartServer = async () => {
setRestartModalOpened(false);
await apiClient.post('/api/v1/admin/settings/restart', undefined, {
suppressErrorToast: true,
})
.then(() => {
alert({
alertType: 'neutral',
title: t('admin.settings.restarting', 'Restarting Server'),
body: t(
'admin.settings.restartingMessage',
'The server is restarting. Please wait a moment...'
),
await apiClient
.post("/api/v1/admin/settings/restart", undefined, {
suppressErrorToast: true,
})
.then(() => {
alert({
alertType: "neutral",
title: t("admin.settings.restarting", "Restarting Server"),
body: t("admin.settings.restartingMessage", "The server is restarting. Please wait a moment..."),
});
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
})
.catch(async (_error) => {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.restartError", "Failed to restart server. Please restart manually."),
});
});
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
})
.catch(async (_error) => {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t(
'admin.settings.restartError',
'Failed to restart server. Please restart manually.'
),
});
})
};
return {
@@ -1,65 +1,64 @@
import React from 'react';
import { Box } from '@mantine/core';
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 DocumentStack: React.FC<DocumentStackProps> = ({ totalFiles, children }) => {
const stackDocumentBaseStyle = {
position: 'absolute' as const,
width: '100%',
height: '100%'
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)'
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%' }}>
<Box style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Background documents (stack effect) */}
{totalFiles >= 3 && (
<Box
style={{
...stackDocumentBaseStyle,
backgroundColor: 'var(--mantine-color-gray-3)',
backgroundColor: "var(--mantine-color-gray-3)",
boxShadow: stackDocumentShadows.back,
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
zIndex: 1
transform: "translate(0.75rem, 0.75rem) rotate(2deg)",
zIndex: 1,
}}
/>
)}
{totalFiles >= 2 && (
<Box
style={{
...stackDocumentBaseStyle,
backgroundColor: 'var(--mantine-color-gray-2)',
backgroundColor: "var(--mantine-color-gray-2)",
boxShadow: stackDocumentShadows.middle,
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
zIndex: 2
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)'
}}>
<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;
export default DocumentStack;
@@ -1,8 +1,8 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import { getFileTypeIcon } from '@app/components/shared/filePreview/getFileTypeIcon';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React from "react";
import { Box, Center, Image } from "@mantine/core";
import { getFileTypeIcon } from "@app/components/shared/filePreview/getFileTypeIcon";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
export interface DocumentThumbnailProps {
file: File | StirlingFileStub | null;
@@ -12,25 +12,19 @@ export interface DocumentThumbnailProps {
children?: React.ReactNode;
}
const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
file,
thumbnail,
style = {},
onClick,
children
}) => {
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
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) {
@@ -41,11 +35,11 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
style={{
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto'
style={{
maxWidth: "100%",
maxHeight: "100%",
width: "auto",
height: "auto",
}}
/>
</PrivateContent>
@@ -56,10 +50,10 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
<PrivateContent>
{getFileTypeIcon(file)}
</PrivateContent>
<Center
style={{ width: "100%", height: "100%", backgroundColor: "var(--mantine-color-gray-1)", borderRadius: "0.25rem" }}
>
<PrivateContent>{getFileTypeIcon(file)}</PrivateContent>
</Center>
{children}
</Box>
@@ -1,6 +1,6 @@
import React from 'react';
import { Box } from '@mantine/core';
import VisibilityIcon from '@mui/icons-material/Visibility';
import React from "react";
import { Box } from "@mantine/core";
import VisibilityIcon from "@mui/icons-material/Visibility";
export interface HoverOverlayProps {
onMouseEnter?: (e: React.MouseEvent) => void;
@@ -8,56 +8,52 @@ export interface HoverOverlayProps {
children: React.ReactNode;
}
const HoverOverlay: React.FC<HoverOverlayProps> = ({
onMouseEnter,
onMouseLeave,
children
}) => {
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 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';
const overlay = e.currentTarget.querySelector(".hover-overlay") as HTMLElement;
if (overlay) overlay.style.opacity = "0";
};
return (
<Box
style={{
position: 'relative',
width: '100%',
height: '100%'
position: "relative",
width: "100%",
height: "100%",
}}
onMouseEnter={onMouseEnter || defaultMouseEnter}
onMouseLeave={onMouseLeave || defaultMouseLeave}
>
{children}
{/* Hover overlay */}
<Box
className="hover-overlay"
style={{
position: 'absolute',
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',
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'
transition: "opacity 0.2s ease",
pointerEvents: "none",
}}
>
<VisibilityIcon style={{ color: 'white', fontSize: '1.5rem' }} />
<VisibilityIcon style={{ color: "white", fontSize: "1.5rem" }} />
</Box>
</Box>
);
};
export default HoverOverlay;
export default HoverOverlay;
@@ -1,7 +1,7 @@
import React from 'react';
import { Box, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
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;
@@ -10,21 +10,16 @@ export interface NavigationArrowsProps {
children: React.ReactNode;
}
const NavigationArrows: React.FC<NavigationArrowsProps> = ({
onPrevious,
onNext,
disabled = false,
children
}) => {
const NavigationArrows: React.FC<NavigationArrowsProps> = ({ onPrevious, onNext, disabled = false, children }) => {
const navigationArrowStyle = {
position: 'absolute' as const,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10
position: "absolute" as const,
top: "50%",
transform: "translateY(-50%)",
zIndex: 10,
};
return (
<Box style={{ position: 'relative', width: '100%', height: '100%' }}>
<Box style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Left Navigation Arrow */}
<ActionIcon
variant="light"
@@ -34,17 +29,17 @@ const NavigationArrows: React.FC<NavigationArrowsProps> = ({
disabled={disabled}
style={{
...navigationArrowStyle,
left: '0'
left: "0",
}}
>
<ChevronLeftIcon />
</ActionIcon>
{/* Content */}
<Box style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
{children}
</Box>
{/* Right Navigation Arrow */}
<ActionIcon
variant="light"
@@ -54,7 +49,7 @@ const NavigationArrows: React.FC<NavigationArrowsProps> = ({
disabled={disabled}
style={{
...navigationArrowStyle,
right: '0'
right: "0",
}}
>
<ChevronRightIcon />
@@ -63,4 +58,4 @@ const NavigationArrows: React.FC<NavigationArrowsProps> = ({
);
};
export default NavigationArrows;
export default NavigationArrows;
@@ -1,4 +1,4 @@
import { RefObject, useEffect } from 'react';
import { RefObject, useEffect } from "react";
export type AdjustFontSizeOptions = {
/** Max font size to start from. Defaults to the element's computed font size. */
@@ -17,14 +17,11 @@ export type AdjustFontSizeOptions = {
* 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 {
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 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;
@@ -32,14 +29,14 @@ export function adjustFontSizeToFit(
// Ensure measurement is consistent
if (singleLine) {
element.style.whiteSpace = 'nowrap';
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';
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';
element.style.setProperty("hyphens", "manual");
element.style.overflow = "visible";
const minFontPx = baseFontPx * minScale;
const stepPx = Math.max(0.5, baseFontPx * stepScale);
@@ -50,7 +47,7 @@ export function adjustFontSizeToFit(
// Calculate target height threshold for line limit
let maxHeight = Number.POSITIVE_INFINITY;
if (typeof maxLines === 'number' && maxLines > 0) {
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
@@ -82,21 +79,24 @@ export function adjustFontSizeToFit(
return () => {
cancelAnimationFrame(raf);
try { ro.disconnect(); } catch { /* Ignore errors */ }
try { mo.disconnect(); } catch { /* Ignore errors */ }
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 = {}
) {
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]);
}
@@ -1,7 +1,7 @@
import { useRef, useEffect, useState } from 'react';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { useRef, useEffect, useState } from "react";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { FileId } from '@app/types/file';
import { FileId } from "@app/types/file";
interface UseFileItemDragDropParams {
fileId: FileId;
@@ -13,7 +13,7 @@ interface UseFileItemDragDropReturn {
itemRef: React.RefObject<HTMLDivElement | null>;
isDragging: boolean;
isDragOver: boolean;
dropPosition: 'above' | 'below';
dropPosition: "above" | "below";
movedRef: React.MutableRefObject<boolean>;
startRef: React.MutableRefObject<{ x: number; y: number } | null>;
onPointerDown: (e: React.PointerEvent) => void;
@@ -25,26 +25,30 @@ interface UseFileItemDragDropReturn {
* Hook to handle drag and drop functionality for file items in a list.
* Manages drag state, drop zones, and reordering logic using Pragmatic Drag and Drop.
*/
export const useFileItemDragDrop = ({
fileId,
index,
onReorder,
}: UseFileItemDragDropParams): UseFileItemDragDropReturn => {
export const useFileItemDragDrop = ({ fileId, index, onReorder }: UseFileItemDragDropParams): UseFileItemDragDropReturn => {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
const [dropPosition, setDropPosition] = useState<"above" | "below">("below");
const itemRef = useRef<HTMLDivElement>(null);
// Keep latest values without re-registering DnD
const indexRef = useRef(index);
const fileIdRef = useRef(fileId);
const dropPositionRef = useRef<'above' | 'below'>('below');
const dropPositionRef = useRef<"above" | "below">("below");
const onReorderRef = useRef(onReorder);
useEffect(() => { indexRef.current = index; }, [index]);
useEffect(() => { fileIdRef.current = fileId; }, [fileId]);
useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]);
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
useEffect(() => {
indexRef.current = index;
}, [index]);
useEffect(() => {
fileIdRef.current = fileId;
}, [fileId]);
useEffect(() => {
dropPositionRef.current = dropPosition;
}, [dropPosition]);
useEffect(() => {
onReorderRef.current = onReorder;
}, [onReorder]);
// Gesture guard for row click vs drag
const movedRef = useRef(false);
@@ -73,7 +77,7 @@ export const useFileItemDragDrop = ({
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file-item',
type: "file-item",
fileId: fileIdRef.current,
fromIndex: indexRef.current,
}),
@@ -85,14 +89,14 @@ export const useFileItemDragDrop = ({
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file-item',
type: "file-item",
fileId: fileIdRef.current,
toIndex: indexRef.current,
}),
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
onDragLeave: () => {
setIsDragOver((p) => (p ? false : p));
setDropPosition('below');
setDropPosition("below");
},
onDrag: ({ source }) => {
// Determine drop position based on cursor location
@@ -103,26 +107,26 @@ export const useFileItemDragDrop = ({
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
const midpoint = rect.top + rect.height / 2;
setDropPosition(clientY < midpoint ? 'below' : 'above');
setDropPosition(clientY < midpoint ? "below" : "above");
},
onDrop: ({ source }) => {
setIsDragOver(false);
const dropPos = dropPositionRef.current;
setDropPosition('below');
setDropPosition("below");
const sourceData = source.data as any;
if (sourceData?.type === 'file-item') {
if (sourceData?.type === "file-item") {
const fromIndex = sourceData.fromIndex as number;
let toIndex = indexRef.current;
// Adjust toIndex based on drop position
if (dropPos === 'below' && fromIndex < toIndex) {
if (dropPos === "below" && fromIndex < toIndex) {
// Dragging down, drop after target - no adjustment needed
} else if (dropPos === 'above' && fromIndex > toIndex) {
} else if (dropPos === "above" && fromIndex > toIndex) {
// Dragging up, drop before target - no adjustment needed
} else if (dropPos === 'below' && fromIndex > toIndex) {
} else if (dropPos === "below" && fromIndex > toIndex) {
// Dragging up but want below target
toIndex = toIndex + 1;
} else if (dropPos === 'above' && fromIndex < toIndex) {
} else if (dropPos === "above" && fromIndex < toIndex) {
// Dragging down but want above target
toIndex = toIndex - 1;
}
@@ -131,12 +135,20 @@ export const useFileItemDragDrop = ({
onReorderRef.current(fromIndex, toIndex);
}
}
}
},
});
return () => {
try { dragCleanup(); } catch { /* cleanup */ }
try { dropCleanup(); } catch { /* cleanup */ }
try {
dragCleanup();
} catch {
/* cleanup */
}
try {
dropCleanup();
} catch {
/* cleanup */
}
};
}, []); // Stable - no dependencies
@@ -12,25 +12,25 @@
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
*/
import React, { useEffect, useRef, useState } from 'react';
import { ActionIcon, Divider } from '@mantine/core';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
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';
import React, { useEffect, useRef, useState } from "react";
import { ActionIcon, Divider } from "@mantine/core";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
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;
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
tooltipPosition?: "left" | "right" | "top" | "bottom";
}
const NAV_IDS = ['read', 'sign', 'automate'];
const NAV_IDS = ["read", "sign", "automate"];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = 'right' }) => {
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = "right" }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
@@ -40,8 +40,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
// Special case: multiTool should always show even when sidebars are hidden
const indicatorShouldShow = Boolean(
selectedToolKey &&
((leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)) ||
selectedToolKey === 'multiTool')
((leftPanelView === "toolContent" && !NAV_IDS.includes(selectedToolKey)) || selectedToolKey === "multiTool"),
);
// Local animation and hover state
@@ -54,7 +53,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
const animTimeoutRef = useRef<number | null>(null);
const replayRafRef = useRef<number | null>(null);
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey; };
const isSwitchingToNewTool = () => {
return prevKeyRef.current && prevKeyRef.current !== selectedToolKey;
};
const clearTimers = () => {
if (collapseTimeoutRef.current) {
@@ -139,12 +140,15 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
return (
<>
<div style={{ overflow: 'visible' }} className={`current-tool-slot ${indicatorVisible ? 'visible' : ''} ${replayAnim ? 'replay' : ''}`}>
<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}
content={isBackHover ? "Back to all tools" : indicatorTool.name}
position={tooltipPosition}
arrow
maxWidth={140}
@@ -154,7 +158,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
href={getHomeNavigation().href}
onClick={(e: React.MouseEvent) => {
const performNavigation = () => {
setActiveButton('tools');
setActiveButton("tools");
handleBackToTools();
};
if (hasUnsavedChanges) {
@@ -164,26 +168,22 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
}
handleUnlessSpecialClick(e, performNavigation);
}}
size={'lg'}
size={"lg"}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name}
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'
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.875rem' }} />
) : (
indicatorTool.icon
)}
{isBackHover ? <ArrowBackRoundedIcon sx={{ fontSize: "1.875rem" }} /> : indicatorTool.icon}
</span>
</ActionIcon>
</Tooltip>
@@ -195,10 +195,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
className="button-text active current-tool-label"
/>
</div>
<Divider
size="xs"
className="current-tool-divider"
/>
<Divider size="xs" className="current-tool-divider" />
</div>
)}
</div>
@@ -207,4 +204,3 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
};
export default ActiveToolButton;
@@ -23,7 +23,9 @@
justify-content: center;
width: 1.5rem;
height: 1.5rem;
transition: width 0.2s, height 0.2s;
transition:
width 0.2s,
height 0.2s;
}
/* Action icon styles */
@@ -64,8 +66,8 @@
}
/* RTL adjustments keep the bar on-screen and separated from content */
:root[dir='rtl'] .quick-access-bar-main,
:root[dir='rtl'] .quick-access-bar-main.rainbow-mode {
:root[dir="rtl"] .quick-access-bar-main,
:root[dir="rtl"] .quick-access-bar-main.rainbow-mode {
border-right: none;
border-left: 1px solid var(--border-default);
}
@@ -75,7 +77,7 @@
padding: 1rem 0.25rem 0.5rem 0.25rem;
}
.nav-header {
.nav-header {
display: flex;
flex-direction: row;
align-items: center;
@@ -224,7 +226,9 @@
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 450ms ease-out, opacity 300ms ease-out;
transition:
max-height 450ms ease-out,
opacity 300ms ease-out;
}
.current-tool-enter {
@@ -274,7 +278,9 @@
opacity: 0;
pointer-events: none;
transform: translateX(-8px) scale(0.98);
transition: opacity 160ms ease, transform 200ms ease;
transition:
opacity 160ms ease,
transform 200ms ease;
}
.quick-access-popout.is-open {
@@ -709,7 +715,9 @@
font-size: 0.78rem;
font-weight: 500;
color: var(--text-primary);
transition: background 120ms, border-color 120ms;
transition:
background 120ms,
border-color 120ms;
white-space: nowrap;
}
@@ -736,7 +744,9 @@
font-size: 0.85rem;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: color 150ms, border-color 150ms;
transition:
color 150ms,
border-color 150ms;
}
.quick-access-popout__tab-button.active {
@@ -809,7 +819,10 @@
border-radius: 5px;
color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
cursor: pointer;
transition: background 120ms, border-color 120ms, color 120ms;
transition:
background 120ms,
border-color 120ms,
color 120ms;
flex-shrink: 0;
}
@@ -883,7 +896,10 @@
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: background 120ms, border-color 120ms, color 120ms;
transition:
background 120ms,
border-color 120ms,
color 120ms;
white-space: nowrap;
}
@@ -1,7 +1,7 @@
import { ButtonConfig } from '@app/types/sidebar';
import { ButtonConfig } from "@app/types/sidebar";
// Border radius constants
export const ROUND_BORDER_RADIUS = '0.5rem';
export const ROUND_BORDER_RADIUS = "0.5rem";
/**
* Check if a navigation button is currently active
@@ -12,16 +12,13 @@ export const isNavButtonActive = (
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
leftPanelView?: "toolPicker" | "toolContent" | "hidden",
): boolean => {
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
const isActiveByContext =
config.type === 'navigation' &&
leftPanelView === 'toolContent' &&
selectedToolKey === config.id;
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);
(config.type === "modal" && config.id === "files" && isFilesModalOpen) ||
(config.type === "modal" && config.id === "config" && configModalOpen);
return isActiveByLocalState || isActiveByContext || isActiveByModal;
};
@@ -35,49 +32,38 @@ export const getNavButtonStyle = (
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
leftPanelView?: "toolPicker" | "toolContent" | "hidden",
) => {
const isActive = isNavButtonActive(
config,
activeButton,
isFilesModalOpen,
configModalOpen,
selectedToolKey,
leftPanelView
);
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',
border: "none",
borderRadius: ROUND_BORDER_RADIUS,
};
}
// Inactive state for all buttons
return {
backgroundColor: 'var(--icon-inactive-bg)',
color: 'var(--icon-inactive-color)',
border: 'none',
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 => {
export const getActiveNavButton = (selectedToolKey: string | null, readerMode: boolean): string => {
// Reader mode takes precedence and should highlight the Read nav item
if (readerMode) {
return 'read';
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';
return selectedToolKey ? selectedToolKey : "tools";
};
@@ -1,6 +1,6 @@
import React from 'react';
import { ActionIcon } from '@mantine/core';
import FitText from '@app/components/shared/FitText';
import React from "react";
import { ActionIcon } from "@mantine/core";
import FitText from "@app/components/shared/FitText";
interface QuickAccessButtonProps {
icon: React.ReactNode;
@@ -9,12 +9,12 @@ interface QuickAccessButtonProps {
onClick?: (e: React.MouseEvent) => void;
href?: string;
ariaLabel: string;
textClassName?: 'button-text' | 'all-tools-text';
textClassName?: "button-text" | "all-tools-text";
backgroundColor?: string;
color?: string;
size?: 'sm' | 'md' | 'lg';
size?: "sm" | "md" | "lg";
className?: string;
component?: 'a' | 'button';
component?: "a" | "button";
dataTestId?: string;
dataTour?: string;
disabled?: boolean;
@@ -27,31 +27,32 @@ const QuickAccessButton: React.FC<QuickAccessButtonProps> = ({
onClick,
href,
ariaLabel,
textClassName = 'button-text',
textClassName = "button-text",
backgroundColor,
color,
size,
className,
component = 'button',
component = "button",
dataTestId,
dataTour,
disabled = false,
}) => {
const buttonSize = size || (isActive ? 'lg' : 'md');
const bgColor = backgroundColor || (isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)');
const textColor = color || (isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)');
const buttonSize = size || (isActive ? "lg" : "md");
const bgColor = backgroundColor || (isActive ? "var(--icon-tools-bg)" : "var(--icon-inactive-bg)");
const textColor = color || (isActive ? "var(--icon-tools-color)" : "var(--icon-inactive-color)");
const actionIconProps = component === 'a' && href
? {
component: 'a' as const,
href,
onClick,
'aria-label': ariaLabel,
}
: {
onClick,
'aria-label': ariaLabel,
};
const actionIconProps =
component === "a" && href
? {
component: "a" as const,
href,
onClick,
"aria-label": ariaLabel,
}
: {
onClick,
"aria-label": ariaLabel,
};
return (
<div className="flex flex-col items-center gap-1" data-tour={dataTour}>
@@ -63,28 +64,28 @@ const QuickAccessButton: React.FC<QuickAccessButtonProps> = ({
style={{
backgroundColor: bgColor,
color: textColor,
border: 'none',
borderRadius: '8px',
textDecoration: 'none',
border: "none",
borderRadius: "8px",
textDecoration: "none",
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
cursor: disabled ? "not-allowed" : "pointer",
}}
className={className || (isActive ? 'activeIconScale' : '')}
className={className || (isActive ? "activeIconScale" : "")}
data-testid={dataTestId}
>
<span className="iconContainer">{icon}</span>
</ActionIcon>
<div style={{ width: '100%' }}>
<div style={{ width: "100%" }}>
<FitText
as="span"
text={label}
lines={2}
minimumFontScale={0.5}
className={`${textClassName} ${isActive ? 'active' : 'inactive'}`}
className={`${textClassName} ${isActive ? "active" : "inactive"}`}
style={{
fontSize: '0.75rem',
textAlign: 'center',
display: 'block',
fontSize: "0.75rem",
textAlign: "center",
display: "block",
}}
/>
</div>
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { useCallback, useEffect, useRef, useState } from "react";
import { TOUR_STATE_EVENT, type TourStatePayload } from "@app/constants/events";
import {
isOnboardingCompleted,
hasShownToursTooltip,
markToursTooltipShown,
} from "@app/components/onboarding/orchestrator/onboardingStorage";
export interface ToursTooltipState {
tooltipOpen: boolean | undefined;
@@ -24,7 +28,7 @@ export function useToursTooltip(): ToursTooltipState {
// Auto-show when a tour ends (fires once per user)
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const handleTourStateChange = (event: Event) => {
const { detail } = event as CustomEvent<TourStatePayload>;
@@ -64,7 +68,7 @@ export function useToursTooltip(): ToursTooltipState {
setShowToursTooltip(true);
}
},
[hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip]
[hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip],
);
const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip;
@@ -78,4 +82,3 @@ export function useToursTooltip(): ToursTooltipState {
handleTooltipOpenChange,
};
}
@@ -97,7 +97,9 @@
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 450ms ease-out, opacity 300ms ease-out;
transition:
max-height 450ms ease-out,
opacity 300ms ease-out;
}
.right-rail-enter {
@@ -1,19 +1,19 @@
import React, { useCallback } from 'react';
import { ActionIcon } 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 { useFileState, useFileContext } from '@app/contexts/FileContext';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import { useNavigationState, useNavigationGuard, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useRedactionMode, useRedaction } from '@app/contexts/RedactionContext';
import { defaultParameters, RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
import { RedactionMode } from '@embedpdf/plugin-redaction';
import React, { useCallback } from "react";
import { ActionIcon } 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 { useFileState, useFileContext } from "@app/contexts/FileContext";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import { useNavigationState, useNavigationGuard, useNavigationActions } from "@app/contexts/NavigationContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
import { useRedactionMode, useRedaction } from "@app/contexts/RedactionContext";
import { defaultParameters, RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
import { RedactionMode } from "@embedpdf/plugin-redaction";
interface ViewerAnnotationControlsProps {
currentView: string;
@@ -40,49 +40,50 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
// Check if we're in sign mode or redaction mode
const { selectedTool } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const isSignMode = selectedTool === 'sign';
const isRedactMode = selectedTool === 'redact';
const isSignMode = selectedTool === "sign";
const isRedactMode = selectedTool === "redact";
// Get redaction pending state and navigation guard
const { isRedacting: _isRedacting } = useRedactionMode();
const { requestNavigation, setHasUnsavedChanges, hasUnsavedChanges } = useNavigationGuard();
const { setRedactionMode, activateRedact, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction();
const { setRedactionMode, activateRedact, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } =
useRedaction();
// Check if we're in any annotation tool that should disable the toggle
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
const isInAnnotationTool =
selectedTool === "annotate" || selectedTool === "sign" || selectedTool === "addImage" || selectedTool === "addText";
// Check if we're on annotate tool to highlight the button
const isAnnotateActive = selectedTool === 'annotate';
const isAnnotateActive = selectedTool === "annotate";
const annotationsHidden = viewerContext ? !viewerContext.isAnnotationsVisible : false;
// Persist annotations to file if there are unsaved changes
const saveAnnotationsIfNeeded = async () => {
if (!viewerContext?.exportActions?.saveAsCopy || currentView !== 'viewer' || !historyApiRef?.current?.canUndo()) return;
if (!viewerContext?.exportActions?.saveAsCopy || currentView !== "viewer" || !historyApiRef?.current?.canUndo()) return;
if (activeFiles.length === 0 || state.files.ids.length === 0) return;
try {
const arrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (!arrayBuffer) return;
const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: 'application/pdf' });
const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: "application/pdf" });
const parentStub = selectors.getStirlingFileStub(state.files.ids[0]);
if (!parentStub) return;
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'redact');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, "redact");
await fileActions.consumeFiles([state.files.ids[0]], stirlingFiles, stubs);
// Clear unsaved changes flags after successful save
setHasUnsavedChanges(false);
setRedactionsApplied(false);
} catch (error) {
console.error('Error auto-saving annotations before redaction:', error);
console.error("Error auto-saving annotations before redaction:", error);
}
};
const exitRedactionMode = useCallback(() => {
navActions.setToolAndWorkbench(null, 'viewer');
setLeftPanelView('toolPicker');
navActions.setToolAndWorkbench(null, "viewer");
setLeftPanelView("toolPicker");
setRedactionMode(false);
setActiveType(null);
}, [navActions, setLeftPanelView, setRedactionMode, setActiveType]);
@@ -102,16 +103,16 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
// Set redaction config to manual mode when opening from viewer
const manualConfig: RedactParameters = {
...defaultParameters,
mode: 'manual',
mode: "manual",
};
setRedactionConfig(manualConfig);
// Set tool and keep viewer workbench
navActions.setToolAndWorkbench('redact', 'viewer');
navActions.setToolAndWorkbench("redact", "viewer");
// Ensure sidebars are visible and open tool content
setSidebarsVisible(true);
setLeftPanelView('toolContent');
setLeftPanelView("toolContent");
setRedactionMode(true);
// Activate unified redact mode after a short delay
@@ -149,33 +150,41 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
return (
<>
{/* Redaction Mode Toggle */}
<Tooltip content={isRedactMode ? t('rightRail.exitRedaction', 'Exit Redaction Mode') : t('rightRail.redact', 'Redact')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<Tooltip
content={isRedactMode ? t("rightRail.exitRedaction", "Exit Redaction Mode") : t("rightRail.redact", "Redact")}
position={tooltipPosition}
offset={tooltipOffset}
arrow
portalTarget={document.body}
>
<ActionIcon
variant={isRedactMode ? 'filled' : 'subtle'}
color={isRedactMode ? 'blue' : undefined}
variant={isRedactMode ? "filled" : "subtle"}
color={isRedactMode ? "blue" : undefined}
radius="md"
className="right-rail-icon"
onClick={handleRedactionToggle}
disabled={disabled || currentView !== 'viewer'}
disabled={disabled || currentView !== "viewer"}
>
<LocalIcon
icon="scan-delete-rounded"
width="1.5rem"
height="1.5rem"
/>
<LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<Tooltip
content={t("rightRail.toggleAnnotations", "Toggle Annotations Visibility")}
position={tooltipPosition}
offset={tooltipOffset}
arrow
portalTarget={document.body}
>
<ActionIcon
variant={annotationsHidden ? "filled" : "subtle"}
color={annotationsHidden ? "blue" : undefined}
radius="md"
className="right-rail-icon"
onClick={handleToggleAnnotationsVisibility}
disabled={disabled || currentView !== 'viewer' || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode}
data-active={annotationsHidden ? 'true' : undefined}
disabled={disabled || currentView !== "viewer" || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode}
data-active={annotationsHidden ? "true" : undefined}
aria-pressed={annotationsHidden}
>
<LocalIcon
@@ -185,7 +194,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
/>
</ActionIcon>
</Tooltip>
</>
);
}
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Loader, Center, Text, Badge } from '@mantine/core';
import { useTranslation } from "react-i18next";
import { Loader, Center, Text, Badge } from "@mantine/core";
interface SessionItem {
itemType: 'signRequest' | 'mySession';
itemType: "signRequest" | "mySession";
sessionId: string;
documentName: string;
createdAt: string;
@@ -21,61 +21,57 @@ interface ActiveSessionsPanelProps {
onSessionClick: (session: SessionItem) => void;
}
const ActiveSessionsPanel = ({
sessions,
loading,
onSessionClick,
}: ActiveSessionsPanelProps) => {
const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessionsPanelProps) => {
const { t } = useTranslation();
const getStatusColor = (status?: string, itemType?: string, item?: SessionItem): string => {
if (itemType === 'mySession' && item) {
if (itemType === "mySession" && item) {
const signedCount = item.signedCount ?? 0;
const totalCount = item.participantCount ?? 0;
if (signedCount === totalCount && totalCount > 0) {
return 'green'; // All signed
return "green"; // All signed
}
if (signedCount > 0) {
return 'yellow'; // Partial
return "yellow"; // Partial
}
return 'blue'; // None signed
return "blue"; // None signed
}
switch (status) {
case 'VIEWED':
return 'blue';
case 'NOTIFIED':
case 'PENDING':
return 'orange';
case "VIEWED":
return "blue";
case "NOTIFIED":
case "PENDING":
return "orange";
default:
return 'gray';
return "gray";
}
};
const getStatusLabel = (item: SessionItem): string => {
if (item.itemType === 'mySession') {
if (item.itemType === "mySession") {
const signedCount = item.signedCount ?? 0;
const totalCount = item.participantCount ?? 0;
if (signedCount === totalCount && totalCount > 0) {
return t('certSign.readyToFinalize', 'Ready to finalize');
return t("certSign.readyToFinalize", "Ready to finalize");
}
// Show progress for all cases (including 0/X)
if (totalCount > 0) {
return t('certSign.signatureProgress', '{{signedCount}}/{{totalCount}} signatures', { signedCount, totalCount });
return t("certSign.signatureProgress", "{{signedCount}}/{{totalCount}} signatures", { signedCount, totalCount });
}
return t('certSign.awaitingSignatures', 'Awaiting signatures');
return t("certSign.awaitingSignatures", "Awaiting signatures");
}
// For sign requests
switch (item.myStatus) {
case 'VIEWED':
return t('certSign.viewed', 'Viewed');
case 'NOTIFIED':
return t('certSign.notified', 'Pending');
case 'PENDING':
return t('certSign.pending', 'Pending');
case "VIEWED":
return t("certSign.viewed", "Viewed");
case "NOTIFIED":
return t("certSign.notified", "Pending");
case "PENDING":
return t("certSign.pending", "Pending");
default:
return item.myStatus || 'PENDING';
return item.myStatus || "PENDING";
}
};
@@ -90,7 +86,7 @@ const ActiveSessionsPanel = ({
{sessions.length === 0 ? (
<div className="quick-access-popout__section">
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('quickAccess.noActiveSessions', 'No pending sign requests or active sessions')}
{t("quickAccess.noActiveSessions", "No pending sign requests or active sessions")}
</Text>
</div>
) : (
@@ -104,7 +100,7 @@ const ActiveSessionsPanel = ({
<div className="quick-access-popout__sign-request-info">
<div className="quick-access-popout__row-title">{session.documentName}</div>
<div className="quick-access-popout__row-subtitle">
{session.itemType === 'signRequest' ? (
{session.itemType === "signRequest" ? (
<>
From: {session.ownerUsername}
{session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
@@ -128,7 +124,6 @@ const ActiveSessionsPanel = ({
))}
</>
)}
</>
)}
</div>
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Loader, Center, Text, Badge } from '@mantine/core';
import { useTranslation } from "react-i18next";
import { Loader, Center, Text, Badge } from "@mantine/core";
interface SessionItem {
itemType: 'signRequest' | 'mySession';
itemType: "signRequest" | "mySession";
sessionId: string;
documentName: string;
createdAt: string;
@@ -21,38 +21,34 @@ interface CompletedSessionsPanelProps {
onSessionClick: (session: SessionItem) => void;
}
const CompletedSessionsPanel = ({
sessions,
loading,
onSessionClick,
}: CompletedSessionsPanelProps) => {
const CompletedSessionsPanel = ({ sessions, loading, onSessionClick }: CompletedSessionsPanelProps) => {
const { t } = useTranslation();
const getStatusColor = (status?: string, itemType?: string): string => {
if (itemType === 'mySession') return 'green';
if (itemType === "mySession") return "green";
switch (status) {
case 'SIGNED':
return 'green';
case 'DECLINED':
return 'red';
case "SIGNED":
return "green";
case "DECLINED":
return "red";
default:
return 'gray';
return "gray";
}
};
const getStatusLabel = (item: SessionItem): string => {
if (item.itemType === 'mySession') {
return t('certSign.finalized', 'Finalized');
if (item.itemType === "mySession") {
return t("certSign.finalized", "Finalized");
}
// For sign requests
switch (item.myStatus) {
case 'SIGNED':
return t('certSign.signed', 'Signed');
case 'DECLINED':
return t('certSign.declined', 'Declined');
case "SIGNED":
return t("certSign.signed", "Signed");
case "DECLINED":
return t("certSign.declined", "Declined");
default:
return item.myStatus || 'COMPLETED';
return item.myStatus || "COMPLETED";
}
};
@@ -65,7 +61,7 @@ const CompletedSessionsPanel = ({
) : sessions.length === 0 ? (
<div className="quick-access-popout__section">
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('quickAccess.noCompletedSessions', 'No completed sessions')}
{t("quickAccess.noCompletedSessions", "No completed sessions")}
</Text>
</div>
) : (
@@ -79,7 +75,7 @@ const CompletedSessionsPanel = ({
<div className="quick-access-popout__sign-request-info">
<div className="quick-access-popout__row-title">{session.documentName}</div>
<div className="quick-access-popout__row-subtitle">
{session.itemType === 'signRequest' ? (
{session.itemType === "signRequest" ? (
<>
From: {session.ownerUsername}
{session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
@@ -1,15 +1,15 @@
import { useState } from 'react';
import { Stack, Text, Group, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CheckIcon from '@mui/icons-material/Check';
import { SelectDocumentStep } from '@app/components/shared/signing/steps/SelectDocumentStep';
import { SelectParticipantsStep } from '@app/components/shared/signing/steps/SelectParticipantsStep';
import { ConfigureSignatureDefaultsStep } from '@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep';
import { ReviewSessionStep } from '@app/components/shared/signing/steps/ReviewSessionStep';
import { useGroupSigningTips } from '@app/components/tooltips/useGroupSigningTips';
import { useSignatureSettingsTips } from '@app/components/tooltips/useSignatureSettingsTips';
import type { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
import type { FileState } from '@app/types/file';
import { useState } from "react";
import { Stack, Text, Group, Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CheckIcon from "@mui/icons-material/Check";
import { SelectDocumentStep } from "@app/components/shared/signing/steps/SelectDocumentStep";
import { SelectParticipantsStep } from "@app/components/shared/signing/steps/SelectParticipantsStep";
import { ConfigureSignatureDefaultsStep } from "@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep";
import { ReviewSessionStep } from "@app/components/shared/signing/steps/ReviewSessionStep";
import { useGroupSigningTips } from "@app/components/tooltips/useGroupSigningTips";
import { useSignatureSettingsTips } from "@app/components/tooltips/useSignatureSettingsTips";
import type { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput";
import type { FileState } from "@app/types/file";
interface CreateSessionFlowProps {
selectedFiles: FileState[];
@@ -29,55 +29,47 @@ interface StepWrapperProps {
children: React.ReactNode;
}
const StepWrapper: React.FC<StepWrapperProps> = ({
number,
title,
isActive,
isCompleted,
children,
}) => {
const StepWrapper: React.FC<StepWrapperProps> = ({ number, title, isActive, isCompleted, children }) => {
const { t } = useTranslation();
return (
<div
style={{
padding: '16px',
border: isActive
? '2px solid var(--mantine-color-blue-6)'
: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-default)',
padding: "16px",
border: isActive ? "2px solid var(--mantine-color-blue-6)" : "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-default)",
backgroundColor: isActive
? 'var(--mantine-color-blue-0)'
? "var(--mantine-color-blue-0)"
: isCompleted
? 'var(--mantine-color-gray-0)'
: 'transparent',
? "var(--mantine-color-gray-0)"
: "transparent",
opacity: !isActive && !isCompleted ? 0.6 : 1,
}}
>
<Group gap="sm" mb={isActive ? 'md' : 0}>
<Group gap="sm" mb={isActive ? "md" : 0}>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: "32px",
height: "32px",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: isCompleted
? 'var(--mantine-color-green-6)'
? "var(--mantine-color-green-6)"
: isActive
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-4)',
color: 'white',
? "var(--mantine-color-blue-6)"
: "var(--mantine-color-gray-4)",
color: "white",
fontWeight: 600,
fontSize: '14px',
fontSize: "14px",
}}
>
{isCompleted ? <CheckIcon sx={{ fontSize: 18 }} /> : number}
</div>
<div style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{t('groupSigning.steps.stepLabel', 'Step {{number}}', { number })}
{t("groupSigning.steps.stepLabel", "Step {{number}}", { number })}
</Text>
<Text size="sm" c="dimmed">
{title}
@@ -85,12 +77,12 @@ const StepWrapper: React.FC<StepWrapperProps> = ({
</div>
{isActive && (
<Badge color="blue" variant="light">
{t('groupSigning.steps.current', 'Current')}
{t("groupSigning.steps.current", "Current")}
</Badge>
)}
{isCompleted && (
<Badge color="green" variant="light">
{t('groupSigning.steps.completed', 'Completed')}
{t("groupSigning.steps.completed", "Completed")}
</Badge>
)}
</Group>
@@ -116,8 +108,8 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
const [signatureSettings, setSignatureSettings] = useState<SignatureSettings>({
showSignature: false,
pageNumber: 1,
reason: '',
location: '',
reason: "",
location: "",
showLogo: false,
includeSummaryPage: false,
});
@@ -131,22 +123,22 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
const steps = [
{
number: 1,
title: t('groupSigning.steps.selectDocument.title', 'Select Document'),
title: t("groupSigning.steps.selectDocument.title", "Select Document"),
tooltip: groupSigningTips,
},
{
number: 2,
title: t('groupSigning.steps.selectParticipants.title', 'Choose Participants'),
title: t("groupSigning.steps.selectParticipants.title", "Choose Participants"),
tooltip: null,
},
{
number: 3,
title: t('groupSigning.steps.configureDefaults.title', 'Configure Signature Settings'),
title: t("groupSigning.steps.configureDefaults.title", "Configure Signature Settings"),
tooltip: signatureSettingsTips,
},
{
number: 4,
title: t('groupSigning.steps.review.titleShort', 'Review & Send'),
title: t("groupSigning.steps.review.titleShort", "Review & Send"),
tooltip: null,
},
];
@@ -155,23 +147,15 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
<div className="quick-access-popout__panel">
<Stack gap="md">
{/* Step 1: Select Document */}
<StepWrapper
number={1}
title={steps[0].title}
isActive={currentStep === 1}
isCompleted={currentStep > 1}
>
<StepWrapper number={1} title={steps[0].title} isActive={currentStep === 1} isCompleted={currentStep > 1}>
{hasValidFile ? (
<SelectDocumentStep
selectedFiles={selectedFiles}
onNext={() => setCurrentStep(2)}
/>
<SelectDocumentStep selectedFiles={selectedFiles} onNext={() => setCurrentStep(2)} />
) : (
<div style={{ padding: '12px 0' }}>
<div style={{ padding: "12px 0" }}>
<Text size="sm" c="dimmed" ta="center">
{t(
'groupSigning.steps.selectDocument.noFile',
'Please select a single PDF file from your active files to create a signing session.'
"groupSigning.steps.selectDocument.noFile",
"Please select a single PDF file from your active files to create a signing session.",
)}
</Text>
</div>
@@ -179,12 +163,7 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
</StepWrapper>
{/* Step 2: Select Participants */}
<StepWrapper
number={2}
title={steps[1].title}
isActive={currentStep === 2}
isCompleted={currentStep > 2}
>
<StepWrapper number={2} title={steps[1].title} isActive={currentStep === 2} isCompleted={currentStep > 2}>
<SelectParticipantsStep
selectedUserIds={selectedUserIds}
onSelectedUserIdsChange={onSelectedUserIdsChange}
@@ -195,12 +174,7 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
</StepWrapper>
{/* Step 3: Configure Signature Defaults */}
<StepWrapper
number={3}
title={steps[2].title}
isActive={currentStep === 3}
isCompleted={currentStep > 3}
>
<StepWrapper number={3} title={steps[2].title} isActive={currentStep === 3} isCompleted={currentStep > 3}>
<ConfigureSignatureDefaultsStep
settings={signatureSettings}
onSettingsChange={setSignatureSettings}
@@ -211,12 +185,7 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
</StepWrapper>
{/* Step 4: Review & Send */}
<StepWrapper
number={4}
title={steps[3].title}
isActive={currentStep === 4}
isCompleted={false}
>
<StepWrapper number={4} title={steps[3].title} isActive={currentStep === 4} isCompleted={false}>
{selectedFile && (
<ReviewSessionStep
selectedFile={selectedFile}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Text, Switch } from '@mantine/core';
import UserSelector from '@app/components/shared/UserSelector';
import type { FileState } from '@app/types/file';
import { useTranslation } from "react-i18next";
import { Text, Switch } from "@mantine/core";
import UserSelector from "@app/components/shared/UserSelector";
import type { FileState } from "@app/types/file";
interface CreateSessionPanelProps {
selectedFiles: FileState[];
@@ -33,31 +33,31 @@ const CreateSessionPanel = ({
{!hasValidFile ? (
<div className="quick-access-popout__section">
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('quickAccess.selectSingleFileToRequest', 'Select a single PDF file to request signatures')}
{t("quickAccess.selectSingleFileToRequest", "Select a single PDF file to request signatures")}
</Text>
</div>
) : (
<>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">{t('quickAccess.selectedFile', 'Selected file')}</div>
<div className="quick-access-popout__label">{t("quickAccess.selectedFile", "Selected file")}</div>
<div className="quick-access-popout__row-title">
{selectedFiles[0]?.name || t('quickAccess.noFile', 'No file selected')}
{selectedFiles[0]?.name || t("quickAccess.noFile", "No file selected")}
</div>
</div>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">{t('quickAccess.selectUsers', 'Select users to sign')}</div>
<div className="quick-access-popout__label">{t("quickAccess.selectUsers", "Select users to sign")}</div>
<UserSelector
value={selectedUserIds}
onChange={onSelectedUserIdsChange}
size="xs"
placeholder={t('quickAccess.selectUsersPlaceholder', 'Choose participants...')}
placeholder={t("quickAccess.selectUsersPlaceholder", "Choose participants...")}
disabled={creating}
/>
</div>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">{t('quickAccess.dueDate', 'Due date (optional)')}</div>
<div className="quick-access-popout__label">{t("quickAccess.dueDate", "Due date (optional)")}</div>
<input
type="date"
className="quick-access-popout__input"
@@ -69,8 +69,11 @@ const CreateSessionPanel = ({
<div className="quick-access-popout__section">
<Switch
label={t('certSign.collab.sessionCreation.includeSummaryPage', 'Include Signature Summary Page')}
description={t('certSign.collab.sessionCreation.includeSummaryPageHelp', 'Add a summary page at the end with all signature details')}
label={t("certSign.collab.sessionCreation.includeSummaryPage", "Include Signature Summary Page")}
description={t(
"certSign.collab.sessionCreation.includeSummaryPageHelp",
"Add a summary page at the end with all signature details",
)}
checked={includeSummaryPage}
onChange={(e) => onIncludeSummaryPageChange(e.currentTarget.checked)}
disabled={creating}
@@ -1,34 +1,34 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Drawer } from '@mantine/core';
import { useIsPhone } from '@app/hooks/useIsMobile';
import LocalIcon from '@app/components/shared/LocalIcon';
import ActiveSessionsPanel from '@app/components/shared/signing/ActiveSessionsPanel';
import CompletedSessionsPanel from '@app/components/shared/signing/CompletedSessionsPanel';
import CreateSessionPanel from '@app/components/shared/signing/CreateSessionPanel';
import apiClient from '@app/services/apiClient';
import { alert } from '@app/components/toast';
import { SignRequestSummary, SignRequestDetail, SessionSummary, SessionDetail } from '@app/types/signingSession';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
import { useFileSelection } from '@app/contexts/file/fileHooks';
import { fileStorage } from '@app/services/fileStorage';
import { useFileActions } from '@app/contexts/FileContext';
import SignRequestWorkbenchView from '@app/components/tools/certSign/SignRequestWorkbenchView';
import SessionDetailWorkbenchView from '@app/components/tools/certSign/SessionDetailWorkbenchView';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Drawer } from "@mantine/core";
import { useIsPhone } from "@app/hooks/useIsMobile";
import LocalIcon from "@app/components/shared/LocalIcon";
import ActiveSessionsPanel from "@app/components/shared/signing/ActiveSessionsPanel";
import CompletedSessionsPanel from "@app/components/shared/signing/CompletedSessionsPanel";
import CreateSessionPanel from "@app/components/shared/signing/CreateSessionPanel";
import apiClient from "@app/services/apiClient";
import { alert } from "@app/components/toast";
import { SignRequestSummary, SignRequestDetail, SessionSummary, SessionDetail } from "@app/types/signingSession";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext";
import { useFileSelection } from "@app/contexts/file/fileHooks";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions } from "@app/contexts/FileContext";
import SignRequestWorkbenchView from "@app/components/tools/certSign/SignRequestWorkbenchView";
import SessionDetailWorkbenchView from "@app/components/tools/certSign/SessionDetailWorkbenchView";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
export const SIGN_REQUEST_WORKBENCH_TYPE = 'custom:signRequestWorkbench' as const;
export const SESSION_DETAIL_WORKBENCH_TYPE = 'custom:sessionDetailWorkbench' as const;
export const SIGN_REQUEST_WORKBENCH_TYPE = "custom:signRequestWorkbench" as const;
export const SESSION_DETAIL_WORKBENCH_TYPE = "custom:sessionDetailWorkbench" as const;
type SessionItem = (SignRequestSummary | SessionSummary) & {
itemType: 'signRequest' | 'mySession';
itemType: "signRequest" | "mySession";
};
function sortSessions(sessions: SessionItem[], tab: 'active' | 'completed'): SessionItem[] {
function sortSessions(sessions: SessionItem[], tab: "active" | "completed"): SessionItem[] {
return [...sessions].sort((a, b) => {
if (tab === 'active') {
if (tab === "active") {
const aDue = (a as SignRequestSummary).dueDate;
const bDue = (b as SignRequestSummary).dueDate;
if (aDue && bDue) return new Date(aDue).getTime() - new Date(bDue).getTime();
@@ -55,23 +55,27 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
// Tab state
const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active');
const [activeTab, setActiveTab] = useState<"active" | "completed">("active");
const [showCreatePanel, setShowCreatePanel] = useState(false);
// Search / filter state
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
const handleTabChange = (tab: 'active' | 'completed') => {
const handleTabChange = (tab: "active" | "completed") => {
setActiveTab(tab);
setSearchQuery('');
setSearchQuery("");
setActiveFilters(new Set());
};
const toggleFilter = (key: string) => {
setActiveFilters(prev => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(key)) { next.delete(key); } else { next.add(key); }
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
@@ -83,7 +87,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
// Create form state
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [dueDate, setDueDate] = useState('');
const [dueDate, setDueDate] = useState("");
const [creating, setCreating] = useState(false);
const [includeSummaryPage, setIncludeSummaryPage] = useState(false);
@@ -101,8 +105,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
} = useToolWorkflow();
// Workbench IDs
const SIGN_REQUEST_WORKBENCH_ID = 'signRequestWorkbench';
const SESSION_DETAIL_WORKBENCH_ID = 'sessionDetailWorkbench';
const SIGN_REQUEST_WORKBENCH_ID = "signRequestWorkbench";
const SESSION_DETAIL_WORKBENCH_ID = "sessionDetailWorkbench";
// Register workbenches when group signing is enabled.
// No cleanup on unmount — registration must persist when this component unmounts
@@ -113,7 +117,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
registerCustomWorkbenchView({
id: SIGN_REQUEST_WORKBENCH_ID,
workbenchId: SIGN_REQUEST_WORKBENCH_TYPE,
label: t('certSign.collab.signRequest.workbenchTitle', 'Sign Request'),
label: t("certSign.collab.signRequest.workbenchTitle", "Sign Request"),
component: SignRequestWorkbenchView,
hideTopControls: true,
hideToolPanel: true,
@@ -122,7 +126,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
registerCustomWorkbenchView({
id: SESSION_DETAIL_WORKBENCH_ID,
workbenchId: SESSION_DETAIL_WORKBENCH_TYPE,
label: t('certSign.collab.sessionDetail.workbenchTitle', 'Session Management'),
label: t("certSign.collab.sessionDetail.workbenchTitle", "Session Management"),
component: SessionDetailWorkbenchView,
hideTopControls: true,
hideToolPanel: true,
@@ -175,12 +179,12 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, { capture: true });
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, { capture: true });
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, { capture: true });
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, { capture: true });
};
}, [isOpen, isRTL, buttonRef]);
@@ -193,24 +197,22 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
if (popoverRef.current?.contains(target)) return;
if (buttonRef.current?.contains(target)) return;
const mantineDropdown = (target as Element).closest?.(
'.mantine-Combobox-dropdown, .mantine-Popover-dropdown'
);
const mantineDropdown = (target as Element).closest?.(".mantine-Combobox-dropdown, .mantine-Popover-dropdown");
if (mantineDropdown) return;
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
if (event.key === "Escape") onClose();
};
document.addEventListener('mousedown', handleOutside);
document.addEventListener('keydown', handleEscape);
document.addEventListener("mousedown", handleOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutside);
document.removeEventListener('keydown', handleEscape);
document.removeEventListener("mousedown", handleOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose, buttonRef]);
@@ -218,17 +220,17 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
setLoading(true);
try {
const [requestsResponse, sessionsResponse] = await Promise.all([
apiClient.get<SignRequestSummary[]>('/api/v1/security/cert-sign/sign-requests'),
apiClient.get<SessionSummary[]>('/api/v1/security/cert-sign/sessions'),
apiClient.get<SignRequestSummary[]>("/api/v1/security/cert-sign/sign-requests"),
apiClient.get<SessionSummary[]>("/api/v1/security/cert-sign/sessions"),
]);
setSignRequests(requestsResponse.data);
setMySessions(sessionsResponse.data);
} catch (error) {
console.error('Failed to fetch signing data:', error instanceof Error ? error.message : error);
console.error("Failed to fetch signing data:", error instanceof Error ? error.message : error);
alert({
alertType: 'warning',
title: t('common.error'),
body: t('certSign.fetchFailed', 'Failed to load signing data'),
alertType: "warning",
title: t("common.error"),
body: t("certSign.fetchFailed", "Failed to load signing data"),
expandable: false,
durationMs: 2500,
});
@@ -246,7 +248,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
// Auto-refresh Active tab every 15 seconds to show updated signature status
useEffect(() => {
if (isOpen && groupSigningEnabled && activeTab === 'active' && !showCreatePanel) {
if (isOpen && groupSigningEnabled && activeTab === "active" && !showCreatePanel) {
const interval = setInterval(() => {
fetchData();
}, 15000); // Refresh every 15 seconds
@@ -259,53 +261,51 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
const activeSessions: SessionItem[] = [
// Sign requests where user hasn't signed or declined yet
...signRequests
.filter(req => req.myStatus !== 'SIGNED' && req.myStatus !== 'DECLINED')
.map(req => ({ ...req, itemType: 'signRequest' as const })),
.filter((req) => req.myStatus !== "SIGNED" && req.myStatus !== "DECLINED")
.map((req) => ({ ...req, itemType: "signRequest" as const })),
// Sessions user created that aren't finalized yet
...mySessions
.filter(s => !s.finalized)
.map(s => ({ ...s, itemType: 'mySession' as const })),
...mySessions.filter((s) => !s.finalized).map((s) => ({ ...s, itemType: "mySession" as const })),
];
const completedSessions: SessionItem[] = [
// Sign requests where user has signed or declined
...signRequests
.filter(req => req.myStatus === 'SIGNED' || req.myStatus === 'DECLINED')
.map(req => ({ ...req, itemType: 'signRequest' as const })),
.filter((req) => req.myStatus === "SIGNED" || req.myStatus === "DECLINED")
.map((req) => ({ ...req, itemType: "signRequest" as const })),
// Sessions user created that have been finalized
...mySessions
.filter(s => s.finalized)
.map(s => ({ ...s, itemType: 'mySession' as const })),
...mySessions.filter((s) => s.finalized).map((s) => ({ ...s, itemType: "mySession" as const })),
];
// Filter options vary by tab
const filterOptions = activeTab === 'active'
? [
{ key: 'mine', label: t('quickAccess.filterMine', 'Mine') },
{ key: 'overdue', label: t('quickAccess.filterOverdue', 'Overdue') },
]
: [
{ key: 'mine', label: t('quickAccess.filterMine', 'Mine') },
{ key: 'signed', label: t('quickAccess.filterSigned', 'Signed') },
{ key: 'declined',label: t('quickAccess.filterDeclined', 'Declined') },
];
const filterOptions =
activeTab === "active"
? [
{ key: "mine", label: t("quickAccess.filterMine", "Mine") },
{ key: "overdue", label: t("quickAccess.filterOverdue", "Overdue") },
]
: [
{ key: "mine", label: t("quickAccess.filterMine", "Mine") },
{ key: "signed", label: t("quickAccess.filterSigned", "Signed") },
{ key: "declined", label: t("quickAccess.filterDeclined", "Declined") },
];
const applyFiltersAndSearch = (sessions: SessionItem[]): SessionItem[] => {
let result = sessions;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(s => s.documentName.toLowerCase().includes(q));
result = result.filter((s) => s.documentName.toLowerCase().includes(q));
}
const now = new Date();
if (activeFilters.has('mine')) result = result.filter(s => s.itemType === 'mySession');
if (activeFilters.has('overdue')) result = result.filter(s => (s as SignRequestSummary).dueDate && new Date((s as SignRequestSummary).dueDate) < now);
if (activeFilters.has('signed')) result = result.filter(s => (s as SignRequestSummary).myStatus === 'SIGNED');
if (activeFilters.has('declined')) result = result.filter(s => (s as SignRequestSummary).myStatus === 'DECLINED');
if (activeFilters.has("mine")) result = result.filter((s) => s.itemType === "mySession");
if (activeFilters.has("overdue"))
result = result.filter((s) => (s as SignRequestSummary).dueDate && new Date((s as SignRequestSummary).dueDate) < now);
if (activeFilters.has("signed")) result = result.filter((s) => (s as SignRequestSummary).myStatus === "SIGNED");
if (activeFilters.has("declined")) result = result.filter((s) => (s as SignRequestSummary).myStatus === "DECLINED");
return result;
};
const displayedActiveSessions = applyFiltersAndSearch(sortSessions(activeSessions, 'active'));
const displayedCompletedSessions = applyFiltersAndSearch(sortSessions(completedSessions, 'completed'));
const displayedActiveSessions = applyFiltersAndSearch(sortSessions(activeSessions, "active"));
const displayedCompletedSessions = applyFiltersAndSearch(sortSessions(completedSessions, "completed"));
// Create session handler
const handleCreateSession = useCallback(async () => {
@@ -315,47 +315,47 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
try {
const selectedFile = selectedFiles[0];
const stirlingFile = await fileStorage.getStirlingFile(selectedFile.fileId);
if (!stirlingFile) throw new Error('File not found');
if (!stirlingFile) throw new Error("File not found");
const formData = new FormData();
formData.append('file', stirlingFile, selectedFile.name);
formData.append('workflowType', 'SIGNING');
formData.append('documentName', selectedFile.name);
formData.append("file", stirlingFile, selectedFile.name);
formData.append("workflowType", "SIGNING");
formData.append("documentName", selectedFile.name);
selectedUserIds.forEach((userId, index) => {
formData.append(`participantUserIds[${index}]`, userId.toString());
});
if (dueDate) formData.append('dueDate', dueDate);
formData.append('notifyOnCreate', 'true');
if (dueDate) formData.append("dueDate", dueDate);
formData.append("notifyOnCreate", "true");
// Send includeSummaryPage setting as workflowMetadata if enabled
if (includeSummaryPage) {
const workflowMetadata = JSON.stringify({
includeSummaryPage: true,
});
formData.append('workflowMetadata', workflowMetadata);
formData.append("workflowMetadata", workflowMetadata);
}
await apiClient.post('/api/v1/security/cert-sign/sessions', formData);
await apiClient.post("/api/v1/security/cert-sign/sessions", formData);
alert({
alertType: 'success',
title: t('success'),
body: t('signSession.created', 'Signing request sent'),
alertType: "success",
title: t("success"),
body: t("signSession.created", "Signing request sent"),
expandable: false,
durationMs: 2500,
});
setSelectedUserIds([]);
setDueDate('');
setDueDate("");
setIncludeSummaryPage(false);
setShowCreatePanel(false);
await fetchData();
} catch (error) {
console.error('Failed to create session:', error instanceof Error ? error.message : error);
console.error("Failed to create session:", error instanceof Error ? error.message : error);
alert({
alertType: 'error',
title: t('common.error'),
body: t('signSession.createFailed', 'Failed to create signing request'),
alertType: "error",
title: t("common.error"),
body: t("signSession.createFailed", "Failed to create signing request"),
expandable: false,
durationMs: 3000,
});
@@ -365,205 +365,207 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
}, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]);
// Handle clicking a sign request
const handleSignRequestClick = useCallback(async (request: SignRequestSummary) => {
onClose();
try {
const [detailResponse, pdfResponse] = await Promise.all([
apiClient.get<SignRequestDetail>(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}`),
apiClient.get(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, {
responseType: 'blob',
}),
]);
const handleSignRequestClick = useCallback(
async (request: SignRequestSummary) => {
onClose();
try {
const [detailResponse, pdfResponse] = await Promise.all([
apiClient.get<SignRequestDetail>(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}`),
apiClient.get(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, {
responseType: "blob",
}),
]);
const pdfFile = new File([pdfResponse.data], detailResponse.data.documentName, {
type: 'application/pdf',
});
const canSign =
detailResponse.data.myStatus === 'PENDING' ||
detailResponse.data.myStatus === 'NOTIFIED' ||
detailResponse.data.myStatus === 'VIEWED';
const pdfFile = new File([pdfResponse.data], detailResponse.data.documentName, {
type: "application/pdf",
});
const canSign =
detailResponse.data.myStatus === "PENDING" ||
detailResponse.data.myStatus === "NOTIFIED" ||
detailResponse.data.myStatus === "VIEWED";
setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, {
signRequest: detailResponse.data,
pdfFile,
onSign: (certData: FormData) => handleSign(request.sessionId, certData),
onDecline: () => handleDecline(request.sessionId),
onBack: () => {
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
navigationActions.setWorkbench('viewer');
},
canSign,
});
setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, {
signRequest: detailResponse.data,
pdfFile,
onSign: (certData: FormData) => handleSign(request.sessionId, certData),
onDecline: () => handleDecline(request.sessionId),
onBack: () => {
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
navigationActions.setWorkbench("viewer");
},
canSign,
});
requestAnimationFrame(() => {
navigationActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE);
});
} catch (error) {
console.error('Failed to load sign request:', error instanceof Error ? error.message : error);
alert({
alertType: 'error',
title: t('common.error'),
body: t('signRequest.fetchFailed', 'Failed to load sign request'),
expandable: false,
durationMs: 3000,
});
}
}, [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t]);
requestAnimationFrame(() => {
navigationActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE);
});
} catch (error) {
console.error("Failed to load sign request:", error instanceof Error ? error.message : error);
alert({
alertType: "error",
title: t("common.error"),
body: t("signRequest.fetchFailed", "Failed to load sign request"),
expandable: false,
durationMs: 3000,
});
}
},
[onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t],
);
// Handle clicking a session
const handleSessionClick = useCallback(async (session: SessionSummary) => {
onClose();
try {
// First fetch session detail
const detailResponse = await apiClient.get<SessionDetail>(
`/api/v1/security/cert-sign/sessions/${session.sessionId}`
);
const handleSessionClick = useCallback(
async (session: SessionSummary) => {
onClose();
try {
// First fetch session detail
const detailResponse = await apiClient.get<SessionDetail>(`/api/v1/security/cert-sign/sessions/${session.sessionId}`);
// Determine which endpoint to use based on session state
let pdfFile: File | null = null;
// Determine which endpoint to use based on session state
let pdfFile: File | null = null;
if (detailResponse.data.finalized) {
// Finalized sessions have signed PDF available
try {
const pdfResponse = await apiClient.get(
`/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`,
{ responseType: 'blob' }
);
pdfFile = new File([pdfResponse.data], session.documentName, { type: 'application/pdf' });
} catch (pdfError: any) {
if (pdfError?.response?.status === 404) {
// Finalized but signed PDF not available - backend issue
alert({
alertType: 'warning',
title: t('certSign.sessions.pdfNotReady', 'PDF Not Ready'),
body: t('certSign.sessions.pdfNotReadyDesc', 'The signed PDF is being generated. Please try again in a moment.'),
expandable: false,
durationMs: 3000,
if (detailResponse.data.finalized) {
// Finalized sessions have signed PDF available
try {
const pdfResponse = await apiClient.get(`/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`, {
responseType: "blob",
});
return;
pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf" });
} catch (pdfError: any) {
if (pdfError?.response?.status === 404) {
// Finalized but signed PDF not available - backend issue
alert({
alertType: "warning",
title: t("certSign.sessions.pdfNotReady", "PDF Not Ready"),
body: t(
"certSign.sessions.pdfNotReadyDesc",
"The signed PDF is being generated. Please try again in a moment.",
),
expandable: false,
durationMs: 3000,
});
return;
}
throw pdfError;
}
} else {
// For non-finalized sessions, get original PDF (always available)
try {
const pdfResponse = await apiClient.get(`/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`, {
responseType: "blob",
});
pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf" });
} catch (_error) {
// Fallback if PDF not available
pdfFile = null;
}
throw pdfError;
}
} else {
// For non-finalized sessions, get original PDF (always available)
try {
const pdfResponse = await apiClient.get(
`/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`,
{ responseType: 'blob' }
);
pdfFile = new File([pdfResponse.data], session.documentName, { type: 'application/pdf' });
} catch (_error) {
// Fallback if PDF not available
pdfFile = null;
}
setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, {
session: detailResponse.data,
pdfFile,
onFinalize: () => handleFinalize(session.sessionId, session.documentName),
onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName),
onAddParticipants: (userIds: number[], defaultReason?: string) =>
handleAddParticipants(session.sessionId, userIds, defaultReason),
onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId),
onDelete: () => handleDeleteSession(session.sessionId),
onBack: () => {
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
navigationActions.setWorkbench("viewer");
},
onRefresh: () => handleRefreshSession(session.sessionId),
});
requestAnimationFrame(() => {
navigationActions.setWorkbench(SESSION_DETAIL_WORKBENCH_TYPE);
});
} catch (error) {
console.error("Failed to load session:", error instanceof Error ? error.message : error);
alert({
alertType: "error",
title: t("common.error"),
body: t("certSign.sessions.fetchFailed", "Failed to load session details"),
expandable: false,
durationMs: 3000,
});
}
setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, {
session: detailResponse.data,
pdfFile,
onFinalize: () => handleFinalize(session.sessionId, session.documentName),
onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName),
onAddParticipants: (userIds: number[], defaultReason?: string) =>
handleAddParticipants(session.sessionId, userIds, defaultReason),
onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId),
onDelete: () => handleDeleteSession(session.sessionId),
onBack: () => {
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
navigationActions.setWorkbench('viewer');
},
onRefresh: () => handleRefreshSession(session.sessionId),
});
requestAnimationFrame(() => {
navigationActions.setWorkbench(SESSION_DETAIL_WORKBENCH_TYPE);
});
} catch (error) {
console.error('Failed to load session:', error instanceof Error ? error.message : error);
alert({
alertType: 'error',
title: t('common.error'),
body: t('certSign.sessions.fetchFailed', 'Failed to load session details'),
expandable: false,
durationMs: 3000,
});
}
}, [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t]);
},
[onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t],
);
// Action handlers
const handleSign = async (sessionId: string, certificateData: FormData) => {
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData);
alert({
alertType: 'success',
title: t('success'),
body: t('signRequest.signed', 'Document signed successfully'),
alertType: "success",
title: t("success"),
body: t("signRequest.signed", "Document signed successfully"),
expandable: false,
durationMs: 2500,
});
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
navigationActions.setWorkbench('viewer');
navigationActions.setWorkbench("viewer");
await fetchData();
};
const handleDecline = async (sessionId: string) => {
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`);
alert({
alertType: 'success',
title: t('success'),
body: t('signRequest.declined', 'Sign request declined'),
alertType: "success",
title: t("success"),
body: t("signRequest.declined", "Sign request declined"),
expandable: false,
durationMs: 2500,
});
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
navigationActions.setWorkbench('viewer');
navigationActions.setWorkbench("viewer");
await fetchData();
};
const handleFinalize = async (sessionId: string, documentName: string) => {
const response = await apiClient.post(
`/api/v1/security/cert-sign/sessions/${sessionId}/finalize`,
null,
{ responseType: 'blob' }
);
const contentDisposition = response.headers['content-disposition'];
const response = await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/finalize`, null, {
responseType: "blob",
});
const contentDisposition = response.headers["content-disposition"];
const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/);
const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`;
const signedFile = new File([response.data], filename, { type: 'application/pdf' });
const signedFile = new File([response.data], filename, { type: "application/pdf" });
await fileActions.addFiles([signedFile]);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.sessions.finalized', 'Session finalized'),
alertType: "success",
title: t("success"),
body: t("certSign.sessions.finalized", "Session finalized"),
expandable: false,
durationMs: 2500,
});
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
navigationActions.setWorkbench('viewer');
navigationActions.setWorkbench("viewer");
await fetchData();
};
const handleLoadSignedPdf = async (sessionId: string, documentName: string) => {
const response = await apiClient.get(
`/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`,
{ responseType: 'blob' }
);
const contentDisposition = response.headers['content-disposition'];
const response = await apiClient.get(`/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`, {
responseType: "blob",
});
const contentDisposition = response.headers["content-disposition"];
const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/);
const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`;
const signedFile = new File([response.data], filename, { type: 'application/pdf' });
const signedFile = new File([response.data], filename, { type: "application/pdf" });
await fileActions.addFiles([signedFile]);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.sessions.loaded', 'Signed PDF loaded'),
alertType: "success",
title: t("success"),
body: t("certSign.sessions.loaded", "Signed PDF loaded"),
expandable: false,
durationMs: 2500,
});
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
navigationActions.setWorkbench('viewer');
navigationActions.setWorkbench("viewer");
};
const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => {
const requests = userIds.map(userId => ({
const requests = userIds.map((userId) => ({
userId,
defaultReason: defaultReason || undefined,
sendNotification: true,
@@ -580,21 +582,19 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
const handleDeleteSession = async (sessionId: string) => {
await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}`);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.sessions.deleted', 'Session deleted'),
alertType: "success",
title: t("success"),
body: t("certSign.sessions.deleted", "Session deleted"),
expandable: false,
durationMs: 2500,
});
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
navigationActions.setWorkbench('viewer');
navigationActions.setWorkbench("viewer");
await fetchData();
};
const handleRefreshSession = async (sessionId: string) => {
const response = await apiClient.get<SessionDetail>(
`/api/v1/security/cert-sign/sessions/${sessionId}`
);
const response = await apiClient.get<SessionDetail>(`/api/v1/security/cert-sign/sessions/${sessionId}`);
// Update workbench data, preserving PDF and callbacks
setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, (prevData: any) => ({
...prevData,
@@ -602,7 +602,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
}));
};
if (typeof document === 'undefined') return null;
if (typeof document === "undefined") return null;
// Shared card content — rendered inside either the portal (desktop/tablet) or Drawer (phone)
const popoutCard = (
@@ -611,20 +611,20 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
<div className="quick-access-popout__header">
<button
type="button"
className={`quick-access-popout__back ${showCreatePanel ? 'is-visible' : ''}`}
className={`quick-access-popout__back ${showCreatePanel ? "is-visible" : ""}`}
onClick={() => setShowCreatePanel(false)}
aria-label={t('quickAccess.back', 'Back')}
aria-label={t("quickAccess.back", "Back")}
>
<LocalIcon icon="arrow-back-rounded" width="1rem" height="1rem" />
</button>
<div className="quick-access-popout__title">
{showCreatePanel
? t('quickAccess.createSession', 'Create Signing Request')
: groupSigningEnabled && activeTab === 'active'
? t('quickAccess.activeSessions', 'Active Sessions')
: groupSigningEnabled
? t('quickAccess.completedSessions', 'Completed Sessions')
: t('quickAccess.sign', 'Sign')}
? t("quickAccess.createSession", "Create Signing Request")
: groupSigningEnabled && activeTab === "active"
? t("quickAccess.activeSessions", "Active Sessions")
: groupSigningEnabled
? t("quickAccess.completedSessions", "Completed Sessions")
: t("quickAccess.sign", "Sign")}
</div>
<div className="quick-access-popout__header-actions">
{!showCreatePanel && (
@@ -633,7 +633,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
className="quick-access-popout__header-action"
onClick={fetchData}
disabled={loading}
aria-label={t('quickAccess.refresh', 'Refresh')}
aria-label={t("quickAccess.refresh", "Refresh")}
style={{ opacity: loading ? 0.5 : 0.7 }}
>
<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />
@@ -643,7 +643,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
type="button"
className="quick-access-popout__header-action"
onClick={onClose}
aria-label={t('close', 'Close')}
aria-label={t("close", "Close")}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</button>
@@ -653,31 +653,29 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
{/* Quick sign tools */}
{!showCreatePanel && (
<div className="quick-access-popout__quick-sign">
<div className="quick-access-popout__section-label">
{t('quickAccess.signYourself', 'Sign Yourself')}
</div>
<div className="quick-access-popout__section-label">{t("quickAccess.signYourself", "Sign Yourself")}</div>
<div className="quick-access-popout__quick-sign-actions">
<button
type="button"
className="quick-access-popout__quick-sign-btn"
onClick={() => {
onClose();
handleToolSelect('sign');
handleToolSelect("sign");
}}
>
<LocalIcon icon="signature-rounded" width="1rem" height="1rem" />
{t('quickAccess.wetSign', 'Add Signature')}
{t("quickAccess.wetSign", "Add Signature")}
</button>
<button
type="button"
className="quick-access-popout__quick-sign-btn"
onClick={() => {
onClose();
handleToolSelect('certSign');
handleToolSelect("certSign");
}}
>
<LocalIcon icon="workspace-premium-rounded" width="1rem" height="1rem" />
{t('quickAccess.certSign', 'Certificate Sign')}
{t("quickAccess.certSign", "Certificate Sign")}
</button>
</div>
</div>
@@ -687,29 +685,29 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
{!showCreatePanel && groupSigningEnabled && (
<>
<div className="quick-access-popout__section-label quick-access-popout__section-label--padded quick-access-popout__section-label--row">
<span>{t('quickAccess.signatureRequests', 'Signature Requests')}</span>
<span>{t("quickAccess.signatureRequests", "Signature Requests")}</span>
<button
type="button"
className="quick-access-popout__section-action"
onClick={() => setShowCreatePanel(true)}
aria-label={t('quickAccess.newRequest', 'New request')}
title={t('quickAccess.newRequest', 'New request')}
aria-label={t("quickAccess.newRequest", "New request")}
title={t("quickAccess.newRequest", "New request")}
>
<LocalIcon icon="add-rounded" width="1rem" height="1rem" />
</button>
</div>
<div className="quick-access-popout__tab-nav">
<button
className={`quick-access-popout__tab-button ${activeTab === 'active' ? 'active' : ''}`}
onClick={() => handleTabChange('active')}
className={`quick-access-popout__tab-button ${activeTab === "active" ? "active" : ""}`}
onClick={() => handleTabChange("active")}
>
{t('quickAccess.activeTab', 'Active')}
{t("quickAccess.activeTab", "Active")}
</button>
<button
className={`quick-access-popout__tab-button ${activeTab === 'completed' ? 'active' : ''}`}
onClick={() => handleTabChange('completed')}
className={`quick-access-popout__tab-button ${activeTab === "completed" ? "active" : ""}`}
onClick={() => handleTabChange("completed")}
>
{t('quickAccess.completedTab', 'Completed')}
{t("quickAccess.completedTab", "Completed")}
</button>
</div>
</>
@@ -721,16 +719,16 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
<input
type="search"
className="quick-access-popout__search"
placeholder={t('quickAccess.searchDocuments', 'Search documents…')}
placeholder={t("quickAccess.searchDocuments", "Search documents…")}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="quick-access-popout__filter-chips">
{filterOptions.map(f => (
{filterOptions.map((f) => (
<button
key={f.key}
type="button"
className={`quick-access-popout__filter-chip ${activeFilters.has(f.key) ? 'is-active' : ''}`}
className={`quick-access-popout__filter-chip ${activeFilters.has(f.key) ? "is-active" : ""}`}
onClick={() => toggleFilter(f.key)}
>
{f.label}
@@ -754,12 +752,12 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
includeSummaryPage={includeSummaryPage}
onIncludeSummaryPageChange={setIncludeSummaryPage}
/>
) : activeTab === 'active' ? (
) : activeTab === "active" ? (
<ActiveSessionsPanel
sessions={displayedActiveSessions}
loading={loading}
onSessionClick={(item) => {
if (item.itemType === 'signRequest') {
if (item.itemType === "signRequest") {
handleSignRequestClick(item as SignRequestSummary);
} else {
handleSessionClick(item as SessionSummary);
@@ -771,7 +769,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
sessions={displayedCompletedSessions}
loading={loading}
onSessionClick={(item) => {
if (item.itemType === 'signRequest') {
if (item.itemType === "signRequest") {
handleSignRequestClick(item as SignRequestSummary);
} else {
handleSessionClick(item as SessionSummary);
@@ -793,8 +791,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
>
<LocalIcon icon="send-rounded" width="1rem" height="1rem" />
{creating
? t('quickAccess.sendingRequest', 'Sending...')
: t('quickAccess.requestSignatures', 'Request Signatures')}
? t("quickAccess.sendingRequest", "Sending...")
: t("quickAccess.requestSignatures", "Request Signatures")}
</button>
</div>
)}
@@ -812,7 +810,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
withCloseButton={false}
padding={0}
className="quick-access-sign-popout"
styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column' } }}
styles={{ body: { padding: 0, height: "100%", display: "flex", flexDirection: "column" } }}
>
{popoutCard}
</Drawer>
@@ -823,9 +821,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
return createPortal(
<div
ref={popoverRef}
className={`quick-access-popout quick-access-sign-popout ${isOpen ? 'is-open' : ''}`}
className={`quick-access-popout quick-access-sign-popout ${isOpen ? "is-open" : ""}`}
style={{
position: 'fixed',
position: "fixed",
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
@@ -834,7 +832,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
>
{popoutCard}
</div>,
document.body
document.body,
);
};
@@ -1,7 +1,7 @@
import { Button, Stack, Text, Group, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SignatureSettingsInput, { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
import { Button, Stack, Text, Group, Paper } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SignatureSettingsInput, { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput";
interface ConfigureSignatureDefaultsStepProps {
settings: SignatureSettings;
@@ -22,41 +22,28 @@ export const ConfigureSignatureDefaultsStep: React.FC<ConfigureSignatureDefaults
return (
<Stack gap="md">
<SignatureSettingsInput
value={settings}
onChange={onSettingsChange}
disabled={disabled}
/>
<SignatureSettingsInput value={settings} onChange={onSettingsChange} disabled={disabled} />
<Paper p="sm" withBorder>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t('groupSigning.steps.configureDefaults.preview', 'Preview')}
{t("groupSigning.steps.configureDefaults.preview", "Preview")}
</Text>
<Text size="xs">
{settings.showSignature
? t(
'groupSigning.steps.configureDefaults.visible',
'Signatures will be visible on page {{page}}',
{ page: settings.pageNumber || 1 }
)
: t(
'groupSigning.steps.configureDefaults.invisible',
'Signatures will be invisible (metadata only)'
)}
? t("groupSigning.steps.configureDefaults.visible", "Signatures will be visible on page {{page}}", {
page: settings.pageNumber || 1,
})
: t("groupSigning.steps.configureDefaults.invisible", "Signatures will be invisible (metadata only)")}
</Text>
{settings.showSignature && settings.reason && (
<Text size="xs">
<strong>{t('groupSigning.steps.configureDefaults.reasonLabel', 'Reason:')}</strong>{' '}
{settings.reason}
<strong>{t("groupSigning.steps.configureDefaults.reasonLabel", "Reason:")}</strong> {settings.reason}
</Text>
)}
{settings.showSignature && settings.location && (
<Text size="xs">
<strong>
{t('groupSigning.steps.configureDefaults.locationLabel', 'Location:')}
</strong>{' '}
{settings.location}
<strong>{t("groupSigning.steps.configureDefaults.locationLabel", "Location:")}</strong> {settings.location}
</Text>
)}
</Stack>
@@ -64,10 +51,10 @@ export const ConfigureSignatureDefaultsStep: React.FC<ConfigureSignatureDefaults
<Group gap="sm">
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
{t('groupSigning.steps.back', 'Back')}
{t("groupSigning.steps.back", "Back")}
</Button>
<Button onClick={onNext} disabled={disabled} style={{ flex: 1 }}>
{t('groupSigning.steps.configureDefaults.continue', 'Continue to Review')}
{t("groupSigning.steps.configureDefaults.continue", "Continue to Review")}
</Button>
</Group>
</Stack>
@@ -1,13 +1,13 @@
import { Button, Stack, Text, Group, Divider, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import PeopleIcon from '@mui/icons-material/People';
import DrawIcon from '@mui/icons-material/Draw';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SendIcon from '@mui/icons-material/Send';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import type { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
import type { FileState } from '@app/types/file';
import { Button, Stack, Text, Group, Divider, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import PeopleIcon from "@mui/icons-material/People";
import DrawIcon from "@mui/icons-material/Draw";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SendIcon from "@mui/icons-material/Send";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import type { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput";
import type { FileState } from "@app/types/file";
interface ReviewSessionStepProps {
selectedFile: FileState;
@@ -35,23 +35,23 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
return (
<Stack gap="md">
<Text size="sm" fw={600} c="dimmed">
{t('groupSigning.steps.review.title', 'Review Session Details')}
{t("groupSigning.steps.review.title", "Review Session Details")}
</Text>
{/* Document Info */}
<div>
<Group gap="xs" mb="xs">
<PictureAsPdfIcon sx={{ fontSize: 18, color: 'var(--mantine-color-red-6)' }} />
<PictureAsPdfIcon sx={{ fontSize: 18, color: "var(--mantine-color-red-6)" }} />
<Text size="sm" fw={600}>
{t('groupSigning.steps.review.document', 'Document')}
{t("groupSigning.steps.review.document", "Document")}
</Text>
</Group>
<div
style={{
padding: '12px',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-default)',
backgroundColor: 'var(--mantine-color-default-hover)',
padding: "12px",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-default)",
backgroundColor: "var(--mantine-color-default-hover)",
}}
>
<Text size="sm">{selectedFile.name}</Text>
@@ -70,13 +70,13 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
<Group gap="xs" mb="xs">
<PeopleIcon sx={{ fontSize: 18 }} />
<Text size="sm" fw={600}>
{t('groupSigning.steps.review.participants', 'Participants')}
{t("groupSigning.steps.review.participants", "Participants")}
</Text>
</Group>
<Text size="sm">
{t('groupSigning.steps.review.participantCount', {
{t("groupSigning.steps.review.participantCount", {
count: participantCount,
defaultValue: '{{count}} participant(s) will sign in order',
defaultValue: "{{count}} participant(s) will sign in order",
})}
</Text>
</div>
@@ -88,34 +88,34 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
<Group gap="xs" mb="xs">
<DrawIcon sx={{ fontSize: 18 }} />
<Text size="sm" fw={600}>
{t('groupSigning.steps.review.signatureSettings', 'Signature Settings')}
{t("groupSigning.steps.review.signatureSettings", "Signature Settings")}
</Text>
</Group>
<Stack gap="xs">
<Text size="sm">
<strong>{t('groupSigning.steps.review.visibility', 'Visibility:')}</strong>{' '}
<strong>{t("groupSigning.steps.review.visibility", "Visibility:")}</strong>{" "}
{signatureSettings.showSignature
? t('groupSigning.steps.review.visible', 'Visible on page {{page}}', {
? t("groupSigning.steps.review.visible", "Visible on page {{page}}", {
page: signatureSettings.pageNumber || 1,
})
: t('groupSigning.steps.review.invisible', 'Invisible (metadata only)')}
: t("groupSigning.steps.review.invisible", "Invisible (metadata only)")}
</Text>
{signatureSettings.showSignature && signatureSettings.reason && (
<Text size="sm">
<strong>{t('groupSigning.steps.review.reason', 'Reason:')}</strong> {signatureSettings.reason}
<strong>{t("groupSigning.steps.review.reason", "Reason:")}</strong> {signatureSettings.reason}
</Text>
)}
{signatureSettings.showSignature && signatureSettings.location && (
<Text size="sm">
<strong>{t('groupSigning.steps.review.location', 'Location:')}</strong> {signatureSettings.location}
<strong>{t("groupSigning.steps.review.location", "Location:")}</strong> {signatureSettings.location}
</Text>
)}
{signatureSettings.showSignature && (
<Text size="sm">
<strong>{t('groupSigning.steps.review.logo', 'Logo:')}</strong>{' '}
<strong>{t("groupSigning.steps.review.logo", "Logo:")}</strong>{" "}
{signatureSettings.showLogo
? t('groupSigning.steps.review.logoShown', 'Stirling PDF logo shown')
: t('groupSigning.steps.review.logoHidden', 'No logo')}
? t("groupSigning.steps.review.logoShown", "Stirling PDF logo shown")
: t("groupSigning.steps.review.logoHidden", "No logo")}
</Text>
)}
</Stack>
@@ -128,7 +128,7 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
<Group gap="xs" mb="xs">
<CalendarTodayIcon sx={{ fontSize: 18 }} />
<Text size="sm" fw={600}>
{t('groupSigning.steps.review.dueDate', 'Due Date (Optional)')}
{t("groupSigning.steps.review.dueDate", "Due Date (Optional)")}
</Text>
</Group>
<TextInput
@@ -136,13 +136,13 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
value={dueDate}
onChange={(e) => onDueDateChange(e.target.value)}
disabled={disabled}
placeholder={t('groupSigning.steps.review.dueDatePlaceholder', 'Select due date...')}
placeholder={t("groupSigning.steps.review.dueDatePlaceholder", "Select due date...")}
/>
</div>
<Group gap="sm" mt="md">
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
{t('groupSigning.steps.back', 'Back')}
{t("groupSigning.steps.back", "Back")}
</Button>
<Button
onClick={onSubmit}
@@ -151,7 +151,7 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
leftSection={<SendIcon sx={{ fontSize: 16 }} />}
color="green"
>
{t('groupSigning.steps.review.send', 'Send Signing Requests')}
{t("groupSigning.steps.review.send", "Send Signing Requests")}
</Button>
</Group>
</Stack>
@@ -1,17 +1,14 @@
import { Button, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import type { FileState } from '@app/types/file';
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import type { FileState } from "@app/types/file";
interface SelectDocumentStepProps {
selectedFiles: FileState[];
onNext: () => void;
}
export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({
selectedFiles,
onNext,
}) => {
export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({ selectedFiles, onNext }) => {
const { t } = useTranslation();
const hasValidFile = selectedFiles.length === 1;
@@ -22,28 +19,28 @@ export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({
{!hasValidFile ? (
<Text size="sm" c="dimmed" ta="center">
{t(
'groupSigning.steps.selectDocument.noFile',
'Please select a single PDF file from your active files to create a signing session.'
"groupSigning.steps.selectDocument.noFile",
"Please select a single PDF file from your active files to create a signing session.",
)}
</Text>
) : (
<>
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('groupSigning.steps.selectDocument.selectedFile', 'Selected document')}
{t("groupSigning.steps.selectDocument.selectedFile", "Selected document")}
</Text>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-default)',
backgroundColor: 'var(--mantine-color-default-hover)',
display: "flex",
alignItems: "center",
gap: "12px",
padding: "12px",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-default)",
backgroundColor: "var(--mantine-color-default-hover)",
}}
>
<PictureAsPdfIcon sx={{ fontSize: 32, color: 'var(--mantine-color-red-6)' }} />
<PictureAsPdfIcon sx={{ fontSize: 32, color: "var(--mantine-color-red-6)" }} />
<div style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{selectedFile?.name}
@@ -58,7 +55,7 @@ export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({
</div>
<Button onClick={onNext} fullWidth>
{t('groupSigning.steps.selectDocument.continue', 'Continue to Participant Selection')}
{t("groupSigning.steps.selectDocument.continue", "Continue to Participant Selection")}
</Button>
</>
)}
@@ -1,7 +1,7 @@
import { Button, Stack, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import UserSelector from '@app/components/shared/UserSelector';
import { Button, Stack, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import UserSelector from "@app/components/shared/UserSelector";
interface SelectParticipantsStepProps {
selectedUserIds: number[];
@@ -26,34 +26,31 @@ export const SelectParticipantsStep: React.FC<SelectParticipantsStepProps> = ({
<Stack gap="md">
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('groupSigning.steps.selectParticipants.label', 'Select participants')}
{t("groupSigning.steps.selectParticipants.label", "Select participants")}
</Text>
<UserSelector
value={selectedUserIds}
onChange={onSelectedUserIdsChange}
placeholder={t(
'groupSigning.steps.selectParticipants.placeholder',
'Choose participants to sign...'
)}
placeholder={t("groupSigning.steps.selectParticipants.placeholder", "Choose participants to sign...")}
disabled={disabled}
/>
</div>
{selectedUserIds.length > 0 && (
<Text size="xs" c="dimmed">
{t('groupSigning.steps.selectParticipants.count', {
{t("groupSigning.steps.selectParticipants.count", {
count: selectedUserIds.length,
defaultValue: '{{count}} participant(s) selected',
defaultValue: "{{count}} participant(s) selected",
})}
</Text>
)}
<Group gap="sm">
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
{t('groupSigning.steps.back', 'Back')}
{t("groupSigning.steps.back", "Back")}
</Button>
<Button onClick={onNext} disabled={!hasParticipants || disabled} style={{ flex: 1 }}>
{t('groupSigning.steps.selectParticipants.continue', 'Continue to Signature Settings')}
{t("groupSigning.steps.selectParticipants.continue", "Continue to Signature Settings")}
</Button>
</Group>
</Stack>
@@ -1,4 +1,4 @@
import { Slider, Text, Group, NumberInput } from '@mantine/core';
import { Slider, Text, Group, NumberInput } from "@mantine/core";
interface Props {
label: string;
@@ -19,11 +19,13 @@ export default function SliderWithInput({
min = 0,
max = 200,
step = 1,
suffix = '%',
suffix = "%",
}: Props) {
return (
<div>
<Text size="sm" fw={500} mb={8}>{label}</Text>
<Text size="sm" fw={500} mb={8}>
{label}
</Text>
<Group gap="md" align="center">
<div style={{ flex: 1 }}>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
@@ -42,5 +44,3 @@ export default function SliderWithInput({
</div>
);
}
@@ -4,12 +4,16 @@
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);
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;
transition:
opacity 100ms ease-out,
transform 100ms ease-out;
max-width: 50vh;
max-height: 80vh;
color: var(--text-primary);
@@ -20,7 +24,10 @@
/* 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);
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 */
@@ -41,7 +48,9 @@
border-radius: 0.25rem;
border: 0.0625rem solid var(--border-default);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
z-index: 1;
display: flex;
align-items: center;
@@ -116,7 +125,9 @@
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;
transition:
color 0.2s ease,
text-decoration-color 0.2s ease;
}
.tooltip-body a:hover {
@@ -144,7 +155,6 @@
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
}
/* Tooltip Arrows */
.tooltip-arrow {
position: absolute;
@@ -155,7 +165,6 @@
transform: rotate(45deg);
}
.tooltip-arrow-sidebar {
top: 50%;
left: -0.25rem;
@@ -194,4 +203,4 @@
transform: translateY(-50%) rotate(45deg);
border-right: none;
border-top: none;
}
}
@@ -1,6 +1,6 @@
import React from 'react';
import styles from '@app/components/shared/tooltip/Tooltip.module.css';
import { TooltipTip } from '@app/types/tips';
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;
@@ -8,62 +8,55 @@ interface TooltipContentProps {
extraRightPadding?: number;
}
export const TooltipContent: React.FC<TooltipContentProps> = ({
content,
tips,
extraRightPadding = 0,
}) => {
export const TooltipContent: React.FC<TooltipContentProps> = ({ content, tips, extraRightPadding = 0 }) => {
return (
<div
className={`${styles['tooltip-body']}`}
className={`${styles["tooltip-body"]}`}
style={{
color: 'var(--text-primary)',
color: "var(--text-primary)",
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
fontSize: '14px',
lineHeight: '1.6'
fontSize: "14px",
lineHeight: "1.6",
}}
>
<div style={{ color: 'var(--text-primary)' }}>
<div style={{ color: "var(--text-primary)" }}>
{tips ? (
<>
{tips.map((tip, index) => (
<div key={index} style={{ marginBottom: index < tips.length - 1 ? '24px' : '0' }}>
<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'
}}>
<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 }} />
<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' }}>
<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 }} />
<li key={bulletIndex} style={{ marginBottom: "6px" }} dangerouslySetInnerHTML={{ __html: bullet }} />
))}
</ul>
)}
{tip.body && (
<div style={{ marginTop: '12px' }}>
{tip.body}
</div>
)}
{tip.body && <div style={{ marginTop: "12px" }}>{tip.body}</div>}
</div>
))}
{content && (
<div style={{ marginTop: '24px' }}>
{content}
</div>
)}
{content && <div style={{ marginTop: "24px" }}>{content}</div>}
</>
) : (
content
@@ -71,4 +64,4 @@ export const TooltipContent: React.FC<TooltipContentProps> = ({
</div>
</div>
);
};
};
@@ -1,7 +1,7 @@
import { useRef, useEffect, useState } from 'react';
import { Stack, Button, Group, ColorPicker, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DeleteIcon from '@mui/icons-material/Delete';
import { useRef, useEffect, useState } from "react";
import { Stack, Button, Group, ColorPicker, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DeleteIcon from "@mui/icons-material/Delete";
interface DrawSignatureCanvasProps {
signature: string | null;
@@ -9,21 +9,17 @@ interface DrawSignatureCanvasProps {
disabled?: boolean;
}
export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
signature,
onChange,
disabled = false,
}) => {
export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({ signature, onChange, disabled = false }) => {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [penColor, setPenColor] = useState('#000000');
const [penColor, setPenColor] = useState("#000000");
const [penSize, setPenSize] = useState(2);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Clear canvas
@@ -48,7 +44,7 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(x, y);
@@ -62,12 +58,12 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.lineTo(x, y);
ctx.strokeStyle = penColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineCap = "round";
ctx.stroke();
};
@@ -78,14 +74,14 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
if (!canvas) return;
// Convert canvas to base64 and save
const dataUrl = canvas.toDataURL('image/png');
const dataUrl = canvas.toDataURL("image/png");
onChange(dataUrl);
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
onChange(null);
@@ -94,7 +90,7 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t('certSign.collab.signRequest.drawSignature', 'Draw your signature below')}
{t("certSign.collab.signRequest.drawSignature", "Draw your signature below")}
</Text>
<canvas
@@ -106,30 +102,25 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
style={{
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-default)',
cursor: disabled ? 'not-allowed' : 'crosshair',
width: '100%',
maxWidth: '400px',
backgroundColor: 'white',
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-default)",
cursor: disabled ? "not-allowed" : "crosshair",
width: "100%",
maxWidth: "400px",
backgroundColor: "white",
}}
/>
<Group gap="sm">
<div style={{ flex: 1 }}>
<Text size="xs" mb={4}>
{t('certSign.collab.signRequest.penColor', 'Pen Color')}
{t("certSign.collab.signRequest.penColor", "Pen Color")}
</Text>
<ColorPicker
value={penColor}
onChange={setPenColor}
format="hex"
size="xs"
/>
<ColorPicker value={penColor} onChange={setPenColor} format="hex" size="xs" />
</div>
<div style={{ flex: 2 }}>
<Text size="xs" mb={4}>
{t('certSign.collab.signRequest.penSize', 'Pen Size: {{size}}px', { size: penSize })}
{t("certSign.collab.signRequest.penSize", "Pen Size: {{size}}px", { size: penSize })}
</Text>
<Slider
value={penSize}
@@ -139,9 +130,9 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
step={1}
disabled={disabled}
marks={[
{ value: 1, label: '1' },
{ value: 5, label: '5' },
{ value: 10, label: '10' },
{ value: 1, label: "1" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
]}
/>
</div>
@@ -155,7 +146,7 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
disabled={disabled || !signature}
fullWidth
>
{t('certSign.collab.signRequest.clearSignature', 'Clear Signature')}
{t("certSign.collab.signRequest.clearSignature", "Clear Signature")}
</Button>
</Stack>
);
@@ -1,7 +1,7 @@
import { SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SegmentedControl } from "@mantine/core";
import { useTranslation } from "react-i18next";
export type SignatureType = 'draw' | 'upload' | 'type';
export type SignatureType = "draw" | "upload" | "type";
interface SignatureTypeSelectorProps {
value: SignatureType;
@@ -9,11 +9,7 @@ interface SignatureTypeSelectorProps {
disabled?: boolean;
}
export const SignatureTypeSelector: React.FC<SignatureTypeSelectorProps> = ({
value,
onChange,
disabled = false,
}) => {
export const SignatureTypeSelector: React.FC<SignatureTypeSelectorProps> = ({ value, onChange, disabled = false }) => {
const { t } = useTranslation();
return (
@@ -23,16 +19,16 @@ export const SignatureTypeSelector: React.FC<SignatureTypeSelectorProps> = ({
disabled={disabled}
data={[
{
value: 'draw',
label: t('certSign.collab.signRequest.signatureType.draw', 'Draw'),
value: "draw",
label: t("certSign.collab.signRequest.signatureType.draw", "Draw"),
},
{
value: 'upload',
label: t('certSign.collab.signRequest.signatureType.upload', 'Upload'),
value: "upload",
label: t("certSign.collab.signRequest.signatureType.upload", "Upload"),
},
{
value: 'type',
label: t('certSign.collab.signRequest.signatureType.type', 'Type'),
value: "type",
label: t("certSign.collab.signRequest.signatureType.type", "Type"),
},
]}
fullWidth
@@ -1,6 +1,6 @@
import { Stack, TextInput, Select, ColorPicker, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useEffect, useRef } from 'react';
import { Stack, TextInput, Select, ColorPicker, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useEffect, useRef } from "react";
interface TypeSignatureTextProps {
text: string;
@@ -38,7 +38,7 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
}
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Clear canvas
@@ -52,40 +52,40 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
// Center text on canvas
ctx.fillStyle = color;
ctx.textBaseline = 'middle';
ctx.textBaseline = "middle";
ctx.fillText(text, (canvas.width - textWidth) / 2, canvas.height / 2);
// Convert to base64
const dataUrl = canvas.toDataURL('image/png');
const dataUrl = canvas.toDataURL("image/png");
onSignatureChange(dataUrl);
}, [text, fontFamily, fontSize, color, onSignatureChange]);
const fontOptions = [
{ value: 'Arial', label: 'Arial' },
{ value: 'Times New Roman', label: 'Times New Roman' },
{ value: 'Courier New', label: 'Courier New' },
{ value: 'Georgia', label: 'Georgia' },
{ value: 'Verdana', label: 'Verdana' },
{ value: 'Comic Sans MS', label: 'Comic Sans MS' },
{ value: 'Brush Script MT', label: 'Brush Script MT (cursive)' },
{ value: "Arial", label: "Arial" },
{ value: "Times New Roman", label: "Times New Roman" },
{ value: "Courier New", label: "Courier New" },
{ value: "Georgia", label: "Georgia" },
{ value: "Verdana", label: "Verdana" },
{ value: "Comic Sans MS", label: "Comic Sans MS" },
{ value: "Brush Script MT", label: "Brush Script MT (cursive)" },
];
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t('certSign.collab.signRequest.typeSignature', 'Type your name to create a signature')}
{t("certSign.collab.signRequest.typeSignature", "Type your name to create a signature")}
</Text>
<TextInput
label={t('certSign.collab.signRequest.signatureText', 'Signature Text')}
placeholder={t('certSign.collab.signRequest.signatureTextPlaceholder', 'Enter your name...')}
label={t("certSign.collab.signRequest.signatureText", "Signature Text")}
placeholder={t("certSign.collab.signRequest.signatureTextPlaceholder", "Enter your name...")}
value={text}
onChange={(e) => onTextChange(e.target.value)}
disabled={disabled}
/>
<Select
label={t('certSign.collab.signRequest.fontFamily', 'Font Family')}
label={t("certSign.collab.signRequest.fontFamily", "Font Family")}
value={fontFamily}
onChange={(val) => val && onFontFamilyChange(val)}
data={fontOptions}
@@ -94,7 +94,7 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
<div>
<Text size="sm" mb={4}>
{t('certSign.collab.signRequest.fontSize', 'Font Size: {{size}}px', { size: fontSize })}
{t("certSign.collab.signRequest.fontSize", "Font Size: {{size}}px", { size: fontSize })}
</Text>
<Slider
value={fontSize}
@@ -104,36 +104,32 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
step={2}
disabled={disabled}
marks={[
{ value: 20, label: '20' },
{ value: 50, label: '50' },
{ value: 80, label: '80' },
{ value: 20, label: "20" },
{ value: 50, label: "50" },
{ value: 80, label: "80" },
]}
/>
</div>
<div>
<Text size="sm" mb={4}>
{t('certSign.collab.signRequest.textColor', 'Text Color')}
{t("certSign.collab.signRequest.textColor", "Text Color")}
</Text>
<ColorPicker
value={color}
onChange={onColorChange}
format="hex"
/>
<ColorPicker value={color} onChange={onColorChange} format="hex" />
</div>
{/* Preview */}
{text && (
<div
style={{
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-default)',
padding: '16px',
backgroundColor: 'white',
minHeight: '100px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-default)",
padding: "16px",
backgroundColor: "white",
minHeight: "100px",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Text
@@ -149,12 +145,7 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
)}
{/* Hidden canvas for image generation */}
<canvas
ref={canvasRef}
width={400}
height={150}
style={{ display: 'none' }}
/>
<canvas ref={canvasRef} width={400} height={150} style={{ display: "none" }} />
</Stack>
);
};
@@ -1,8 +1,8 @@
import { useState, useRef } from 'react';
import { Stack, Button, Text, Image } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import DeleteIcon from '@mui/icons-material/Delete';
import { useState, useRef } from "react";
import { Stack, Button, Text, Image } from "@mantine/core";
import { useTranslation } from "react-i18next";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import DeleteIcon from "@mui/icons-material/Delete";
interface UploadSignatureImageProps {
signature: string | null;
@@ -10,11 +10,7 @@ interface UploadSignatureImageProps {
disabled?: boolean;
}
export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
signature,
onChange,
disabled = false,
}) => {
export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({ signature, onChange, disabled = false }) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(null);
@@ -24,14 +20,14 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setError(t('certSign.collab.signRequest.invalidFileType', 'Please select an image file'));
if (!file.type.startsWith("image/")) {
setError(t("certSign.collab.signRequest.invalidFileType", "Please select an image file"));
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setError(t('certSign.collab.signRequest.fileTooLarge', 'File size must be less than 5MB'));
setError(t("certSign.collab.signRequest.fileTooLarge", "File size must be less than 5MB"));
return;
}
@@ -54,36 +50,31 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
onChange(null);
setError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.value = "";
}
};
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t('certSign.collab.signRequest.uploadSignature', 'Upload your signature image')}
{t("certSign.collab.signRequest.uploadSignature", "Upload your signature image")}
</Text>
{signature ? (
<Stack gap="sm">
<div
style={{
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-default)',
padding: '16px',
backgroundColor: 'var(--mantine-color-default-hover)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '150px',
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-default)",
padding: "16px",
backgroundColor: "var(--mantine-color-default-hover)",
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "150px",
}}
>
<Image
src={signature}
alt="Signature"
fit="contain"
style={{ maxHeight: '150px', maxWidth: '100%' }}
/>
<Image src={signature} alt="Signature" fit="contain" style={{ maxHeight: "150px", maxWidth: "100%" }} />
</div>
<Button
@@ -94,7 +85,7 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
disabled={disabled}
fullWidth
>
{t('certSign.collab.signRequest.removeImage', 'Remove Image')}
{t("certSign.collab.signRequest.removeImage", "Remove Image")}
</Button>
</Stack>
) : (
@@ -105,7 +96,7 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
disabled={disabled}
fullWidth
>
{t('certSign.collab.signRequest.selectFile', 'Select Image File')}
{t("certSign.collab.signRequest.selectFile", "Select Image File")}
</Button>
)}
@@ -119,7 +110,7 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
style={{ display: "none" }}
onChange={handleFileSelect}
disabled={disabled}
/>