mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
+45
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user