Prettier 2: Electric Boogaloo (#6113)

# Description of Changes
When I added Prettier formatting in #6052, my aim was to use just the
default settings in Prettier. Turns out, Prettier looks _really hard_
for any config files if it's not explicitly given one, which means that
if a developer has some sort of Prettier config file lying around on
their system, Prettier might find it and use it. Also, Prettier changes
its defaults based on stuff in `.editorconfig` without any good way of
disabling that behaviour explicitly in its config file.

To solve both of these issues, I've introduced a `.prettierrc` file
which sets Prettier's defaults explicitly, and then reformatted all our
code _again_ in Prettier's actual default settings. This should achieve
the aim of #6052 and remove the possibility for it breaking on different
dev computers.
This commit is contained in:
James Brunton
2026-04-17 09:50:16 +00:00
committed by GitHub
parent de8c483054
commit 8ab060a4be
1207 changed files with 52646 additions and 15352 deletions
@@ -2,7 +2,10 @@ 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 {
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";
@@ -12,9 +15,17 @@ interface AllToolsNavButtonProps {
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 {
handleReaderToggle,
handleBackToTools,
selectedToolKey,
leftPanelView,
} = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
@@ -35,7 +46,10 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
};
// 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();
@@ -1,4 +1,10 @@
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
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";
@@ -7,9 +13,15 @@ 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 {
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 {
UnsavedChangesProvider,
useUnsavedChanges,
} from "@app/contexts/UnsavedChangesContext";
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
interface AppConfigModalProps {
@@ -17,7 +29,10 @@ interface AppConfigModalProps {
onClose: () => void;
}
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
opened,
onClose,
}) => {
const [active, setActive] = useState<NavKey>("general");
const isMobile = useIsMobile();
const navigate = useNavigate();
@@ -42,7 +57,11 @@ 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 });
}
@@ -64,7 +83,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
}
};
window.addEventListener("appConfig:navigate", handler as EventListener);
return () => window.removeEventListener("appConfig:navigate", handler as EventListener);
return () =>
window.removeEventListener(
"appConfig:navigate",
handler as EventListener,
);
}, [navigate]);
const colors = useMemo(
@@ -86,7 +109,11 @@ 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) {
@@ -151,7 +178,12 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{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>
)}
@@ -159,10 +191,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{section.items.map((item) => {
const isActive = active === item.key;
const isDisabled = item.disabled ?? false;
const color = isActive ? colors.navItemActive : colors.navItem;
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
@@ -174,20 +210,32 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
}}
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",
}}
data-tour={`admin-${item.key}-nav`}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
<LocalIcon
icon={item.icon}
width={iconSize}
height={iconSize}
style={{ color }}
/>
{!isMobile && (
<Group gap={4} align="center" wrap="nowrap">
<Text size="sm" fw={500} style={{ color }}>
{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 +244,9 @@ 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>
@@ -215,7 +265,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{navItemContent}
</Tooltip>
) : (
<React.Fragment key={item.key}>{navItemContent}</React.Fragment>
<React.Fragment key={item.key}>
{navItemContent}
</React.Fragment>
);
})}
</div>
@@ -239,8 +291,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{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>
@@ -1,5 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core";
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";
@@ -22,7 +32,12 @@ 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();
@@ -30,7 +45,9 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
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) {
@@ -50,7 +67,9 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
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}`);
@@ -58,9 +77,12 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
const createShareLink = useCallback(
async (storedFileId: number) => {
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
accessRole: shareRole,
});
const response = await apiClient.post(
`/api/v1/storage/files/${storedFileId}/shares/links`,
{
accessRole: shareRole,
},
);
return response.data as { token?: string };
},
[shareRole],
@@ -81,11 +103,26 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
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);
@@ -118,7 +155,12 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
}
} catch (error: any) {
console.error("Failed to generate share link:", error);
setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again."));
setErrorMessage(
t(
"storageShare.failure",
"Unable to generate a share link. Please try again.",
),
);
} finally {
setIsWorking(false);
}
@@ -159,7 +201,10 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
<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.")}
{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", {
@@ -170,13 +215,22 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
</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>
)}
@@ -191,7 +245,9 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
<Button
variant="subtle"
size="xs"
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
leftSection={
<ContentCopyRoundedIcon style={{ fontSize: 16 }} />
}
onClick={handleCopyLink}
>
{t("storageShare.copy", "Copy")}
@@ -210,12 +266,26 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
<Select
label={t("storageShare.roleLabel", "Role")}
value={shareRole}
onChange={(value) => setShareRole((value as typeof shareRole) || "editor")}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
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" && (
@@ -18,7 +18,12 @@ 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);
@@ -39,11 +44,23 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opene
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 existingRemoteId = 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 =
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, {
@@ -72,7 +89,12 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opene
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."));
setErrorMessage(
t(
"storageUpload.failure",
"Upload failed. Please check your login and storage settings.",
),
);
} finally {
setIsUploading(false);
}
@@ -87,7 +109,12 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opene
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", {
count: files.length,
@@ -105,7 +132,10 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opene
)}
{errorMessage && (
<Alert color="red" title={t("storageUpload.errorTitle", "Upload failed")}>
<Alert
color="red"
title={t("storageUpload.errorTitle", "Upload failed")}
>
{errorMessage}
</Alert>
)}
@@ -114,7 +144,11 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opene
<Button variant="default" onClick={onClose} disabled={isUploading}>
{t("cancel", "Cancel")}
</Button>
<Button leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />} onClick={handleUpload} loading={isUploading}>
<Button
leftSection={<CloudUploadIcon style={{ fontSize: 18 }} />}
onClick={handleUpload}
loading={isUploading}
>
{t("storageUpload.uploadButton", "Upload to Server")}
</Button>
</Group>
@@ -4,7 +4,9 @@ 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", () => {
const mockOnChange = vi.fn();
@@ -21,7 +23,12 @@ describe("ButtonSelector", () => {
render(
<TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} label="Test Label" />
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Test Label"
/>
</TestWrapper>,
);
@@ -38,7 +45,12 @@ describe("ButtonSelector", () => {
render(
<TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} label="Selection Label" />
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Selection Label"
/>
</TestWrapper>,
);
@@ -59,7 +71,11 @@ describe("ButtonSelector", () => {
render(
<TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} />
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>,
);
@@ -76,7 +92,11 @@ describe("ButtonSelector", () => {
render(
<TestWrapper>
<ButtonSelector value={undefined} onChange={mockOnChange} options={options} />
<ButtonSelector
value={undefined}
onChange={mockOnChange}
options={options}
/>
</TestWrapper>,
);
@@ -110,7 +130,12 @@ describe("ButtonSelector", () => {
])("should $description", ({ options, globalDisabled, expectedStates }) => {
render(
<TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} disabled={globalDisabled} />
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
disabled={globalDisabled}
/>
</TestWrapper>,
);
@@ -128,7 +153,11 @@ describe("ButtonSelector", () => {
render(
<TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} />
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>,
);
@@ -145,7 +174,13 @@ describe("ButtonSelector", () => {
render(
<TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} fullWidth={false} label="Layout Label" />
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
fullWidth={false}
label="Layout Label"
/>
</TestWrapper>,
);
@@ -162,7 +197,11 @@ describe("ButtonSelector", () => {
const { container } = render(
<TestWrapper>
<ButtonSelector value="option1" onChange={mockOnChange} options={options} />
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>,
);
@@ -171,7 +210,9 @@ describe("ButtonSelector", () => {
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"]');
const stackElement = container.querySelector(
'[class*="mantine-Stack-root"]',
);
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
});
});
@@ -51,7 +51,11 @@ const ButtonSelector = <T extends string | number>({
const button = (
<Button
variant={value === option.value ? "filled" : "outline"}
color={value === option.value ? "var(--color-primary-500)" : "var(--text-muted)"}
color={
value === option.value
? "var(--color-primary-500)"
: "var(--text-muted)"
}
onClick={() => onChange(option.value)}
disabled={isDisabled}
className={buttonClassName}
@@ -65,21 +69,39 @@ const ButtonSelector = <T extends string | number>({
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>
);
// Wrap with tooltip if provided (useful for disabled state explanations)
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>
<Tooltip
key={option.value}
label={option.tooltip}
position="top"
withArrow
>
<span
style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}
>
{button}
</span>
</Tooltip>
);
}
return (
<span key={option.value} style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}>
<span
key={option.value}
style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}
>
{button}
</span>
);
@@ -74,5 +74,9 @@ export const ButtonToggle: React.FC<ButtonToggleProps> = ({
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>
);
};
@@ -47,7 +47,12 @@ 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}>
<Tooltip
key={option.value as string}
sidebarTooltip
tips={tips}
disabled={tips.length === 0}
>
<Card
radius="md"
w="100%"
@@ -63,14 +68,17 @@ const CardSelector = <T, K extends CardOption<T>>({
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-3)";
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)";
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.backgroundColor =
"var(--mantine-color-gray-2)";
e.currentTarget.style.transform = "translateY(0px)";
e.currentTarget.style.boxShadow = "none";
}
@@ -81,7 +89,13 @@ const CardSelector = <T, K extends CardOption<T>>({
<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" }}>
<Text
fw={600}
size="sm"
c={undefined}
ta="center"
style={{ marginLeft: "0.25rem" }}
>
{t(option.nameKey, "Option Name")}
</Text>
</Flex>
@@ -10,7 +10,9 @@ interface DismissAllErrorsButtonProps {
className?: string;
}
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({
className,
}) => {
const { t } = useTranslation();
const { state } = useFileState();
const { actions } = useFileActions();
@@ -43,7 +45,8 @@ const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ classNa
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,5 +1,13 @@
import React, { ReactNode, useState, useMemo } from "react";
import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from "@mantine/core";
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";
@@ -73,7 +81,9 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
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) => {
@@ -91,7 +101,9 @@ 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`;
@@ -122,10 +134,12 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
<Popover.Target>
<Box
style={{
border: "light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))",
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))",
backgroundColor:
"light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))",
opacity: disabled ? 0.6 : 1,
cursor: disabled ? "not-allowed" : "pointer",
minHeight: "36px",
@@ -140,7 +154,8 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
<UnfoldMoreIcon
style={{
fontSize: "1rem",
color: "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2))",
color:
"light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2))",
}}
/>
</Box>
@@ -151,7 +166,8 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
{header && (
<Box
style={{
borderBottom: "light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))",
borderBottom:
"light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))",
paddingBottom: "8px",
}}
>
@@ -162,7 +178,8 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
{searchable && (
<Box
style={{
borderBottom: "light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))",
borderBottom:
"light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))",
paddingBottom: "8px",
}}
>
@@ -181,14 +198,18 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
{filteredItems.length === 0 ? (
<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)}
onClick={() =>
!item.disabled && handleItemClick(item.value)
}
style={{
padding: "8px 12px",
cursor: item.disabled ? "not-allowed" : "pointer",
@@ -209,7 +230,11 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
}}
>
<Group gap="sm" style={{ flex: 1 }}>
{item.leftIcon && <Box style={{ display: "flex", alignItems: "center" }}>{item.leftIcon}</Box>}
{item.leftIcon && (
<Box style={{ display: "flex", alignItems: "center" }}>
{item.leftIcon}
</Box>
)}
<Text size="sm">{item.name}</Text>
</Group>
@@ -229,7 +254,8 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
{footer && (
<Box
style={{
borderTop: "light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))",
borderTop:
"light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))",
paddingTop: "8px",
}}
>
@@ -1,5 +1,11 @@
import { useState, useRef, useEffect } from "react";
import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from "@mantine/core";
import {
PasswordInput,
Group,
ActionIcon,
Tooltip,
TextInput,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -63,15 +69,38 @@ export default function EditableSecretField({
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">
<ActionIcon
variant="light"
onClick={handleEdit}
disabled={disabled}
title="Edit"
aria-label="Edit secret value"
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
@@ -1,4 +1,11 @@
import { Modal, Stack, Text, Button, PasswordInput, Group } from "@mantine/core";
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";
@@ -61,7 +68,10 @@ const EncryptedPdfUnlockModal = ({
<Stack gap={4}>
<PasswordInput
label={t("encryptedPdfUnlock.password.label", "PDF password")}
placeholder={t("encryptedPdfUnlock.password.placeholder", "Enter the PDF password")}
placeholder={t(
"encryptedPdfUnlock.password.placeholder",
"Enter the PDF password",
)}
value={password}
onChange={(event) => onPasswordChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
@@ -76,16 +86,32 @@ const EncryptedPdfUnlockModal = ({
</Stack>
<Group justify="space-between">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onSkip}
disabled={isProcessing}
>
{t("encryptedPdfUnlock.skip", "Skip for now")}
</Button>
<Group gap="xs">
{remainingCount > 0 && (
<Button variant="light" onClick={onUnlockAll} loading={isProcessing} disabled={password.trim().length === 0}>
{t("encryptedPdfUnlock.unlockAll", "Use for all ({{count}})", { count: remainingCount + 1 })}
<Button
variant="light"
onClick={onUnlockAll}
loading={isProcessing}
disabled={password.trim().length === 0}
>
{t("encryptedPdfUnlock.unlockAll", "Use for all ({{count}})", {
count: remainingCount + 1,
})}
</Button>
)}
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
<Button
onClick={onUnlock}
loading={isProcessing}
disabled={password.trim().length === 0}
>
{t("encryptedPdfUnlock.unlock", "Unlock & Continue")}
</Button>
</Group>
@@ -11,7 +11,10 @@ interface ErrorBoundaryProps {
fallback?: React.ComponentType<{ error?: Error; retry: () => void }>;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
export default class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
@@ -42,7 +45,9 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
const errorCodeMatch = error.message.match(/#(\d+)/);
if (errorCodeMatch) {
const errorCode = errorCodeMatch[1];
console.error(`React Error #${errorCode}: https://react.dev/errors/${errorCode}`);
console.error(
`React Error #${errorCode}: https://react.dev/errors/${errorCode}`,
);
}
}
@@ -75,19 +80,34 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
<Stack
align="center"
justify="center"
style={{ minHeight: "200px", padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
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" }}>
<summary
style={{ cursor: "pointer", marginBottom: "0.5rem" }}
>
<Text size="sm" component="span">
Show stack trace
</Text>
@@ -1,5 +1,18 @@
import { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip, Loader } from "@mantine/core";
import {
Card,
Stack,
Text,
Group,
Badge,
Button,
Box,
Image,
ThemeIcon,
ActionIcon,
Tooltip,
Loader,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
@@ -35,7 +48,8 @@ const FileCard = ({
}: FileCardProps) => {
const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub);
const { thumbnail: indexedDBThumb, isGenerating } =
useIndexedDBThumbnail(fileStub);
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
@@ -55,7 +69,9 @@ const FileCard = ({
maxWidth: 260,
cursor: onDoubleClick && isSupported ? "pointer" : undefined,
position: "relative",
border: isSelected ? "2px solid var(--mantine-color-blue-6)" : undefined,
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%)",
@@ -131,7 +147,14 @@ const FileCard = ({
</div>
)}
{thumb ? (
<Image src={thumb} alt="PDF thumbnail" height={110} width={80} fit="contain" radius="sm" />
<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" />
@@ -153,7 +176,11 @@ const FileCard = ({
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
@@ -178,7 +205,12 @@ const FileCard = ({
{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>
)}
@@ -31,7 +31,9 @@ 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" />
) : (
@@ -73,7 +75,10 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
justifyContent: "flex-start",
}}
>
<Group gap="xs" style={{ width: "100%", justifyContent: "space-between" }}>
<Group
gap="xs"
style={{ width: "100%", justifyContent: "space-between" }}
>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<PrivateContent>
<FitText
@@ -1,5 +1,13 @@
import { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
import {
Box,
Flex,
Group,
Text,
Button,
TextInput,
Select,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
@@ -47,7 +55,9 @@ const FileGrid = ({
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) => {
@@ -64,9 +74,11 @@ 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;
const hasMoreFiles =
maxDisplay && !showingAll && sortedFiles.length > maxDisplay;
return (
<Box>
@@ -87,9 +99,18 @@ 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)}
@@ -108,11 +129,20 @@ 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) => {
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;
@@ -120,18 +150,26 @@ const FileGrid = ({
.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;
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}
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}
onSelect={
onSelect && supported ? () => onSelect(fileId) : undefined
}
isSupported={supported}
/>
);
@@ -152,7 +190,10 @@ const FileGrid = ({
<Box style={{ textAlign: "center", padding: "2rem" }}>
<Text c="dimmed">
{searchTerm
? t("fileManager.noFilesFound", "No files found matching your search")
? t(
"fileManager.noFilesFound",
"No files found matching your search",
)
: t("fileManager.noFiles", "No files available")}
</Text>
</Box>
@@ -25,7 +25,12 @@ 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[]>([]);
@@ -39,7 +44,9 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
const toggleFileSelection = (fileId: FileId) => {
setSelectedFileIds((prev) => {
return prev.includes(fileId) ? prev.filter((id) => id !== fileId) : [...prev, fileId];
return prev.includes(fileId)
? prev.filter((id) => id !== fileId)
: [...prev, fileId];
});
};
@@ -52,7 +59,9 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
};
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(
@@ -69,9 +78,14 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
}
// 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(),
@@ -80,7 +94,9 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
// 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(),
@@ -130,8 +146,11 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
{/* Selection controls */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{storedFiles.length} {t("fileUpload.filesAvailable", "files available")}
{selectedFileIds.length > 0 && <> {selectedFileIds.length} selected</>}
{storedFiles.length}{" "}
{t("fileUpload.filesAvailable", "files available")}
{selectedFileIds.length > 0 && (
<> {selectedFileIds.length} selected</>
)}
</Text>
<Group gap="xs">
<Button size="xs" variant="light" onClick={selectAll}>
@@ -155,9 +174,13 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
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",
backgroundColor: isSelected
? "var(--mantine-color-blue-0)"
: "transparent",
cursor: "pointer",
transition: "all 0.2s ease",
}}
@@ -185,7 +208,13 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
}}
>
{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}>
<PictureAsPdfIcon style={{ fontSize: 24 }} />
@@ -200,7 +229,9 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
</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>
@@ -214,7 +245,8 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
{/* Selection summary */}
{selectedFileIds.length > 0 && (
<Text size="sm" c="blue" ta="center">
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
{selectedFileIds.length}{" "}
{t("fileManager.filesSelected", "files selected")}
</Text>
)}
</>
@@ -225,7 +257,10 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
<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")}
@@ -68,7 +68,12 @@ const FilePreview: React.FC<FilePreviewProps> = ({
// 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
@@ -84,7 +89,11 @@ const FilePreview: React.FC<FilePreviewProps> = ({
// 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>
);
@@ -27,7 +27,12 @@ const FileUploadButton = ({
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}
@@ -1,5 +1,12 @@
import { useState } from "react";
import { Modal, Stack, Text, PasswordInput, Button, Alert } from "@mantine/core";
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";
@@ -18,7 +25,11 @@ interface FirstLoginModalProps {
* Forces first-time users to change their password.
* Cannot be dismissed until password is successfully changed.
*/
export default function FirstLoginModal({ opened, onPasswordChanged, username }: FirstLoginModalProps) {
export default function FirstLoginModal({
opened,
onPasswordChanged,
username,
}: FirstLoginModalProps) {
const { t } = useTranslation();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@@ -34,17 +45,29 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
}
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;
}
@@ -52,11 +75,18 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError("");
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
await accountService.changePasswordOnLogin(
currentPassword,
newPassword,
confirmPassword,
);
alert({
alertType: "success",
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
title: t(
"firstLogin.passwordChangedSuccess",
"Password changed successfully! Please log in again.",
),
});
// Clear form
@@ -73,7 +103,10 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
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);
@@ -99,12 +132,16 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
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 && (
@@ -119,7 +156,10 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
<PasswordInput
label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
placeholder={t(
"firstLogin.enterCurrentPassword",
"Enter your current password",
)}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
@@ -127,7 +167,10 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
<PasswordInput
label={t("firstLogin.newPassword", "New Password")}
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
placeholder={t(
"firstLogin.enterNewPassword",
"Enter new password (min 8 characters)",
)}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
@@ -136,7 +179,10 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
<PasswordInput
label={t("firstLogin.confirmPassword", "Confirm New Password")}
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
placeholder={t(
"firstLogin.reEnterNewPassword",
"Re-enter new password",
)}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
minLength={8}
@@ -148,7 +194,11 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
onClick={handleSubmit}
loading={loading}
disabled={
!currentPassword || !newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8
!currentPassword ||
!newPassword ||
!confirmPassword ||
newPassword.length < 8 ||
confirmPassword.length < 8
}
mt="md"
>
@@ -44,7 +44,9 @@ const FitText: React.FC<FitTextProps> = ({
const displayText = useMemo(() => {
if (!text) return text;
if (!lines || lines <= 1) return text;
const chars = Array.isArray(softBreakChars) ? softBreakChars : [softBreakChars];
const chars = Array.isArray(softBreakChars)
? softBreakChars
: [softBreakChars];
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(${chars.filter(Boolean).map(esc).join("|")})`, "g");
return text.replace(re, `$1\u200B`);
@@ -67,7 +69,11 @@ const FitText: React.FC<FitTextProps> = ({
};
return (
<ElementTag ref={ref} className={className} style={{ ...clampStyles, ...style }}>
<ElementTag
ref={ref}
className={className}
style={{ ...clampStyles, ...style }}
>
{displayText}
</ElementTag>
);
+59 -14
View File
@@ -26,14 +26,20 @@ export default function Footer({
const { footerInfo } = useFooterInfo();
// Use props if provided, otherwise fall back to fetched footer info
const finalAnalyticsEnabled = analyticsEnabled ?? footerInfo?.analyticsEnabled ?? false;
const finalAnalyticsEnabled =
analyticsEnabled ?? footerInfo?.analyticsEnabled ?? false;
const finalPrivacyPolicy = privacyPolicy ?? footerInfo?.privacyPolicy;
const finalTermsAndConditions = termsAndConditions ?? footerInfo?.termsAndConditions;
const finalAccessibilityStatement = accessibilityStatement ?? footerInfo?.accessibilityStatement;
const finalTermsAndConditions =
termsAndConditions ?? footerInfo?.termsAndConditions;
const finalAccessibilityStatement =
accessibilityStatement ?? footerInfo?.accessibilityStatement;
const finalCookiePolicy = cookiePolicy ?? footerInfo?.cookiePolicy;
const finalImpressum = impressum ?? footerInfo?.impressum;
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled: finalAnalyticsEnabled, forceLightMode });
const { showCookiePreferences } = useCookieConsent({
analyticsEnabled: finalAnalyticsEnabled,
forceLightMode,
});
// Default URLs
const defaultTermsUrl = "https://www.stirling.com/terms";
@@ -43,7 +49,8 @@ export default function Footer({
// Use provided URLs or fall back to defaults
const finalTermsUrl = finalTermsAndConditions || defaultTermsUrl;
const finalPrivacyUrl = finalPrivacyPolicy || defaultPrivacyUrl;
const finalAccessibilityUrl = finalAccessibilityStatement || defaultAccessibilityUrl;
const finalAccessibilityUrl =
finalAccessibilityStatement || defaultAccessibilityUrl;
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
@@ -52,8 +59,12 @@ export default function Footer({
<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)",
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",
@@ -78,13 +89,28 @@ export default function Footer({
>
{t("survey.nav", "Survey")}
</a>
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href={finalPrivacyUrl}>
<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}>
<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">
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://discord.gg/Cn8pWhQRxZ"
>
{t("footer.discord", "Discord")}
</a>
<a
@@ -95,21 +121,40 @@ export default function Footer({
>
{t("footer.issues", "GitHub")}
</a>
<a className="footer-link px-3" target="_blank" rel="noopener noreferrer" href={finalAccessibilityUrl}>
<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}>
<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}>
<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}>
<button
className="footer-link px-3"
id="cookieBanner"
onClick={showCookiePreferences}
>
{t("legal.showCookieBanner", "Cookie Preferences")}
</button>
)}
@@ -20,7 +20,12 @@ interface HoverActionMenuProps {
className?: string;
}
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ show, actions, position = "inside", className = "" }) => {
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
show,
actions,
position = "inside",
className = "",
}) => {
const visibleActions = actions.filter((action) => !action.hidden);
if (visibleActions.length === 0) {
@@ -97,8 +97,19 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
alignItems: "center",
}}
>
<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 }}>
<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}
width="1.2rem"
@@ -107,11 +118,20 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text fw={600} size="sm" style={{ color: textColor ?? toneStyle.text }}>
<Text
fw={600}
size="sm"
style={{ color: textColor ?? toneStyle.text }}
>
{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>
@@ -124,7 +144,9 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
}
>
{buttonText}
</Button>
@@ -14,7 +14,12 @@ type LandingActionsProps = {
onFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
export function LandingActions({ fileInputRef, onUploadClick, onMobileUploadClick, onFileSelect }: LandingActionsProps) {
export function LandingActions({
fileInputRef,
onUploadClick,
onMobileUploadClick,
onFileSelect,
}: LandingActionsProps) {
const terminology = useFileActionTerminology();
const { openFilesModal } = useFilesModalContext();
const icons = useFileActionIcons();
@@ -26,7 +31,14 @@ 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" }} />}
leftSection={
<LocalIcon
icon={icons.uploadIconName}
width="1rem"
height="1rem"
style={{ color: "white" }}
/>
}
onClick={(e) => {
e.stopPropagation();
onUploadClick();
@@ -38,7 +50,14 @@ export function LandingActions({ fileInputRef, onUploadClick, onMobileUploadClic
<Button
variant="default"
classNames={{ root: "landing-btn-secondary" }}
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" className="text-[var(--accent-interactive)]" />}
leftSection={
<LocalIcon
icon="add"
width="1rem"
height="1rem"
className="text-[var(--accent-interactive)]"
/>
}
onClick={(e) => {
e.stopPropagation();
openFilesModal();
@@ -60,12 +79,22 @@ export function LandingActions({ fileInputRef, onUploadClick, onMobileUploadClic
onMobileUploadClick();
}}
>
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" />
<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" }}
/>
</>
);
}
@@ -10,7 +10,10 @@ export function LandingDocumentStack() {
<div aria-hidden className="landing-stack">
<div className="landing-sheet landing-sheet--back landing-sheet--left">
<div className="landing-sheet-side-body">
<div className="landing-bar landing-bar--strong" style={bar(100, 10, 12)} />
<div
className="landing-bar landing-bar--strong"
style={bar(100, 10, 12)}
/>
<div className="landing-bar" style={bar(80, 8, 8)} />
<div className="landing-bar" style={bar(100, 8, 8)} />
<div className="landing-bar" style={bar(60, 8, 0)} />
@@ -19,12 +22,24 @@ 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)} />
<div
className="landing-bar landing-bar--strong"
style={bar(100, 10, 10)}
/>
<div className="landing-bar" style={bar(80, 7, 6)} />
<div className="landing-bar" style={bar(100, 7, 6)} />
<div className="landing-bar" style={bar(66, 7, 6)} />
@@ -36,7 +51,10 @@ export function LandingDocumentStack() {
<div className="landing-sheet landing-sheet--back landing-sheet--right">
<div className="landing-sheet-side-body">
<div className="landing-bar landing-bar--strong" style={bar(100, 10, 12)} />
<div
className="landing-bar landing-bar--strong"
style={bar(100, 10, 12)}
/>
<div className="landing-bar" style={bar(75, 8, 8)} />
<div className="landing-bar" style={bar(100, 8, 8)} />
<div className="landing-bar" style={bar(80, 8, 0)} />
@@ -128,7 +128,10 @@
color: var(--landing-button-color, var(--text-primary)) !important;
}
.landing-btn-secondary:hover {
background-color: var(--landing-button-hover-bg, var(--landing-button-bg, var(--bg-surface))) !important;
background-color: var(
--landing-button-hover-bg,
var(--landing-button-bg, var(--bg-surface))
) !important;
}
/* Icon-only variant: accent colour instead of button text colour */
@@ -31,7 +31,9 @@ const LandingPage = () => {
}
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileSelect = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
await addFiles(files);
@@ -46,7 +48,13 @@ 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
@@ -59,16 +67,35 @@ const LandingPage = () => {
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 },
"&[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}
@@ -133,7 +133,8 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
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)",
animation:
"ripple-expand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
zIndex: 1,
}}
/>
@@ -185,10 +186,15 @@ 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))
.filter(
([code]) =>
allowedLanguages.length === 0 || allowedLanguages.includes(code),
)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
@@ -197,9 +203,11 @@ 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)
@@ -234,7 +242,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
};
const currentLanguage =
supportedLanguages[i18n.language as keyof typeof supportedLanguages] || supportedLanguages["en-GB"] || "English"; // Fallback if supportedLanguages lookup fails
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)
@@ -270,7 +280,8 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
root: {
color: "var(--right-rail-icon)",
"&:hover": {
backgroundColor: "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
backgroundColor:
"light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
},
},
}}
@@ -281,14 +292,19 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
<Button
variant="subtle"
size="sm"
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
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)",
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))",
backgroundColor:
"light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
},
},
label: { fontSize: "12px", fontWeight: 500 },
@@ -304,11 +320,16 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
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))",
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)` }}>
<div
className={styles.languageGrid}
style={{ gridTemplateColumns: `repeat(${gridColumns}, 1fr)` }}
>
{languageOptions.map((option, index) => (
<LanguageItem
key={option.value}
@@ -10,7 +10,9 @@ try {
addCollection(iconSet);
iconsLoaded = true;
const localIconCount = Object.keys(iconSet.icons || {}).length;
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
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");
@@ -28,9 +30,17 @@ interface LocalIconProps {
* LocalIcon component that uses our locally bundled Material Symbols icons
* instead of loading from CDN
*/
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, width, height, style, ...props }) => {
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") {
@@ -21,7 +21,8 @@ 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") {
const bytes = new Uint8Array(16);
@@ -43,8 +44,12 @@ function generateSessionId(): string {
}
// 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 {
@@ -60,7 +65,11 @@ interface SessionInfo {
* Displays a QR code that mobile devices can scan to upload files via backend server.
* Files are temporarily stored on server and retrieved by desktop.
*/
export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) {
export default function MobileUploadModal({
opened,
onClose,
onFilesReceived,
}: MobileUploadModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
@@ -102,7 +111,9 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
console.log("[MobileUploadModal] Session created:", data);
} catch (err) {
console.error("[MobileUploadModal] Failed to create session:", err);
setError(t("mobileUpload.sessionCreateError", "Failed to create session"));
setError(
t("mobileUpload.sessionCreateError", "Failed to create session"),
);
}
},
[t],
@@ -122,7 +133,9 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
if (!opened) return;
try {
const response = await apiClient.get(`/api/v1/mobile-scanner/files/${sessionId}`);
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");
}
@@ -131,7 +144,9 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
const files = data.files || [];
// Download only files we haven't processed yet
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
const newFiles = files.filter(
(f: any) => !processedFiles.current.has(f.filename),
);
if (newFiles.length > 0) {
for (const fileMetadata of newFiles) {
@@ -150,16 +165,32 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
});
// Convert images to PDF if enabled
if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) {
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
}
}
@@ -169,7 +200,11 @@ 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,
);
}
}
@@ -177,9 +212,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) {
@@ -212,7 +252,9 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
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));
.catch((err) =>
console.warn("[MobileUploadModal] Cleanup failed:", err),
);
};
}, [opened, sessionId, createSession]);
@@ -289,14 +331,21 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
}}
>
<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.",
)
: 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>
@@ -326,7 +375,14 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
</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",
@@ -339,8 +395,15 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }:
</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>
)}
@@ -23,7 +23,14 @@ const MultiSelectControls = ({
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")}
@@ -40,13 +47,23 @@ const MultiSelectControls = ({
)}
{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>
)}
@@ -30,9 +30,15 @@ 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}>
<button
type="button"
onClick={onButtonClick}
className={styles.overlayButton}
>
{buttonText}
</button>
)}
@@ -28,13 +28,27 @@ 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 fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
@@ -52,7 +66,9 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({ file, index, colorIndex, on
<div
style={{
position: "absolute",
...(dropPosition === "above" ? { top: "-2px" } : { bottom: "-2px" }),
...(dropPosition === "above"
? { top: "-2px" }
: { bottom: "-2px" }),
left: 0,
right: 0,
height: "4px",
@@ -75,7 +91,9 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({ file, index, colorIndex, on
style={{
padding: "0.75rem 0.75rem",
cursor: isDragging ? "grabbing" : "grab",
backgroundColor: file.isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent",
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",
@@ -83,16 +101,18 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({ file, index, colorIndex, on
}}
onMouseEnter={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(0, 0, 0, 0.05)";
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
(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.borderLeftColor = fileColorBorder;
(e.currentTarget as HTMLDivElement).style.backgroundColor =
file.isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent";
(e.currentTarget as HTMLDivElement).style.borderLeftColor =
fileColorBorder;
}
}}
>
@@ -155,11 +175,18 @@ 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" />
<LocalIcon
icon="dashboard-customize-rounded"
width="1.4rem"
height="1.4rem"
/>
)}
<span className="ph-no-capture">
{selectedCount}/{totalCount} files selected
@@ -208,15 +235,25 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
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">
<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>
@@ -14,7 +14,11 @@ interface PageSelectionSyntaxHintProps {
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();
@@ -26,20 +30,42 @@ const PageSelectionSyntaxHint = ({ input, maxPages, variant = "panel" }: PageSel
}
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES);
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,
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}>
<div
className={
variant === "panel" ? classes.selectedList : classes.errorCompact
}
>
<Text
size="xs"
className={
variant === "panel" ? classes.errorText : classes.errorTextClamp
}
>
{syntaxError}
</Text>
</div>
@@ -23,7 +23,12 @@ interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
* <img src={thumbnail} alt="preview" />
* </PrivateContent>
*/
export const PrivateContent: React.FC<PrivateContentProps> = ({ children, className = "", style, ...props }) => {
export const PrivateContent: React.FC<PrivateContentProps> = ({
children,
className = "",
style,
...props
}) => {
const combinedClassName = `ph-no-capture${className ? ` ${className}` : ""}`;
const combinedStyle = { display: "contents" as const, ...style };
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,9 @@ 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,12 +37,22 @@ 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}
+124 -29
View File
@@ -3,7 +3,11 @@ 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 {
useFileState,
useFileSelection,
useFileActions,
} from "@app/contexts/FileContext";
import { isStirlingFile } from "@app/types/fileContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { useTranslation } from "react-i18next";
@@ -20,7 +24,11 @@ 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 {
RightRailButtonConfig,
RightRailRenderContext,
RightRailSection,
} from "@app/types/rightRail";
import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide";
import { downloadFile } from "@app/services/downloadService";
@@ -34,10 +42,17 @@ function renderWithTooltip(
) {
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}>
<Tooltip
content={tooltip}
position={position}
offset={offset}
arrow
portalTarget={portalTarget}
>
<div className="right-rail-tooltip-wrapper">{node}</div>
</Tooltip>
);
@@ -45,7 +60,8 @@ function renderWithTooltip(
export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
const { position: tooltipPosition, offset: tooltipOffset } =
useRightRailTooltipSide(sidebarRefs);
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
@@ -53,8 +69,10 @@ export default function RightRail() {
const { toggleTheme, themeMode } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const { pageEditorFunctions, toolPanelMode, leftPanelView } =
useToolWorkflow();
const disableForFullscreen =
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const { workbench: currentView } = useNavigationState();
@@ -63,7 +81,8 @@ export default function RightRail() {
const { actions: fileActions } = useFileActions();
const activeFiles = selectors.getFiles();
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
const pageEditorSelectedCount =
pageEditorFunctions?.selectedPageIds?.length ?? 0;
const totalItems = useMemo(() => {
if (currentView === "pageEditor") return pageEditorTotalPages;
@@ -79,7 +98,9 @@ export default function RightRail() {
const sectionsWithButtons = useMemo(() => {
return SECTION_ORDER.map((section) => {
const sectionButtons = buttons.filter((btn) => (btn.section ?? "top") === section && (btn.visible ?? true));
const sectionButtons = buttons.filter(
(btn) => (btn.section ?? "top") === section && (btn.visible ?? true),
);
return { section, buttons: sectionButtons };
}).filter((entry) => entry.buttons.length > 0);
}, [buttons]);
@@ -87,7 +108,9 @@ export default function RightRail() {
const renderButton = useCallback(
(btn: RightRailButtonConfig) => {
const action = actions[btn.id];
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
const disabled = Boolean(
btn.disabled || allButtonsDisabled || disableForFullscreen,
);
const isActive = Boolean(btn.active);
const triggerAction = () => {
@@ -108,8 +131,12 @@ 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"}
@@ -126,9 +153,20 @@ export default function RightRail() {
</ActionIcon>
);
return renderWithTooltip(buttonNode, btn.tooltip, tooltipPosition, tooltipOffset);
return renderWithTooltip(
buttonNode,
btn.tooltip,
tooltipPosition,
tooltipOffset,
);
},
[actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset],
[
actions,
allButtonsDisabled,
disableForFullscreen,
tooltipPosition,
tooltipOffset,
],
);
const handleExportAll = useCallback(
@@ -136,9 +174,12 @@ export default function RightRail() {
if (currentView === "viewer") {
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
if (!buffer) return;
const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
const fileToExport =
selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
if (!fileToExport) return;
const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined;
const stub = isStirlingFile(fileToExport)
? selectors.getStirlingFileStub(fileToExport.fileId)
: undefined;
try {
const result = await downloadFile({
data: new Blob([buffer], { type: "application/pdf" }),
@@ -162,11 +203,14 @@ export default function RightRail() {
return;
}
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
const filesToExport =
selectedFiles.length > 0 ? selectedFiles : activeFiles;
if (filesToExport.length > 0) {
for (const file of filesToExport) {
const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined;
const stub = isStirlingFile(file)
? selectors.getStirlingFileStub(file.fileId)
: undefined;
try {
const result = await downloadFile({
data: file,
@@ -181,12 +225,24 @@ 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(() => {
@@ -203,7 +259,11 @@ export default function RightRail() {
}, [currentView, selectedCount, t, terminology]);
return (
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
<div
ref={sidebarRefs.rightRailRef}
className="right-rail"
data-sidebar="right-rail"
>
<div className="right-rail-inner">
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
<React.Fragment key={section}>
@@ -212,7 +272,11 @@ 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>
);
@@ -222,11 +286,21 @@ export default function RightRail() {
</React.Fragment>
))}
<div
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "1rem" }}
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}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={toggleTheme}
>
{themeMode === "dark" ? (
<LightModeIcon sx={{ fontSize: "1.5rem" }} />
) : (
@@ -238,7 +312,12 @@ export default function RightRail() {
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
@@ -246,9 +325,17 @@ 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" />
<LocalIcon
icon={icons.downloadIconName}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>,
downloadTooltip,
tooltipPosition,
@@ -261,9 +348,17 @@ 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" />
<LocalIcon
icon={icons.saveAsIconName}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>,
t("rightRail.saveAs", "Save As"),
tooltipPosition,
@@ -1,5 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core";
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";
@@ -22,7 +32,12 @@ 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();
@@ -30,7 +45,9 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
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) {
@@ -53,7 +70,9 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
try {
const parsed = new URL(frontendUrl);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
const normalized = frontendUrl.endsWith("/")
? frontendUrl.slice(0, -1)
: frontendUrl;
return `${normalized}/share/${shareToken}`;
}
} catch {
@@ -65,9 +84,12 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
const createShareLink = useCallback(
async (storedFileId: number) => {
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
accessRole: shareRole,
});
const response = await apiClient.post(
`/api/v1/storage/files/${storedFileId}/shares/links`,
{
accessRole: shareRole,
},
);
return response.data as { token?: string };
},
[shareRole],
@@ -99,7 +121,11 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
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) {
@@ -132,14 +158,21 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
});
if (storedId) {
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true });
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true });
await fileStorage.updateFileMetadata(file.id, {
remoteHasShareLinks: true,
});
}
if (onUploaded) {
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."));
setErrorMessage(
t(
"storageShare.failure",
"Unable to generate a share link. Please try again.",
),
);
} finally {
setIsWorking(false);
}
@@ -192,13 +225,22 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
</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>
)}
@@ -213,7 +255,9 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
<Button
variant="subtle"
size="xs"
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
leftSection={
<ContentCopyRoundedIcon style={{ fontSize: 16 }} />
}
onClick={handleCopyLink}
>
{t("storageShare.copy", "Copy")}
@@ -232,12 +276,26 @@ const ShareFileModal: React.FC<ShareFileModalProps> = ({ opened, onClose, file,
<Select
label={t("storageShare.roleLabel", "Role")}
value={shareRole}
onChange={(value) => setShareRole((value as typeof shareRole) || "editor")}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
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" && (
@@ -53,7 +53,11 @@ 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;
@@ -62,24 +66,48 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [shareLinks, setShareLinks] = useState<ShareLinkResponse[]>([]);
const [activityMap, setActivityMap] = useState<Record<string, ShareLinkAccessResponse[]>>({});
const [sharedUsers, setSharedUsers] = useState<Array<{ username: string; accessRole?: string | null }>>([]);
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 [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);
const [confirmRemoveUser, setConfirmRemoveUser] = useState<string | null>(null);
const [selectedActivityToken, setSelectedActivityToken] = useState<
string | null
>(null);
const [confirmRevokeToken, setConfirmRevokeToken] = useState<string | null>(
null,
);
const [confirmRemoveUser, setConfirmRemoveUser] = useState<string | null>(
null,
);
const normalizedShareUsername = shareUsername.trim();
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 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 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(() => {
@@ -88,7 +116,9 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
try {
const parsed = new URL(frontendUrl);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
const normalized = frontendUrl.endsWith("/")
? frontendUrl.slice(0, -1)
: frontendUrl;
return `${normalized}/share/`;
}
} catch {
@@ -103,9 +133,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
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 ??
@@ -117,7 +150,9 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
setSharedUsers(users);
} catch (error) {
console.error("Failed to load share links:", error);
setErrorMessage(t("storageShare.manageLoadFailed", "Unable to load share links."));
setErrorMessage(
t("storageShare.manageLoadFailed", "Unable to load share links."),
);
} finally {
setIsLoading(false);
}
@@ -141,7 +176,10 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
setSelectedActivityToken(null);
return;
}
if (!selectedActivityToken || !shareLinks.some((link) => link.token === selectedActivityToken)) {
if (
!selectedActivityToken ||
!shareLinks.some((link) => link.token === selectedActivityToken)
) {
setSelectedActivityToken(shareLinks[0].token);
}
}, [opened, selectedActivityToken, shareLinks]);
@@ -151,9 +189,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
setIsLoading(true);
setErrorMessage(null);
try {
const response = await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/links`, {
accessRole: shareRole,
});
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) => [
@@ -165,7 +206,9 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
},
]);
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true });
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true });
await fileStorage.updateFileMetadata(file.id, {
remoteHasShareLinks: true,
});
alert({
alertType: "success",
title: t("storageShare.generated", "Share link generated"),
@@ -175,7 +218,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
}
} catch (error: any) {
console.error("Failed to create share link:", error);
setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again."));
setErrorMessage(
t(
"storageShare.failure",
"Unable to generate a share link. Please try again.",
),
);
} finally {
setIsLoading(false);
}
@@ -210,9 +258,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
setIsLoading(true);
setConfirmRevokeToken(null);
try {
await apiClient.delete(`/api/v1/storage/files/${file.remoteStorageId}/shares/links/${token}`);
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;
const nextHasLinks =
shareLinks.filter((link) => link.token !== token).length > 0;
setShareLinks((prev) => prev.filter((link) => link.token !== token));
setActivityMap((prev) => {
const updated = { ...prev };
@@ -220,8 +271,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
return updated;
});
setSelectedActivityToken((prev) => (prev === token ? null : prev));
actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: nextHasLinks });
await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: nextHasLinks });
actions.updateStirlingFileStub(file.id, {
remoteHasShareLinks: nextHasLinks,
});
await fileStorage.updateFileMetadata(file.id, {
remoteHasShareLinks: nextHasLinks,
});
alert({
alertType: "success",
title: t("storageShare.revoked", "Share link removed"),
@@ -230,7 +285,9 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
});
} catch (error) {
console.error("Failed to revoke share link:", error);
setErrorMessage(t("storageShare.revokeFailed", "Unable to remove the share link."));
setErrorMessage(
t("storageShare.revokeFailed", "Unable to remove the share link."),
);
} finally {
setIsLoading(false);
}
@@ -253,7 +310,9 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
}));
} catch (error) {
console.error("Failed to load share activity:", error);
setErrorMessage(t("storageShare.accessFailed", "Unable to load activity."));
setErrorMessage(
t("storageShare.accessFailed", "Unable to load activity."),
);
} finally {
setIsLoading(false);
}
@@ -283,15 +342,24 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
setIsLoading(true);
setErrorMessage(null);
try {
await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/users`, {
username: trimmed,
accessRole: shareRole,
});
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.map((user) =>
user.username === trimmed
? { ...user, accessRole: shareRole }
: user,
);
}
return [...prev, { username: trimmed, accessRole: shareRole }].sort((a, b) => a.username.localeCompare(b.username));
return [...prev, { username: trimmed, accessRole: shareRole }].sort(
(a, b) => a.username.localeCompare(b.username),
);
});
setShareUsername("");
setShowEmailWarning(false);
@@ -303,12 +371,21 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
});
} catch (error) {
console.error("Failed to share with user:", error);
setErrorMessage(t("storageShare.userAddFailed", "Unable to share with that user."));
setErrorMessage(
t("storageShare.userAddFailed", "Unable to share with that user."),
);
} finally {
setIsLoading(false);
}
},
[file.remoteStorageId, isEmailInput, isValidShareUsername, shareRole, shareUsername, t],
[
file.remoteStorageId,
isEmailInput,
isValidShareUsername,
shareRole,
shareUsername,
t,
],
);
const handleUpdateUserRole = useCallback(
@@ -317,11 +394,20 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
setIsLoading(true);
setErrorMessage(null);
try {
await apiClient.post(`/api/v1/storage/files/${file.remoteStorageId}/shares/users`, {
username,
accessRole: nextRole,
});
setSharedUsers((prev) => prev.map((user) => (user.username === username ? { ...user, accessRole: nextRole } : user)));
await apiClient.post(
`/api/v1/storage/files/${file.remoteStorageId}/shares/users`,
{
username,
accessRole: nextRole,
},
);
setSharedUsers((prev) =>
prev.map((user) =>
user.username === username
? { ...user, accessRole: nextRole }
: user,
),
);
alert({
alertType: "success",
title: t("storageShare.userAdded", "User added to shared list."),
@@ -330,7 +416,9 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
});
} catch (error) {
console.error("Failed to update shared user role:", error);
setErrorMessage(t("storageShare.userAddFailed", "Unable to share with that user."));
setErrorMessage(
t("storageShare.userAddFailed", "Unable to share with that user."),
);
} finally {
setIsLoading(false);
}
@@ -345,17 +433,26 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
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));
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."),
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."));
setErrorMessage(
t("storageShare.userRemoveFailed", "Unable to remove that user."),
);
} finally {
setIsLoading(false);
}
@@ -363,8 +460,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
[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
@@ -379,7 +480,10 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
<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")}:{" "}
@@ -390,13 +494,22 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
</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>
)}
@@ -413,17 +526,34 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
<Select
label={t("storageShare.roleLabel", "Role")}
value={shareRole}
onChange={(value) => setShareRole((value as typeof shareRole) || "editor")}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
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" && (
<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">
@@ -447,7 +577,10 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
<Group align="flex-end" gap="sm">
<TextInput
label={t("storageShare.usernameLabel", "Username or email")}
placeholder={t("storageShare.usernamePlaceholder", "Enter a username or email")}
placeholder={t(
"storageShare.usernamePlaceholder",
"Enter a username or email",
)}
value={shareUsername}
onChange={(event) => {
setShareUsername(event.currentTarget.value);
@@ -464,13 +597,22 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
/>
<Button
onClick={() => handleAddUser()}
disabled={!sharingEnabled || isLoading || !normalizedShareUsername || !!shareUsernameError}
disabled={
!sharingEnabled ||
isLoading ||
!normalizedShareUsername ||
!!shareUsernameError
}
>
{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(
@@ -479,11 +621,21 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setShowEmailWarning(false)} disabled={isLoading}>
<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>
@@ -491,17 +643,27 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
)}
{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">
{sharedUsers.map((user) => (
<Group key={user.username} justify="space-between" align="flex-start">
<Group
key={user.username}
justify="space-between"
align="flex-start"
>
<Stack gap={2}>
<Text size="sm">{user.username}</Text>
{user.accessRole === "commenter" && (
<Text size="xs" c="dimmed">
{t("storageShare.commenterHint", "Commenting is coming soon.")}
{t(
"storageShare.commenterHint",
"Commenting is coming soon.",
)}
</Text>
)}
</Stack>
@@ -509,14 +671,34 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
<Select
value={user.accessRole ?? "editor"}
onChange={(value) => {
const nextRole = (value as "editor" | "commenter" | "viewer") || "editor";
void handleUpdateUserRole(user.username, nextRole);
const nextRole =
(value as "editor" | "commenter" | "viewer") ||
"editor";
void handleUpdateUserRole(
user.username,
nextRole,
);
}}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10,
}}
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"
/>
@@ -533,7 +715,11 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
>
{t("confirm", "Confirm")}
</Button>
<Button variant="default" size="xs" onClick={() => setConfirmRemoveUser(null)}>
<Button
variant="default"
size="xs"
onClick={() => setConfirmRemoveUser(null)}
>
{t("cancel", "Cancel")}
</Button>
</Group>
@@ -542,8 +728,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
variant="light"
size="xs"
color="red"
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={() => setConfirmRemoveUser(user.username)}
leftSection={
<DeleteIcon style={{ fontSize: 16 }} />
}
onClick={() =>
setConfirmRemoveUser(user.username)
}
disabled={isLoading}
>
{t("storageShare.removeUser", "Remove")}
@@ -579,8 +769,13 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
{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 (
@@ -594,7 +789,11 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
<Button
variant="subtle"
size="xs"
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
leftSection={
<ContentCopyRoundedIcon
style={{ fontSize: 16 }}
/>
}
onClick={() => handleCopyLink(link.token)}
>
{t("storageShare.copy", "Copy")}
@@ -609,21 +808,39 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
{link.accessRole === "editor"
? t("storageShare.roleEditor", "Editor")
: link.accessRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
: t("storageShare.roleViewer", "Viewer")}
? 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")}:{" "}
{t(
"storageShare.lastAccessed",
"Last accessed",
)}
:{" "}
{new Date(lastAccessedAt).toLocaleString()}
</Text>
)}
@@ -633,12 +850,24 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
<Button
variant={isSelected ? "filled" : "light"}
size="xs"
leftSection={<HistoryIcon style={{ fontSize: 16 }} />}
onClick={() => setSelectedActivityToken((prev) => (prev === link.token ? null : link.token))}
leftSection={
<HistoryIcon style={{ fontSize: 16 }} />
}
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">
@@ -653,7 +882,11 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
>
{t("confirm", "Confirm")}
</Button>
<Button variant="default" size="xs" onClick={() => setConfirmRevokeToken(null)}>
<Button
variant="default"
size="xs"
onClick={() => setConfirmRevokeToken(null)}
>
{t("cancel", "Cancel")}
</Button>
</Group>
@@ -662,8 +895,12 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
variant="light"
size="xs"
color="red"
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={() => setConfirmRevokeToken(link.token)}
leftSection={
<DeleteIcon style={{ fontSize: 16 }} />
}
onClick={() =>
setConfirmRevokeToken(link.token)
}
disabled={isLoading}
>
{t("storageShare.removeLink", "Remove link")}
@@ -707,13 +944,28 @@ const ShareManagementModal: React.FC<ShareManagementModalProps> = ({ opened, onC
<Stack gap="xs">
{(selectedActivity ?? []).length > 0 ? (
selectedActivity?.map((entry, index) => (
<Paper key={`${selectedActivityToken}-${index}`} radius="md" p="xs" withBorder>
<Paper
key={`${selectedActivityToken}-${index}`}
radius="md"
p="xs"
withBorder
>
<Group justify="space-between">
<Stack gap={2}>
<Text size="xs" c="dimmed">
{entry.accessedAt ? new Date(entry.accessedAt).toLocaleString() : t("unknown", "Unknown")}
{entry.accessedAt
? new Date(
entry.accessedAt,
).toLocaleString()
: t("unknown", "Unknown")}
</Text>
<Text size="sm">
{entry.username ||
t(
"storageShare.unknownUser",
"Unknown user",
)}
</Text>
<Text size="sm">{entry.username || t("storageShare.unknownUser", "Unknown user")}</Text>
</Stack>
<Badge size="sm" variant="light">
{entry.accessType === "VIEW"
@@ -10,7 +10,14 @@ interface SkeletonLoaderProps {
radius?: number | string;
}
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({ type, count = 8, animated = true, width, height, radius = 8 }) => {
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.
@@ -78,9 +85,24 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({ type, count = 8, animat
const renderControlsSkeleton = () => (
<Group mb="md">
<Box w={150} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={120} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={100} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box
w={150}
h={36}
bg="gray.1"
style={{ borderRadius: 4, ...animationStyle }}
/>
<Box
w={120}
h={36}
bg="gray.1"
style={{ borderRadius: 4, ...animationStyle }}
/>
<Box
w={100}
h={36}
bg="gray.1"
style={{ borderRadius: 4, ...animationStyle }}
/>
</Group>
);
@@ -88,10 +110,30 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({ type, count = 8, animat
<Stack gap="md" h="100%">
{/* Toolbar skeleton */}
<Group>
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={80} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box
w={40}
h={40}
bg="gray.1"
style={{ borderRadius: 4, ...animationStyle }}
/>
<Box
w={40}
h={40}
bg="gray.1"
style={{ borderRadius: 4, ...animationStyle }}
/>
<Box
w={80}
h={40}
bg="gray.1"
style={{ borderRadius: 4, ...animationStyle }}
/>
<Box
w={40}
h={40}
bg="gray.1"
style={{ borderRadius: 4, ...animationStyle }}
/>
</Group>
{/* Main content skeleton */}
<Box
@@ -71,12 +71,16 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
}
};
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" }}>
<span
className={styles.icon}
style={{ color: colorScheme === "dark" ? "#FFFFFF" : "#6B7382" }}
>
{icon}
</span>
)}
@@ -72,7 +72,10 @@ const ToolChain: React.FC<ToolChainProps> = ({
// Compact style for very small spaces
if (displayStyle === "compact") {
const compactText = toolIds.length === 1 ? getToolName(toolIds[0]) : `${toolIds.length} tools`;
const compactText =
toolIds.length === 1
? getToolName(toolIds[0])
: `${toolIds.length} tools`;
const isCompactTruncated = toolIds.length > 1;
const compactElement = (
+90 -27
View File
@@ -1,4 +1,10 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from "react";
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";
@@ -73,7 +79,8 @@ 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) {
@@ -91,15 +98,17 @@ 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"]>;
}, [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(
(newOpen: boolean) => {
@@ -128,8 +137,12 @@ export const Tooltip: React.FC<TooltipProps> = ({
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as unknown;
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
const insideTooltip = Boolean(
tEl && isDomNode(target) && tEl.contains(target),
);
const insideTrigger = Boolean(
trg && isDomNode(target) && trg.contains(target),
);
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
@@ -141,7 +154,12 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
// Not pinned and configured to close on outside
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) {
if (
allowAutoClose &&
closeOnOutside &&
!insideTooltip &&
!insideTrigger
) {
setOpen(false);
}
},
@@ -151,7 +169,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
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]);
@@ -171,7 +193,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
const getArrowStyleClass = useCallback(
(key: string) =>
styles[key as keyof typeof styles] ||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
styles[
key.replace(/-([a-z])/g, (_, l) =>
l.toUpperCase(),
) as keyof typeof styles
] ||
"",
[],
);
@@ -180,7 +206,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
const openWithDelay = useCallback(() => {
clearTimers();
if (disabled) return;
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
openTimeoutRef.current = setTimeout(
() => setOpen(true),
Math.max(0, delay || 0),
);
}, [clearTimers, setOpen, delay, disabled]);
const handlePointerEnter = useCallback(
@@ -196,7 +225,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
if (
isDomNode(related) &&
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
@@ -260,7 +293,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
if (
isDomNode(related) &&
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
(children.props as any)?.onBlur?.(e);
return;
}
@@ -287,7 +324,12 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (
isDomNode(related) &&
triggerRef.current &&
triggerRef.current.contains(related)
)
return;
if (allowAutoClose && !isPinned) setOpen(false);
},
[isPinned, setOpen, allowAutoClose],
@@ -299,7 +341,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
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;
else if (originalRef && typeof originalRef === "object")
(originalRef as any).current = node;
},
"aria-describedby": open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
@@ -327,7 +370,12 @@ export const Tooltip: React.FC<TooltipProps> = ({
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",
@@ -364,7 +412,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
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
}
/>
@@ -373,13 +425,21 @@ export const Tooltip: React.FC<TooltipProps> = ({
<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>
</div>
)}
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
<TooltipContent
content={content}
tips={tips}
extraRightPadding={shouldShowCloseButton ? 48 : 0}
/>
</div>
) : null;
@@ -387,9 +447,12 @@ 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;
})()}
</>
);
@@ -56,7 +56,11 @@ const createViewOptions = (
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "viewer" ? <Loader size="sm" /> : <InsertDriveFileIcon fontSize="medium" />}
{switchingTo === "viewer" ? (
<Loader size="sm" />
) : (
<InsertDriveFileIcon fontSize="medium" />
)}
</div>
),
value: "viewer",
@@ -81,7 +85,11 @@ const createViewOptions = (
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "pageEditor" ? <Loader size="sm" /> : <GridViewIcon fontSize="medium" />}
{switchingTo === "pageEditor" ? (
<Loader size="sm" />
) : (
<GridViewIcon fontSize="medium" />
)}
</div>
),
value: "pageEditor",
@@ -90,7 +98,11 @@ const createViewOptions = (
const fileEditorOption = {
label: (
<div style={viewOptionStyle}>
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
{switchingTo === "fileEditor" ? (
<Loader size="sm" />
) : (
<FolderIcon fontSize="medium" />
)}
</div>
),
value: "fileEditor",
@@ -103,7 +115,11 @@ const createViewOptions = (
.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>
),
@@ -175,7 +191,16 @@ const TopControls = ({
pageEditorState,
customViews,
),
[currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, onFileRemove, pageEditorState, customViews],
[
currentView,
switchingTo,
activeFiles,
currentFileIndex,
onFileSelect,
onFileRemove,
pageEditorState,
customViews,
],
);
return (
@@ -1,7 +1,24 @@
import React, { useState, useEffect } from "react";
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from "@mantine/core";
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 {
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";
@@ -18,20 +35,32 @@ 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 [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(
null,
);
const [loading, setLoading] = useState(true);
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(new Set([0]));
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(
new Set([0]),
);
useEffect(() => {
if (opened) {
setLoading(true);
setExpandedVersions(new Set([0]));
updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => {
setFullUpdateInfo(info);
setLoading(false);
});
updateService
.getFullUpdateInfo(currentVersion, machineInfo)
.then((info) => {
setFullUpdateInfo(info);
setLoading(false);
});
}
}, [opened, currentVersion, machineInfo]);
@@ -91,7 +120,12 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
<Stack gap="lg" pt="md">
{/* Version Summary Section */}
<Box>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
<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")}
@@ -157,7 +191,13 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
}}
>
<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")}
@@ -179,10 +219,19 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
}}
>
<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(
@@ -196,48 +245,51 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
)}
{/* Migration guides */}
{updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && (
<>
<Divider />
<Stack gap="xs">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{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)",
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t("update.version", "Version")} {guide.version}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{guide.notes}
</Text>
</Box>
<Button
component="a"
href={guide.url}
target="_blank"
variant="light"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t("update.viewGuide", "View Guide")}
</Button>
</Group>
</Box>
))}
</Stack>
</>
)}
{updateSummary.migration_guides &&
updateSummary.migration_guides.length > 0 && (
<>
<Divider />
<Stack gap="xs">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{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)",
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t("update.version", "Version")} {guide.version}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{guide.notes}
</Text>
</Box>
<Button
component="a"
href={guide.url}
target="_blank"
variant="light"
size="xs"
rightSection={
<OpenInNewIcon style={{ fontSize: 14 }} />
}
>
{t("update.viewGuide", "View Guide")}
</Button>
</Group>
</Box>
))}
</Stack>
</>
)}
{/* Version details */}
<Divider />
@@ -246,18 +298,26 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
<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>
) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? (
) : fullUpdateInfo &&
fullUpdateInfo.new_versions &&
fullUpdateInfo.new_versions.length > 0 ? (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{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">
@@ -278,7 +338,9 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
p="md"
style={{
cursor: "pointer",
background: isExpanded ? "var(--mantine-color-gray-0)" : "transparent",
background: isExpanded
? "var(--mantine-color-gray-0)"
: "transparent",
transition: "background 0.15s ease",
}}
onClick={() => toggleVersion(index)}
@@ -292,7 +354,10 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
{version.version}
</Text>
</Box>
<Badge color={getPriorityColor(version.priority)} size="md">
<Badge
color={getPriorityColor(version.priority)}
size="md"
>
{getPriorityLabel(version.priority)}
</Badge>
</Group>
@@ -304,26 +369,48 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
variant="light"
size="xs"
onClick={(e) => e.stopPropagation()}
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
rightSection={
<OpenInNewIcon style={{ fontSize: 14 }} />
}
>
{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}>
{version.announcement.title}
</Text>
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
<Text
size="sm"
c="dimmed"
style={{ lineHeight: 1.6 }}
>
{version.announcement.message}
</Text>
</Box>
@@ -334,32 +421,55 @@ const UpdateModal: React.FC<UpdateModalProps> = ({ opened, onClose, currentVersi
background: "var(--mantine-color-orange-light)",
padding: "12px",
borderRadius: "6px",
border: "1px solid var(--mantine-color-orange-outline)",
border:
"1px solid var(--mantine-color-orange-outline)",
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
<Group
gap="xs"
align="flex-start"
wrap="nowrap"
mb="xs"
>
<WarningAmberIcon
style={{ fontSize: 16, color: "var(--mantine-color-orange-filled)", marginTop: 2 }}
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
component="a"
href={version.compatibility.migration_guide_url}
href={
version.compatibility.migration_guide_url
}
target="_blank"
variant="light"
color="orange"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
rightSection={
<OpenInNewIcon style={{ fontSize: 14 }} />
}
>
{t("update.migrationGuide", "Migration Guide")}
{t(
"update.migrationGuide",
"Migration Guide",
)}
</Button>
)}
</Box>
@@ -18,7 +18,12 @@ 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);
@@ -38,7 +43,11 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({ opened, onClo
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, {
@@ -65,7 +74,12 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({ opened, onClo
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."));
setErrorMessage(
t(
"storageUpload.failure",
"Upload failed. Please check your login and storage settings.",
),
);
} finally {
setIsUploading(false);
}
@@ -81,17 +95,26 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({ opened, onClo
>
<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}
</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>
)}
@@ -100,7 +123,11 @@ const UploadToServerModal: React.FC<UploadToServerModalProps> = ({ opened, onClo
<Button variant="default" onClick={onClose} disabled={isUploading}>
{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")}
@@ -19,7 +19,13 @@ interface UserSelectorProps {
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();
@@ -43,13 +49,18 @@ const UserSelector = ({ value, onChange, placeholder, size = "sm", disabled = fa
.filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user
.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 label =
displayName !== username
? `${displayName} (@${username})`
: displayName;
usersByTeam[teamName].push({
value: String(user.userId),
label,
@@ -57,10 +68,12 @@ const UserSelector = ({ value, onChange, placeholder, size = "sm", disabled = fa
});
// Convert to Mantine's grouped format
const processed: GroupedData[] = Object.entries(usersByTeam).map(([teamName, items]) => ({
group: teamName,
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}));
const processed: GroupedData[] = Object.entries(usersByTeam).map(
([teamName, items]) => ({
group: teamName,
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}),
);
console.log("Processed selectData:", processed);
setSelectData(processed);
@@ -69,7 +82,10 @@ const UserSelector = ({ value, onChange, placeholder, size = "sm", disabled = fa
alert({
alertType: "error",
title: t("common.error"),
body: t("certSign.collab.userSelector.loadError", "Failed to load users"),
body: t(
"certSign.collab.userSelector.loadError",
"Failed to load users",
),
});
} finally {
setLoading(false);
@@ -82,7 +98,9 @@ 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);
const result = safeValue
.map((id) => (id != null ? id.toString() : ""))
.filter(Boolean);
console.log("stringValue for MultiSelect:", result);
setStringValue(result);
}, [value]);
@@ -98,7 +116,11 @@ const UserSelector = ({ value, onChange, placeholder, size = "sm", disabled = fa
<Text size="sm" c="dimmed">
{t("certSign.collab.userSelector.noUsers", "No other users found.")}
</Text>
<Button size="xs" variant="light" onClick={() => navigate("/settings/people")}>
<Button
size="xs"
variant="light"
onClick={() => navigate("/settings/people")}
>
{t("certSign.collab.userSelector.inviteUsers", "Add Users")}
</Button>
</Stack>
@@ -110,16 +132,24 @@ 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}
disabled={disabled}
maxDropdownHeight={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10,
}}
/>
);
};
@@ -20,7 +20,13 @@ const WARNING_ICON_STYLE: CSSProperties = {
color: "var(--mantine-color-blue-6)",
};
const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => {
const ZipWarningModal = ({
opened,
onConfirm,
onCancel,
fileCount,
zipFileName,
}: ZipWarningModalProps) => {
const { t } = useTranslation();
return (
@@ -10,7 +10,9 @@ interface LoginRequiredBannerProps {
* Banner component that displays when login mode is required but not enabled
* Shows prominent warning that settings are read-only
*/
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
export default function LoginRequiredBanner({
show,
}: LoginRequiredBannerProps) {
const { t } = useTranslation();
if (!show) return null;
@@ -10,7 +10,10 @@ export function OverviewHeader() {
{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>
);
@@ -10,7 +10,11 @@ interface RestartConfirmationModalProps {
onRestart: () => void;
}
export default function RestartConfirmationModal({ opened, onClose, onRestart }: RestartConfirmationModalProps) {
export default function RestartConfirmationModal({
opened,
onClose,
onRestart,
}: RestartConfirmationModalProps) {
const { t } = useTranslation();
return (
@@ -36,14 +40,25 @@ export default function RestartConfirmationModal({ opened, onClose, onRestart }:
</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}>
<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}>
<Button
color="blue"
leftSection={<RefreshIcon style={{ fontSize: 16 }} />}
onClick={onRestart}
>
{t("admin.settings.restart.now", "Restart Now")}
</Button>
</Group>
@@ -4,7 +4,10 @@ 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 type {
ConfigNavSection,
ConfigNavItem,
} from "@app/components/shared/config/configNavSections";
interface SettingsSearchBarProps {
configNavSections: ConfigNavSection[];
@@ -21,37 +24,42 @@ interface SettingsSearchOption {
matchedContext?: string;
}
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"],
adminConnections: [
"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"],
};
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"],
adminConnections: [
"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"],
};
const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
@@ -60,7 +68,8 @@ const getTranslationPrefixesForNavKey = (key: string): string[] => {
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix = adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
const normalizedAdminSuffix =
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
@@ -80,7 +89,9 @@ const flattenTranslationStrings = (value: unknown): string[] => {
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(flattenTranslationStrings);
return Object.values(value as Record<string, unknown>).flatMap(
flattenTranslationStrings,
);
}
return [];
@@ -108,7 +119,11 @@ const buildMatchSnippet = (text: string, query: string): string => {
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("");
@@ -121,11 +136,18 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({ configNavS
.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 {
@@ -147,20 +169,25 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({ configNavS
const normalizedQuery = query.toLocaleLowerCase();
return searchableSections.reduce<SettingsSearchOption[]>((accumulator, option) => {
const matchedEntry = option.searchableContent.find((entry) => entry.toLocaleLowerCase().includes(normalizedQuery));
return searchableSections.reduce<SettingsSearchOption[]>(
(accumulator, option) => {
const matchedEntry = option.searchableContent.find((entry) =>
entry.toLocaleLowerCase().includes(normalizedQuery),
);
if (!matchedEntry) {
return accumulator;
}
accumulator.push({
...option,
matchedContext: buildMatchSnippet(matchedEntry, query),
});
if (!matchedEntry) {
return accumulator;
}
accumulator.push({
...option,
matchedContext: buildMatchSnippet(matchedEntry, query),
});
return accumulator;
}, []);
},
[],
);
}, [searchValue, searchableSections]);
const handleSearchNavigation = useCallback(
@@ -197,7 +224,8 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({ configNavS
{searchOption.label}
</Text>
<Text size="xs" c="dimmed">
{searchOption.sectionTitle} · {searchOption.matchedContext || searchOption.destinationPath}
{searchOption.sectionTitle} ·{" "}
{searchOption.matchedContext || searchOption.destinationPath}
</Text>
</div>
);
@@ -9,7 +9,13 @@ 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) {
@@ -66,7 +66,9 @@ export const createConfigNavSections = (
_runningEE: 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",
@@ -20,7 +20,10 @@ import { useTranslation } from "react-i18next";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { ToolPanelMode } from "@app/constants/toolPanel";
import type { StartupView, ViewerZoomSetting } from "@app/services/preferencesService";
import type {
StartupView,
ViewerZoomSetting,
} from "@app/services/preferencesService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import LocalIcon from "@app/components/shared/LocalIcon";
import { updateService, UpdateSummary } from "@app/services/updateService";
@@ -44,15 +47,21 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
preferences.autoUnzipFileLimit,
);
const [bannerDismissed, setBannerDismissed] = useState(() => {
// Check localStorage on mount
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
});
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(null);
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(
null,
);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const { appVersion, mismatchVersion } = useFrontendVersionInfo(config?.appVersion);
const { appVersion, mismatchVersion } = useFrontendVersionInfo(
config?.appVersion,
);
const frontendVersionLabel = appVersion ?? t("common.loading", "Loading..."); // null = loading, shown only when appVersion !== undefined
// Sync local state with preference changes
@@ -79,9 +88,16 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
licenseType: config.license ?? "NORMAL",
};
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
const summary = await updateService.getUpdateSummary(
config.appVersion,
machineInfo,
);
if (summary && summary.latest_version) {
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
const isNewerVersion =
updateService.compareVersions(
summary.latest_version,
config.appVersion,
) > 0;
if (isNewerVersion) {
setUpdateSummary(summary);
} else {
@@ -111,13 +127,24 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
{t("settings.general.title", "General")}
</Text>
<Text size="sm" c="dimmed">
{t("settings.general.description", "Configure general application preferences.")}
{t(
"settings.general.description",
"Configure general application preferences.",
)}
</Text>
</div>
)}
{!hideAdminBanner && loginDisabled && !bannerDismissed && (
<Paper withBorder p="md" radius="md" style={{ background: "var(--mantine-color-blue-0)", position: "relative" }}>
<Paper
withBorder
p="md"
radius="md"
style={{
background: "var(--mantine-color-blue-0)",
position: "relative",
}}
>
<ActionIcon
variant="subtle"
color="gray"
@@ -136,8 +163,15 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
height="1.2rem"
style={{ color: "var(--mantine-color-blue-6)" }}
/>
<Text fw={600} size="sm" style={{ color: "var(--mantine-color-blue-9)" }}>
{t("settings.general.enableFeatures.title", "For System Administrators")}
<Text
fw={600}
size="sm"
style={{ color: "var(--mantine-color-blue-9)" }}
>
{t(
"settings.general.enableFeatures.title",
"For System Administrators",
)}
</Text>
</Group>
<Text size="sm" c="dimmed">
@@ -168,7 +202,11 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
size="sm"
style={{ color: "var(--mantine-color-blue-6)" }}
>
{t("settings.general.enableFeatures.learnMore", "Learn more in documentation")}
{t(
"settings.general.enableFeatures.learnMore",
"Learn more in documentation",
)}{" "}
</Anchor>
</Stack>
</Paper>
@@ -185,11 +223,19 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
{t("settings.general.updates.title", "Software Updates")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("settings.general.updates.description", "Check for updates and view version information")}
{t(
"settings.general.updates.description",
"Check for updates and view version information",
)}
</Text>
</div>
{updateSummary && (
<Badge color={updateSummary.max_priority === "urgent" ? "red" : "blue"} variant="filled">
<Badge
color={
updateSummary.max_priority === "urgent" ? "red" : "blue"
}
variant="filled"
>
{updateSummary.max_priority === "urgent"
? t("update.urgentUpdateAvailable", "Urgent Update")
: t("update.updateAvailable", "Update Available")}
@@ -201,7 +247,11 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Group justify="space-between" align="center">
<div>
<Text size="sm" c="dimmed">
{t("settings.general.updates.currentFrontendVersion", "Current Frontend Version")}:{" "}
{t(
"settings.general.updates.currentFrontendVersion",
"Current Frontend Version",
)}
:{" "}
<Text component="span" fw={500}>
{frontendVersionLabel}
</Text>
@@ -220,14 +270,22 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Group justify="space-between" align="center">
<div>
<Text size="sm" c="dimmed">
{t("settings.general.updates.currentBackendVersion", "Current Backend Version")}:{" "}
{t(
"settings.general.updates.currentBackendVersion",
"Current Backend Version",
)}
:{" "}
<Text component="span" fw={500}>
{config.appVersion}
</Text>
</Text>
{updateSummary && (
<Text size="sm" c="dimmed" mt={4}>
{t("settings.general.updates.latestVersion", "Latest Version")}:{" "}
{t(
"settings.general.updates.latestVersion",
"Latest Version",
)}
:{" "}
<Text component="span" fw={500} c="blue">
{updateSummary.latest_version}
</Text>
@@ -240,16 +298,33 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
variant="default"
onClick={checkForUpdate}
loading={checkingUpdate}
leftSection={<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="refresh-rounded"
width="1rem"
height="1rem"
/>
}
>
{t("settings.general.updates.checkForUpdates", "Check for Updates")}
{t(
"settings.general.updates.checkForUpdates",
"Check for Updates",
)}
</Button>
{updateSummary && (
<Button
size="sm"
color={updateSummary.max_priority === "urgent" ? "red" : "blue"}
color={
updateSummary.max_priority === "urgent" ? "red" : "blue"
}
onClick={() => setUpdateModalOpened(true)}
leftSection={<LocalIcon icon="system-update-alt-rounded" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="system-update-alt-rounded"
width="1rem"
height="1rem"
/>
}
>
{t("settings.general.updates.viewDetails", "View Details")}
</Button>
@@ -260,7 +335,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
{updateSummary?.any_breaking && (
<Alert
color="orange"
title={t("update.breakingChangesDetected", "Breaking Changes Detected")}
title={t(
"update.breakingChangesDetected",
"Breaking Changes Detected",
)}
styles={{
title: { fontWeight: 600 },
}}
@@ -279,10 +357,19 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.defaultToolPickerMode", "Default tool picker mode")}
{t(
"settings.general.defaultToolPickerMode",
"Default tool picker mode",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -293,17 +380,34 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference("defaultToolPanelMode", val as ToolPanelMode)}
onChange={(val: string) =>
updatePreference("defaultToolPanelMode", val as ToolPanelMode)
}
data={[
{ label: t("settings.general.mode.sidebar", "Sidebar"), value: "sidebar" },
{ label: t("settings.general.mode.fullscreen", "Fullscreen"), value: "fullscreen" },
{
label: t("settings.general.mode.sidebar", "Sidebar"),
value: "sidebar",
},
{
label: t("settings.general.mode.fullscreen", "Fullscreen"),
value: "fullscreen",
},
]}
/>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.defaultStartupView", "Default view on launch")}
{t(
"settings.general.defaultStartupView",
"Default view on launch",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -314,15 +418,32 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</div>
<SegmentedControl
value={preferences.defaultStartupView}
onChange={(val: string) => updatePreference("defaultStartupView", val as StartupView)}
onChange={(val: string) =>
updatePreference("defaultStartupView", val as StartupView)
}
data={[
{ label: t("settings.general.startupView.tools", "Tools"), value: "tools" },
{ label: t("settings.general.startupView.read", "Reader"), value: "read" },
{ label: t("settings.general.startupView.automate", "Automate"), value: "automate" },
{
label: t("settings.general.startupView.tools", "Tools"),
value: "tools",
},
{
label: t("settings.general.startupView.read", "Reader"),
value: "read",
},
{
label: t("settings.general.startupView.automate", "Automate"),
value: "automate",
},
]}
/>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.defaultViewerZoom", "Default reader zoom")}
@@ -337,12 +458,25 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Select
value={preferences.defaultViewerZoom}
onChange={(val: string | null) => {
if (val) updatePreference("defaultViewerZoom", val as ViewerZoomSetting);
if (val)
updatePreference(
"defaultViewerZoom",
val as ViewerZoomSetting,
);
}}
data={[
{ label: t("settings.general.zoomLevel.auto", "Auto"), value: "auto" },
{ label: t("settings.general.zoomLevel.fitWidth", "Fit width"), value: "fitWidth" },
{ label: t("settings.general.zoomLevel.fitPage", "Fit page"), value: "fitPage" },
{
label: t("settings.general.zoomLevel.auto", "Auto"),
value: "auto",
},
{
label: t("settings.general.zoomLevel.fitWidth", "Fit width"),
value: "fitWidth",
},
{
label: t("settings.general.zoomLevel.fitPage", "Fit page"),
value: "fitPage",
},
{ label: "50%", value: "50" },
{ label: "75%", value: "75" },
{ label: "100%", value: "100" },
@@ -352,13 +486,25 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
]}
style={{ width: 140 }}
allowDeselect={false}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.hideUnavailableTools", "Hide unavailable tools")}
{t(
"settings.general.hideUnavailableTools",
"Hide unavailable tools",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -369,13 +515,27 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</div>
<Switch
checked={preferences.hideUnavailableTools}
onChange={(event) => updatePreference("hideUnavailableTools", event.currentTarget.checked)}
onChange={(event) =>
updatePreference(
"hideUnavailableTools",
event.currentTarget.checked,
)
}
/>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.hideUnavailableConversions", "Hide unavailable conversions")}
{t(
"settings.general.hideUnavailableConversions",
"Hide unavailable conversions",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -386,7 +546,12 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</div>
<Switch
checked={preferences.hideUnavailableConversions}
onChange={(event) => updatePreference("hideUnavailableConversions", event.currentTarget.checked)}
onChange={(event) =>
updatePreference(
"hideUnavailableConversions",
event.currentTarget.checked,
)
}
/>
</div>
<Tooltip
@@ -398,18 +563,30 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
w={300}
withArrow
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", cursor: "help" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "help",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.autoUnzip", "Auto-unzip API responses")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("settings.general.autoUnzipDescription", "Automatically extract files from ZIP responses")}
{t(
"settings.general.autoUnzipDescription",
"Automatically extract files from ZIP responses",
)}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference("autoUnzip", event.currentTarget.checked)}
onChange={(event) =>
updatePreference("autoUnzip", event.currentTarget.checked)
}
/>
</div>
</Tooltip>
@@ -423,13 +600,26 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
w={300}
withArrow
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", cursor: "help" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "help",
}}
>
<div>
<Text fw={500} size="sm">
{t("settings.general.autoUnzipFileLimit", "Auto-unzip file limit")}
{t(
"settings.general.autoUnzipFileLimit",
"Auto-unzip file limit",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("settings.general.autoUnzipFileLimitDescription", "Maximum number of files to extract from ZIP")}
{t(
"settings.general.autoUnzipFileLimitDescription",
"Maximum number of files to extract from ZIP",
)}
</Text>
</div>
<NumberInput
@@ -438,7 +628,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue =
!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100
!fileLimitInput ||
isNaN(numValue) ||
numValue < 1 ||
numValue > 100
? DEFAULT_AUTO_UNZIP_FILE_LIMIT
: numValue;
setFileLimitInput(finalValue);
@@ -1,11 +1,26 @@
import React, { useEffect, useMemo, useState } from "react";
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
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 {
bindingEquals,
eventToBinding,
HotkeyBinding,
} from "@app/utils/hotkeys";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
const rowStyle: React.CSSProperties = {
@@ -25,12 +40,24 @@ const rowHeaderStyle: React.CSSProperties = {
const HotkeysSection: React.FC = () => {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const {
hotkeys,
defaults,
updateHotkey,
resetHotkey,
pauseHotkeys,
resumeHotkeys,
getDisplayParts,
isMac,
} = useHotkeys();
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
const tools = useMemo(
() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][],
[toolRegistry],
);
const filteredTools = useMemo(() => {
if (!searchQuery.trim()) return tools;
@@ -78,14 +105,26 @@ const HotkeysSection: React.FC = () => {
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;
}
@@ -140,13 +179,22 @@ const HotkeysSection: React.FC = () => {
const isEditing = editingTool === toolId;
const defaultParts = getDisplayParts(defaultBinding);
const defaultLabel =
defaultParts.length > 0 ? defaultParts.join(" + ") : t("settings.hotkeys.none", "Not assigned");
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 }}>
<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" />
@@ -156,7 +204,11 @@ const HotkeysSection: React.FC = () => {
</Badge>
)}
<Text size="xs" c="dimmed">
{t("settings.hotkeys.defaultLabel", "Default: {{shortcut}}", { shortcut: defaultLabel })}
{t(
"settings.hotkeys.defaultLabel",
"Default: {{shortcut}}",
{ shortcut: defaultLabel },
)}
</Text>
</Group>
</div>
@@ -169,13 +221,19 @@ const HotkeysSection: React.FC = () => {
onClick={() => handleStartCapture(toolId)}
>
{isEditing
? t("settings.hotkeys.capturing", "Press keys… (Esc to cancel)")
? 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)}
disabled={bindingEquals(
currentBinding,
defaultBinding,
)}
onClick={() => resetHotkey(toolId)}
>
{t("settings.hotkeys.reset", "Reset")}
@@ -91,13 +91,31 @@ 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>
)}
@@ -16,7 +16,10 @@ import {
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";
import {
Provider,
ProviderField,
} from "@app/components/shared/config/configSections/providerDefinitions";
interface ProviderCardProps {
provider: Provider;
@@ -41,7 +44,8 @@ export default function ProviderCard({
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
const [localSettings, setLocalSettings] =
useState<Record<string, any>>(settings);
// Keep local settings in sync with incoming settings (values loaded from settings.yml)
// Update whenever parent settings change, whether expanded or not (important for Discard to work)
@@ -57,7 +61,8 @@ export default function ProviderCard({
const defaultSettings: Record<string, any> = { ...settings };
provider.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultSettings[field.key] = defaultSettings[field.key] ?? field.defaultValue;
defaultSettings[field.key] =
defaultSettings[field.key] ?? field.defaultValue;
}
});
setLocalSettings(defaultSettings);
@@ -88,7 +93,14 @@ export default function ProviderCard({
switch (field.type) {
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}
@@ -146,7 +158,9 @@ export default function ProviderCard({
);
case "tags": {
const tagValue = Array.isArray(value) ? value.map((val) => `${val}`) : [];
const tagValue = Array.isArray(value)
? value.map((val) => `${val}`)
: [];
return (
<TagsInput
@@ -179,7 +193,13 @@ 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" }} />;
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" />;
@@ -206,12 +226,20 @@ export default function ProviderCard({
<Button
variant={isConfigured ? "subtle" : "filled"}
size="xs"
onClick={isConfigured ? () => setExpanded(!expanded) : handleConnectToggle}
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" />
<LocalIcon
icon="expand-more-rounded"
width="1rem"
height="1rem"
/>
) : undefined
}
>
@@ -231,8 +259,17 @@ 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>
)}
@@ -241,7 +278,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}>
<Button
variant="outline"
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t("admin.settings.connections.disconnect", "Disconnect")}
</Button>
)}
@@ -31,13 +31,17 @@ const useGoogleProvider = (): Provider => {
icon: "/Login/google.svg",
type: "oauth2",
scope: t("provider.oauth2.google.scope", "Sign-in authentication"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration",
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"),
description: t(
"provider.oauth2.google.clientId.description",
"The OAuth2 client ID from Google Cloud Console",
),
placeholder: "your-client-id.apps.googleusercontent.com",
},
{
@@ -53,13 +57,19 @@ const useGoogleProvider = (): Provider => {
key: "scopes",
type: "text",
label: t("provider.oauth2.google.scopes.label", "Scopes"),
description: t("provider.oauth2.google.scopes.description", "Comma-separated OAuth2 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"),
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)",
@@ -79,13 +89,17 @@ const useGitHubProvider = (): Provider => {
icon: "/Login/github.svg",
type: "oauth2",
scope: t("provider.oauth2.github.scope", "Sign-in authentication"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration",
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"),
description: t(
"provider.oauth2.github.clientId.description",
"The OAuth2 client ID from GitHub Developer Settings",
),
},
{
key: "clientSecret",
@@ -100,14 +114,23 @@ const useGitHubProvider = (): Provider => {
key: "scopes",
type: "text",
label: t("provider.oauth2.github.scopes.label", "Scopes"),
description: t("provider.oauth2.github.scopes.description", "Comma-separated OAuth2 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)"),
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",
},
],
@@ -124,7 +147,8 @@ const useKeycloakProvider = (): Provider => {
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",
@@ -140,25 +164,40 @@ const useKeycloakProvider = (): Provider => {
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"),
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"),
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"),
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"),
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)",
@@ -179,13 +218,20 @@ const useGenericOAuth2Provider = (): Provider => {
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"),
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,
},
{
@@ -212,32 +258,50 @@ const useGenericOAuth2Provider = (): 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"),
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"),
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"),
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"),
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"),
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",
@@ -247,8 +311,14 @@ const useGenericOAuth2Provider = (): Provider => {
{
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"),
label: t(
"provider.oauth2.generic.blockRegistration.label",
"Block Registration",
),
description: t(
"provider.oauth2.generic.blockRegistration.description",
"Prevent new user registration via OAuth2",
),
defaultValue: false,
},
],
@@ -264,27 +334,37 @@ const useSMTPProvider = (): Provider => {
icon: "mail-rounded",
type: "oauth2",
scope: t("provider.smtp.scope", "Email Notifications"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/System%20and%20Security/#email-configuration",
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"),
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"),
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)"),
description: t(
"provider.smtp.port.description",
"The port number for SMTP connection (typically 25, 465, or 587)",
),
placeholder: "587",
defaultValue: "587",
},
@@ -292,19 +372,28 @@ const useSMTPProvider = (): Provider => {
key: "username",
type: "text",
label: t("provider.smtp.username.label", "SMTP Username"),
description: t("provider.smtp.username.description", "Username for SMTP authentication"),
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"),
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"),
description: t(
"provider.smtp.from.description",
"The email address to use as the sender",
),
placeholder: "[email protected]",
},
],
@@ -326,7 +415,10 @@ const useTelegramProvider = (): Provider => {
{
key: "enabled",
type: "switch",
label: t("admin.settings.telegram.enabled.label", "Enable Telegram Bot"),
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.",
@@ -337,7 +429,10 @@ const useTelegramProvider = (): Provider => {
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."),
description: t(
"admin.settings.telegram.botUsername.description",
"The public username of your Telegram bot.",
),
placeholder: "my_pdf_bot",
},
{
@@ -353,7 +448,10 @@ const useTelegramProvider = (): Provider => {
{
key: "pipelineInboxFolder",
type: "text",
label: t("admin.settings.telegram.pipelineInboxFolder.label", "Inbox Folder"),
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.",
@@ -363,7 +461,10 @@ const useTelegramProvider = (): Provider => {
{
key: "customFolderSuffix",
type: "switch",
label: t("admin.settings.telegram.customFolderSuffix.label", "Use Custom Folder Suffix"),
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.",
@@ -373,7 +474,10 @@ const useTelegramProvider = (): Provider => {
{
key: "enableAllowUserIDs",
type: "switch",
label: t("admin.settings.telegram.enableAllowUserIDs.label", "Allow Specific User IDs"),
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.",
@@ -383,18 +487,27 @@ const useTelegramProvider = (): Provider => {
{
key: "allowUserIDs",
type: "tags",
label: t("admin.settings.telegram.allowUserIDs.label", "Allowed User IDs"),
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.",
),
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"),
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.",
@@ -404,18 +517,27 @@ const useTelegramProvider = (): Provider => {
{
key: "allowChannelIDs",
type: "tags",
label: t("admin.settings.telegram.allowChannelIDs.label", "Allowed Channel IDs"),
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.",
),
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)"),
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.",
@@ -425,7 +547,10 @@ const useTelegramProvider = (): Provider => {
{
key: "pollingIntervalMillis",
type: "number",
label: t("admin.settings.telegram.pollingIntervalMillis.label", "Polling Interval (ms)"),
label: t(
"admin.settings.telegram.pollingIntervalMillis.label",
"Polling Interval (ms)",
),
description: t(
"admin.settings.telegram.pollingIntervalMillis.description",
"Interval between checks for new Telegram updates.",
@@ -435,7 +560,10 @@ const useTelegramProvider = (): Provider => {
{
key: "feedback.general.enabled",
type: "switch",
label: t("admin.settings.telegram.feedback.general.enabled.label", "Enable Feedback"),
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.",
@@ -445,7 +573,10 @@ const useTelegramProvider = (): Provider => {
{
key: "feedback.channel.noValidDocument",
type: "switch",
label: t("admin.settings.telegram.feedback.channel.noValidDocument.label", 'Show "No valid document" (Channel)'),
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.",
@@ -455,7 +586,10 @@ const useTelegramProvider = (): Provider => {
{
key: "feedback.channel.errorProcessing",
type: "switch",
label: t("admin.settings.telegram.feedback.channel.errorProcessing.label", "Show processing errors (Channel)"),
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.",
@@ -465,7 +599,10 @@ const useTelegramProvider = (): Provider => {
{
key: "feedback.channel.errorMessage",
type: "switch",
label: t("admin.settings.telegram.feedback.channel.errorMessage.label", "Show error messages (Channel)"),
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.",
@@ -475,7 +612,10 @@ const useTelegramProvider = (): Provider => {
{
key: "feedback.user.noValidDocument",
type: "switch",
label: t("admin.settings.telegram.feedback.user.noValidDocument.label", 'Show "No valid document" (User)'),
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.",
@@ -485,7 +625,10 @@ const useTelegramProvider = (): Provider => {
{
key: "feedback.user.errorProcessing",
type: "switch",
label: t("admin.settings.telegram.feedback.user.errorProcessing.label", "Show processing errors (User)"),
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.",
@@ -495,7 +638,10 @@ const useTelegramProvider = (): Provider => {
{
key: "feedback.user.errorMessage",
type: "switch",
label: t("admin.settings.telegram.feedback.user.errorMessage.label", "Show error messages (User)"),
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.",
@@ -516,88 +662,137 @@ const useSAML2Provider = (): Provider => {
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)"),
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"),
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"),
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",
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",
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",
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"),
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)"),
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"),
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"),
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"),
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"),
label: t(
"provider.saml2.blockRegistration.label",
"Block Registration",
),
description: t(
"provider.saml2.blockRegistration.description",
"Prevent new user registration via SAML2",
),
defaultValue: false,
},
],
@@ -613,20 +808,30 @@ const useGoogleDriveProvider = (): Provider => {
icon: "/images/google-drive.svg",
type: "googledrive",
scope: t("provider.googledrive.scope", "File Import"),
documentationUrl: "https://docs.stirlingpdf.com/Configuration/Google%20Drive%20File%20Picker/",
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"),
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"),
description: t(
"provider.googledrive.clientId.description",
"Google OAuth 2.0 Client ID from Google Cloud Console",
),
placeholder: "xxx.apps.googleusercontent.com",
},
{
@@ -643,7 +848,10 @@ const useGoogleDriveProvider = (): Provider => {
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"),
description: t(
"provider.googledrive.appId.description",
"Google Drive App ID from Google Cloud Console",
),
placeholder: "xxxxxxxxxxxxx",
},
],
@@ -26,7 +26,10 @@ export function useRestartServer() {
alert({
alertType: "neutral",
title: t("admin.settings.restarting", "Restarting Server"),
body: t("admin.settings.restartingMessage", "The server is restarting. Please wait a moment..."),
body: t(
"admin.settings.restartingMessage",
"The server is restarting. Please wait a moment...",
),
});
// Wait a moment then reload the page
setTimeout(() => {
@@ -37,7 +40,10 @@ export function useRestartServer() {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.restartError", "Failed to restart server. Please restart manually."),
body: t(
"admin.settings.restartError",
"Failed to restart server. Please restart manually.",
),
});
});
};
@@ -6,7 +6,10 @@ export interface DocumentStackProps {
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%",
@@ -12,7 +12,13 @@ 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 = {
@@ -51,7 +57,12 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({ file, thumbnail,
return (
<Box style={containerStyle} onClick={onClick}>
<Center
style={{ width: "100%", height: "100%", backgroundColor: "var(--mantine-color-gray-1)", borderRadius: "0.25rem" }}
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: "0.25rem",
}}
>
<PrivateContent>{getFileTypeIcon(file)}</PrivateContent>
</Center>
@@ -8,14 +8,22 @@ 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;
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;
const overlay = e.currentTarget.querySelector(
".hover-overlay",
) as HTMLElement;
if (overlay) overlay.style.opacity = "0";
};
@@ -10,7 +10,12 @@ 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%",
@@ -36,7 +41,15 @@ const NavigationArrows: React.FC<NavigationArrowsProps> = ({ onPrevious, onNext,
</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>
@@ -12,21 +12,36 @@ type FileLike = File | StirlingFileStub;
* - Uses the real file type and extension to decide the icon.
* - No any-casts; accepts File or StirlingFileStub.
*/
export function getFileTypeIcon(file: FileLike, size: number | string = "2rem"): React.ReactElement {
export function getFileTypeIcon(
file: FileLike,
size: number | string = "2rem",
): React.ReactElement {
const name = (file?.name ?? "").toLowerCase();
const mime = (file?.type ?? "").toLowerCase();
const ext = detectFileExtension(name);
// JavaScript
if (ext === "js" || mime.includes("javascript")) {
return <JavascriptIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
return (
<JavascriptIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
/>
);
}
// PDF
if (ext === "pdf" || mime === "application/pdf") {
return <PictureAsPdfIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
return (
<PictureAsPdfIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
/>
);
}
// Fallback generic
return <InsertDriveFileIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
return (
<InsertDriveFileIcon
style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }}
/>
);
}
@@ -17,11 +17,15 @@ 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;
@@ -93,10 +97,20 @@ export function adjustFontSizeToFit(element: HTMLElement, options: AdjustFontSiz
}
/** 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]);
}, [
ref,
options.maxFontSizePx,
options.minFontScale,
options.stepScale,
options.maxLines,
options.singleLine,
]);
}
@@ -10,6 +10,8 @@ interface InsufficientCreditsModalProps {
requiredCredits?: number;
}
export function InsufficientCreditsModal(_props: InsufficientCreditsModalProps) {
export function InsufficientCreditsModal(
_props: InsufficientCreditsModalProps,
) {
return null;
}
@@ -1,5 +1,8 @@
import { useRef, useEffect, useState } from "react";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { FileId } from "@app/types/file";
@@ -25,7 +28,11 @@ 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");
@@ -104,7 +111,8 @@ export const useFileItemDragDrop = ({ fileId, index, onReorder }: UseFileItemDra
if (!element) return;
const rect = element.getBoundingClientRect();
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
const clientY =
(source as any).element?.getBoundingClientRect().top || 0;
const midpoint = rect.top + rect.height / 2;
setDropPosition(clientY < midpoint ? "below" : "above");
@@ -16,7 +16,10 @@ 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 {
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";
@@ -30,8 +33,12 @@ interface ActiveToolButtonProps {
const NAV_IDS = ["read", "sign", "automate"];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, tooltipPosition = "right" }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
setActiveButton,
tooltipPosition = "right",
}) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } =
useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation();
@@ -40,11 +47,14 @@ 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
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
const [indicatorTool, setIndicatorTool] = useState<
typeof selectedTool | null
>(null);
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
const [replayAnim, setReplayAnim] = useState<boolean>(false);
const [isBackHover, setIsBackHover] = useState<boolean>(false);
@@ -172,9 +182,13 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
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)",
backgroundColor: isBackHover
? "var(--color-gray-300)"
: "var(--icon-tools-bg)",
color: isBackHover ? "#fff" : "var(--icon-tools-color)",
border: "none",
borderRadius: "8px",
@@ -183,7 +197,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton, to
}}
>
<span className="iconContainer">
{isBackHover ? <ArrowBackRoundedIcon sx={{ fontSize: "1.875rem" }} /> : indicatorTool.icon}
{isBackHover ? (
<ArrowBackRoundedIcon sx={{ fontSize: "1.875rem" }} />
) : (
indicatorTool.icon
)}
</span>
</ActionIcon>
</Tooltip>
@@ -293,7 +293,8 @@
background: var(--bg-raised);
border-radius: 16px;
border: 1px solid var(--border-default);
box-shadow: 0 18px 36px color-mix(in srgb, var(--text-primary) 12%, transparent);
box-shadow: 0 18px 36px
color-mix(in srgb, var(--text-primary) 12%, transparent);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -444,7 +445,11 @@
.quick-access-popout__input.has-error {
border-color: var(--color-yellow-400);
background: color-mix(in srgb, var(--color-yellow-200) 18%, var(--bg-surface));
background: color-mix(
in srgb,
var(--color-yellow-200) 18%,
var(--bg-surface)
);
}
.quick-access-popout__input::placeholder {
@@ -591,7 +596,11 @@
.quick-access-popout__warning {
margin-top: 0.75rem;
background: color-mix(in srgb, var(--color-yellow-100) 35%, var(--bg-surface));
background: color-mix(
in srgb,
var(--color-yellow-100) 35%,
var(--bg-surface)
);
border: 1px solid var(--color-yellow-300);
border-radius: 12px;
padding: 0.75rem;
@@ -723,7 +732,11 @@
.quick-access-popout__quick-sign-btn:hover {
background: color-mix(in srgb, var(--btn-open-file) 12%, var(--bg-muted));
border-color: color-mix(in srgb, var(--btn-open-file) 40%, var(--border-default));
border-color: color-mix(
in srgb,
var(--btn-open-file) 40%,
var(--border-default)
);
}
/* Tab navigation */
@@ -814,8 +827,17 @@
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 40%, transparent);
background: color-mix(
in srgb,
var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%,
transparent
);
border: 1px solid
color-mix(
in srgb,
var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 40%,
transparent
);
border-radius: 5px;
color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
cursor: pointer;
@@ -827,7 +849,11 @@
}
.quick-access-popout__section-action:hover {
background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 28%, transparent);
background: color-mix(
in srgb,
var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 28%,
transparent
);
border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
}
@@ -909,7 +935,11 @@
}
.quick-access-popout__filter-chip.is-active {
background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, transparent);
background: color-mix(
in srgb,
var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%,
transparent
);
border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6));
font-weight: 500;
@@ -926,7 +956,8 @@
min-height: 0;
}
.quick-access-sign-popout .quick-access-popout__body.sign-tab-requestSignatures {
.quick-access-sign-popout
.quick-access-popout__body.sign-tab-requestSignatures {
transform: translateX(0%);
}
@@ -14,8 +14,12 @@ export const isNavButtonActive = (
selectedToolKey?: string | null,
leftPanelView?: "toolPicker" | "toolContent" | "hidden",
): boolean => {
const isActiveByLocalState = config.type === "navigation" && activeButton === config.id;
const isActiveByContext = config.type === "navigation" && leftPanelView === "toolContent" && selectedToolKey === config.id;
const 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);
@@ -34,7 +38,14 @@ export const getNavButtonStyle = (
selectedToolKey?: string | null,
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 {
@@ -57,7 +68,10 @@ export const getNavButtonStyle = (
/**
* 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";
@@ -38,8 +38,12 @@ const QuickAccessButton: React.FC<QuickAccessButtonProps> = ({
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 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
@@ -41,7 +41,8 @@ export function useToursTooltip(): ToursTooltipState {
};
window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange);
return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange);
return () =>
window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange);
}, []);
// Show once after onboarding is complete
@@ -71,7 +72,11 @@ export function useToursTooltip(): ToursTooltipState {
[hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip],
);
const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip;
const tooltipOpen = toursMenuOpen
? false
: hasBeenDismissed
? undefined
: showToursTooltip;
return {
tooltipOpen,
@@ -7,12 +7,19 @@ 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 {
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 {
defaultParameters,
RedactParameters,
} from "@app/hooks/tools/redact/useRedactParameters";
import { RedactionMode } from "@embedpdf/plugin-redaction";
interface ViewerAnnotationControlsProps {
@@ -20,11 +27,15 @@ interface ViewerAnnotationControlsProps {
disabled?: boolean;
}
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
export default function ViewerAnnotationControls({
currentView,
disabled = false,
}: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
const { position: tooltipPosition, offset: tooltipOffset } =
useRightRailTooltipSide(sidebarRefs);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
@@ -45,33 +56,60 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
// 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 { requestNavigation, setHasUnsavedChanges, hasUnsavedChanges } =
useNavigationGuard();
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";
selectedTool === "annotate" ||
selectedTool === "sign" ||
selectedTool === "addImage" ||
selectedTool === "addText";
// Check if we're on annotate tool to highlight the button
const isAnnotateActive = selectedTool === "annotate";
const annotationsHidden = viewerContext ? !viewerContext.isAnnotationsVisible : false;
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");
await fileActions.consumeFiles([state.files.ids[0]], stirlingFiles, stubs);
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);
@@ -151,7 +189,11 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
<>
{/* Redaction Mode Toggle */}
<Tooltip
content={isRedactMode ? t("rightRail.exitRedaction", "Exit Redaction Mode") : t("rightRail.redact", "Redact")}
content={
isRedactMode
? t("rightRail.exitRedaction", "Exit Redaction Mode")
: t("rightRail.redact", "Redact")
}
position={tooltipPosition}
offset={tooltipOffset}
arrow
@@ -165,13 +207,20 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
onClick={handleRedactionToggle}
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")}
content={t(
"rightRail.toggleAnnotations",
"Toggle Annotations Visibility",
)}
position={tooltipPosition}
offset={tooltipOffset}
arrow
@@ -183,12 +232,21 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
radius="md"
className="right-rail-icon"
onClick={handleToggleAnnotationsVisibility}
disabled={disabled || currentView !== "viewer" || (isInAnnotationTool && !isAnnotateActive) || isPlacementMode}
disabled={
disabled ||
currentView !== "viewer" ||
(isInAnnotationTool && !isAnnotateActive) ||
isPlacementMode
}
data-active={annotationsHidden ? "true" : undefined}
aria-pressed={annotationsHidden}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "preview-off-rounded"}
icon={
viewerContext?.isAnnotationsVisible
? "visibility"
: "preview-off-rounded"
}
width="1.5rem"
height="1.5rem"
/>
@@ -21,10 +21,18 @@ 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 => {
const getStatusColor = (
status?: string,
itemType?: string,
item?: SessionItem,
): string => {
if (itemType === "mySession" && item) {
const signedCount = item.signedCount ?? 0;
const totalCount = item.participantCount ?? 0;
@@ -57,7 +65,11 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio
}
// 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");
}
@@ -86,7 +98,10 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio
{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>
) : (
@@ -98,16 +113,20 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio
onClick={() => onSessionClick(session)}
>
<div className="quick-access-popout__sign-request-info">
<div className="quick-access-popout__row-title">{session.documentName}</div>
<div className="quick-access-popout__row-title">
{session.documentName}
</div>
<div className="quick-access-popout__row-subtitle">
{session.itemType === "signRequest" ? (
<>
From: {session.ownerUsername}
{session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
{session.dueDate &&
` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
</>
) : (
<>
Created: {new Date(session.createdAt).toLocaleDateString()}
Created:{" "}
{new Date(session.createdAt).toLocaleDateString()}
{session.signedCount !== undefined &&
session.participantCount !== undefined &&
`${session.signedCount}/${session.participantCount} signed`}
@@ -116,7 +135,14 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio
</div>
</div>
<div className="quick-access-popout__sign-request-badge">
<Badge size="sm" color={getStatusColor(session.myStatus, session.itemType, session)}>
<Badge
size="sm"
color={getStatusColor(
session.myStatus,
session.itemType,
session,
)}
>
{getStatusLabel(session)}
</Badge>
</div>
@@ -21,7 +21,11 @@ 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 => {
@@ -73,16 +77,20 @@ const CompletedSessionsPanel = ({ sessions, loading, onSessionClick }: Completed
onClick={() => onSessionClick(session)}
>
<div className="quick-access-popout__sign-request-info">
<div className="quick-access-popout__row-title">{session.documentName}</div>
<div className="quick-access-popout__row-title">
{session.documentName}
</div>
<div className="quick-access-popout__row-subtitle">
{session.itemType === "signRequest" ? (
<>
From: {session.ownerUsername}
{session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
{session.dueDate &&
` • Due: ${new Date(session.dueDate).toLocaleDateString()}`}
</>
) : (
<>
Created: {new Date(session.createdAt).toLocaleDateString()}
Created:{" "}
{new Date(session.createdAt).toLocaleDateString()}
{session.signedCount !== undefined &&
session.participantCount !== undefined &&
`${session.signedCount}/${session.participantCount} signed`}
@@ -91,7 +99,10 @@ const CompletedSessionsPanel = ({ sessions, loading, onSessionClick }: Completed
</div>
</div>
<div className="quick-access-popout__sign-request-badge">
<Badge size="sm" color={getStatusColor(session.myStatus, session.itemType)}>
<Badge
size="sm"
color={getStatusColor(session.myStatus, session.itemType)}
>
{getStatusLabel(session)}
</Badge>
</div>
@@ -29,14 +29,22 @@ 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)",
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)"
@@ -105,14 +113,16 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
const [currentStep, setCurrentStep] = useState(1);
// Signature settings state
const [signatureSettings, setSignatureSettings] = useState<SignatureSettings>({
showSignature: false,
pageNumber: 1,
reason: "",
location: "",
showLogo: false,
includeSummaryPage: false,
});
const [signatureSettings, setSignatureSettings] = useState<SignatureSettings>(
{
showSignature: false,
pageNumber: 1,
reason: "",
location: "",
showLogo: false,
includeSummaryPage: false,
},
);
const groupSigningTips = useGroupSigningTips();
const signatureSettingsTips = useSignatureSettingsTips();
@@ -128,12 +138,18 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
},
{
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,
},
{
@@ -147,9 +163,17 @@ 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" }}>
<Text size="sm" c="dimmed" ta="center">
@@ -163,7 +187,12 @@ 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}
@@ -174,7 +203,12 @@ 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}
@@ -185,7 +219,12 @@ 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}
@@ -33,31 +33,44 @@ 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,13 +82,18 @@ const CreateSessionPanel = ({
<div className="quick-access-popout__section">
<Switch
label={t("certSign.collab.sessionCreation.includeSummaryPage", "Include Signature Summary Page")}
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)}
onChange={(e) =>
onIncludeSummaryPageChange(e.currentTarget.checked)
}
disabled={creating}
size="sm"
/>
@@ -9,9 +9,17 @@ import CompletedSessionsPanel from "@app/components/shared/signing/CompletedSess
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 {
SignRequestSummary,
SignRequestDetail,
SessionSummary,
SessionDetail,
} from "@app/types/signingSession";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext";
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";
@@ -19,19 +27,25 @@ import SignRequestWorkbenchView from "@app/components/tools/certSign/SignRequest
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";
};
function sortSessions(sessions: SessionItem[], tab: "active" | "completed"): SessionItem[] {
function sortSessions(
sessions: SessionItem[],
tab: "active" | "completed",
): SessionItem[] {
return [...sessions].sort((a, b) => {
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();
if (aDue && bDue)
return new Date(aDue).getTime() - new Date(bDue).getTime();
if (aDue) return -1;
if (bDue) return 1;
}
@@ -47,11 +61,20 @@ interface SignPopoutProps {
groupSigningEnabled: boolean;
}
const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: SignPopoutProps) => {
const SignPopout = ({
isOpen,
onClose,
buttonRef,
isRTL,
groupSigningEnabled,
}: SignPopoutProps) => {
const { t } = useTranslation();
const isPhone = useIsPhone();
const popoverRef = useRef<HTMLDivElement>(null);
const [popoverPosition, setPopoverPosition] = useState({ top: 160, left: 84 });
const [popoverPosition, setPopoverPosition] = useState({
top: 160,
left: 84,
});
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
// Tab state
@@ -126,7 +149,10 @@ 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,
@@ -197,7 +223,9 @@ 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();
@@ -220,13 +248,18 @@ 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<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"),
@@ -248,7 +281,12 @@ 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
@@ -264,7 +302,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
.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[] = [
@@ -273,7 +313,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
.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
@@ -286,7 +328,10 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
: [
{ key: "mine", label: t("quickAccess.filterMine", "Mine") },
{ key: "signed", label: t("quickAccess.filterSigned", "Signed") },
{ key: "declined", label: t("quickAccess.filterDeclined", "Declined") },
{
key: "declined",
label: t("quickAccess.filterDeclined", "Declined"),
},
];
const applyFiltersAndSearch = (sessions: SessionItem[]): SessionItem[] => {
@@ -296,16 +341,31 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
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("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");
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 () => {
@@ -314,7 +374,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
setCreating(true);
try {
const selectedFile = selectedFiles[0];
const stirlingFile = await fileStorage.getStirlingFile(selectedFile.fileId);
const stirlingFile = await fileStorage.getStirlingFile(
selectedFile.fileId,
);
if (!stirlingFile) throw new Error("File not found");
const formData = new FormData();
@@ -351,7 +413,10 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
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"),
@@ -362,7 +427,14 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
} finally {
setCreating(false);
}
}, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]);
}, [
selectedUserIds,
dueDate,
selectedFiles,
fetchData,
t,
includeSummaryPage,
]);
// Handle clicking a sign request
const handleSignRequestClick = useCallback(
@@ -370,15 +442,24 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
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",
}),
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 pdfFile = new File(
[pdfResponse.data],
detailResponse.data.documentName,
{
type: "application/pdf",
},
);
const canSign =
detailResponse.data.myStatus === "PENDING" ||
detailResponse.data.myStatus === "NOTIFIED" ||
@@ -387,7 +468,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, {
signRequest: detailResponse.data,
pdfFile,
onSign: (certData: FormData) => handleSign(request.sessionId, certData),
onSign: (certData: FormData) =>
handleSign(request.sessionId, certData),
onDecline: () => handleDecline(request.sessionId),
onBack: () => {
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
@@ -400,7 +482,10 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
navigationActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE);
});
} catch (error) {
console.error("Failed to load sign request:", error instanceof Error ? error.message : error);
console.error(
"Failed to load sign request:",
error instanceof Error ? error.message : error,
);
alert({
alertType: "error",
title: t("common.error"),
@@ -410,7 +495,13 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
});
}
},
[onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t],
[
onClose,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
navigationActions,
t,
],
);
// Handle clicking a session
@@ -419,7 +510,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
onClose();
try {
// First fetch session detail
const detailResponse = await apiClient.get<SessionDetail>(`/api/v1/security/cert-sign/sessions/${session.sessionId}`);
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;
@@ -427,10 +520,15 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
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",
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",
});
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
@@ -451,10 +549,15 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
} 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",
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",
});
pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf" });
} catch (_error) {
// Fallback if PDF not available
pdfFile = null;
@@ -464,11 +567,14 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, {
session: detailResponse.data,
pdfFile,
onFinalize: () => handleFinalize(session.sessionId, session.documentName),
onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName),
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),
onRemoveParticipant: (participantId: number) =>
handleRemoveParticipant(session.sessionId, participantId),
onDelete: () => handleDeleteSession(session.sessionId),
onBack: () => {
clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID);
@@ -481,22 +587,37 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
navigationActions.setWorkbench(SESSION_DETAIL_WORKBENCH_TYPE);
});
} catch (error) {
console.error("Failed to load session:", error instanceof Error ? error.message : 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"),
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);
await apiClient.post(
`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`,
certificateData,
);
alert({
alertType: "success",
title: t("success"),
@@ -510,7 +631,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
};
const handleDecline = async (sessionId: string) => {
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`);
await apiClient.post(
`/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`,
);
alert({
alertType: "success",
title: t("success"),
@@ -524,13 +647,21 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
};
const handleFinalize = async (sessionId: string, documentName: string) => {
const response = await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/finalize`, null, {
responseType: "blob",
});
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 filename = filenameMatch
? filenameMatch[1]
: `${documentName}_signed.pdf`;
const signedFile = new File([response.data], filename, {
type: "application/pdf",
});
await fileActions.addFiles([signedFile]);
alert({
alertType: "success",
@@ -544,14 +675,24 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
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 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 filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/);
const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`;
const signedFile = new File([response.data], filename, { type: "application/pdf" });
const filename = filenameMatch
? filenameMatch[1]
: `${documentName}_signed.pdf`;
const signedFile = new File([response.data], filename, {
type: "application/pdf",
});
await fileActions.addFiles([signedFile]);
alert({
alertType: "success",
@@ -564,18 +705,30 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
navigationActions.setWorkbench("viewer");
};
const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => {
const handleAddParticipants = async (
sessionId: string,
userIds: number[],
defaultReason?: string,
) => {
const requests = userIds.map((userId) => ({
userId,
defaultReason: defaultReason || undefined,
sendNotification: true,
}));
await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/participants`, requests);
await apiClient.post(
`/api/v1/security/cert-sign/sessions/${sessionId}/participants`,
requests,
);
await handleRefreshSession(sessionId);
};
const handleRemoveParticipant = async (sessionId: string, participantId: number) => {
await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`);
const handleRemoveParticipant = async (
sessionId: string,
participantId: number,
) => {
await apiClient.delete(
`/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`,
);
await handleRefreshSession(sessionId);
};
@@ -594,19 +747,29 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
};
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,
session: response.data,
}));
setCustomWorkbenchViewData(
SESSION_DETAIL_WORKBENCH_ID,
(prevData: any) => ({
...prevData,
session: response.data,
}),
);
};
if (typeof document === "undefined") return null;
// Shared card content — rendered inside either the portal (desktop/tablet) or Drawer (phone)
const popoutCard = (
<div className="quick-access-popout__card" style={{ maxHeight: !isPhone && maxHeight ? `${maxHeight}px` : undefined }}>
<div
className="quick-access-popout__card"
style={{
maxHeight: !isPhone && maxHeight ? `${maxHeight}px` : undefined,
}}
>
{/* Header */}
<div className="quick-access-popout__header">
<button
@@ -653,7 +816,9 @@ 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"
@@ -674,7 +839,11 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
handleToolSelect("certSign");
}}
>
<LocalIcon icon="workspace-premium-rounded" width="1rem" height="1rem" />
<LocalIcon
icon="workspace-premium-rounded"
width="1rem"
height="1rem"
/>
{t("quickAccess.certSign", "Certificate Sign")}
</button>
</div>
@@ -685,7 +854,9 @@ 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"
@@ -787,7 +958,11 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
type="button"
className="quick-access-popout__primary"
onClick={handleCreateSession}
disabled={selectedFiles.length !== 1 || selectedUserIds.length === 0 || creating}
disabled={
selectedFiles.length !== 1 ||
selectedUserIds.length === 0 ||
creating
}
>
<LocalIcon icon="send-rounded" width="1rem" height="1rem" />
{creating
@@ -810,7 +985,14 @@ 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>
@@ -1,7 +1,9 @@
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 SignatureSettingsInput, {
SignatureSettings,
} from "@app/components/tools/certSign/SignatureSettingsInput";
interface ConfigureSignatureDefaultsStepProps {
settings: SignatureSettings;
@@ -11,18 +13,18 @@ interface ConfigureSignatureDefaultsStepProps {
disabled?: boolean;
}
export const ConfigureSignatureDefaultsStep: React.FC<ConfigureSignatureDefaultsStepProps> = ({
settings,
onSettingsChange,
onBack,
onNext,
disabled = false,
}) => {
export const ConfigureSignatureDefaultsStep: React.FC<
ConfigureSignatureDefaultsStepProps
> = ({ settings, onSettingsChange, onBack, onNext, disabled = false }) => {
const { t } = useTranslation();
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">
@@ -31,30 +33,56 @@ export const ConfigureSignatureDefaultsStep: React.FC<ConfigureSignatureDefaults
</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>
</Paper>
<Group gap="sm">
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
<Button
variant="default"
onClick={onBack}
leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}
>
{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>
@@ -41,7 +41,9 @@ export const ReviewSessionStep: React.FC<ReviewSessionStepProps> = ({
{/* 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")}
</Text>
@@ -88,33 +90,54 @@ 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}}", {
page: signatureSettings.pageNumber || 1,
})
: t("groupSigning.steps.review.invisible", "Invisible (metadata only)")}
? t(
"groupSigning.steps.review.visible",
"Visible on page {{page}}",
{
page: signatureSettings.pageNumber || 1,
},
)
: 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>{" "}
{signatureSettings.showLogo
? t("groupSigning.steps.review.logoShown", "Stirling PDF logo shown")
? t(
"groupSigning.steps.review.logoShown",
"Stirling PDF logo shown",
)
: t("groupSigning.steps.review.logoHidden", "No logo")}
</Text>
)}
@@ -136,12 +159,19 @@ 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 }} />}>
<Button
variant="default"
onClick={onBack}
leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}
>
{t("groupSigning.steps.back", "Back")}
</Button>
<Button
@@ -8,7 +8,10 @@ interface SelectDocumentStepProps {
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;
@@ -27,7 +30,10 @@ export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({ selected
<>
<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={{
@@ -40,7 +46,9 @@ export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({ selected
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}
@@ -55,7 +63,10 @@ export const SelectDocumentStep: React.FC<SelectDocumentStepProps> = ({ selected
</div>
<Button onClick={onNext} fullWidth>
{t("groupSigning.steps.selectDocument.continue", "Continue to Participant Selection")}
{t(
"groupSigning.steps.selectDocument.continue",
"Continue to Participant Selection",
)}
</Button>
</>
)}
@@ -26,12 +26,18 @@ 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>
@@ -46,11 +52,22 @@ export const SelectParticipantsStep: React.FC<SelectParticipantsStepProps> = ({
)}
<Group gap="sm">
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
<Button
variant="default"
onClick={onBack}
leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}
>
{t("groupSigning.steps.back", "Back")}
</Button>
<Button onClick={onNext} disabled={!hasParticipants || disabled} style={{ flex: 1 }}>
{t("groupSigning.steps.selectParticipants.continue", "Continue to Signature Settings")}
<Button
onClick={onNext}
disabled={!hasParticipants || disabled}
style={{ flex: 1 }}
>
{t(
"groupSigning.steps.selectParticipants.continue",
"Continue to Signature Settings",
)}
</Button>
</Group>
</Stack>
@@ -28,7 +28,14 @@ export default function SliderWithInput({
</Text>
<Group gap="md" align="center">
<div style={{ flex: 1 }}>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={onChange}
disabled={disabled}
/>
</div>
<NumberInput
value={value}
@@ -132,7 +132,10 @@
.tooltip-body a:hover {
color: var(--link-hover-color, #2563eb) !important;
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
text-decoration-color: var(
--link-hover-underline-color,
rgba(37, 99, 235, 0.5)
);
}
.tooltip-container .tooltip-body {
@@ -152,7 +155,10 @@
.tooltip-container .tooltip-body a:hover {
color: var(--link-hover-color, #2563eb) !important;
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
text-decoration-color: var(
--link-hover-underline-color,
rgba(37, 99, 235, 0.5)
);
}
/* Tooltip Arrows */
@@ -8,7 +8,11 @@ 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"]}`}
@@ -23,7 +27,10 @@ export const TooltipContent: React.FC<TooltipContentProps> = ({ content, tips, e
{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={{
@@ -42,18 +49,35 @@ export const TooltipContent: React.FC<TooltipContentProps> = ({ content, tips, e
)}
{tip.description && (
<p
style={{ margin: "0 0 12px 0", color: "var(--text-secondary)", fontSize: "13px" }}
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>}
@@ -9,7 +9,11 @@ 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);
@@ -90,7 +94,10 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({ signat
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
@@ -116,11 +123,18 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({ signat
<Text size="xs" mb={4}>
{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}
@@ -9,7 +9,11 @@ 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 (
@@ -24,7 +28,10 @@ export const SignatureTypeSelector: React.FC<SignatureTypeSelectorProps> = ({ va
},
{
value: "upload",
label: t("certSign.collab.signRequest.signatureType.upload", "Upload"),
label: t(
"certSign.collab.signRequest.signatureType.upload",
"Upload",
),
},
{
value: "type",
@@ -1,4 +1,11 @@
import { Stack, TextInput, Select, ColorPicker, Slider, Text } from "@mantine/core";
import {
Stack,
TextInput,
Select,
ColorPicker,
Slider,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useEffect, useRef } from "react";
@@ -73,12 +80,18 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
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...")}
placeholder={t(
"certSign.collab.signRequest.signatureTextPlaceholder",
"Enter your name...",
)}
value={text}
onChange={(e) => onTextChange(e.target.value)}
disabled={disabled}
@@ -94,7 +107,9 @@ 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}
@@ -145,7 +160,12 @@ 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>
);
};
@@ -10,7 +10,11 @@ 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);
@@ -21,13 +25,23 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({ sign
// Validate file type
if (!file.type.startsWith("image/")) {
setError(t("certSign.collab.signRequest.invalidFileType", "Please select an image file"));
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;
}
@@ -57,7 +71,10 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({ sign
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 ? (
@@ -74,7 +91,12 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({ sign
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