mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Merge branch 'V2' into V2-CBR-port
This commit is contained in:
commit
6bd6d91f3c
@ -3658,8 +3658,10 @@
|
|||||||
"help": "Help",
|
"help": "Help",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
|
"settings": "Settings",
|
||||||
"adminSettings": "Admin Settings",
|
"adminSettings": "Admin Settings",
|
||||||
"allTools": "All Tools",
|
"allTools": "Tools",
|
||||||
|
"reader": "Reader",
|
||||||
"helpMenu": {
|
"helpMenu": {
|
||||||
"toolsTour": "Tools Tour",
|
"toolsTour": "Tools Tour",
|
||||||
"toolsTourDesc": "Learn what the tools can do",
|
"toolsTourDesc": "Learn what the tools can do",
|
||||||
@ -4800,7 +4802,7 @@
|
|||||||
"maybeLater": "Maybe Later",
|
"maybeLater": "Maybe Later",
|
||||||
"dontShowAgain": "Don't Show Again"
|
"dontShowAgain": "Don't Show Again"
|
||||||
},
|
},
|
||||||
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||||
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
||||||
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
||||||
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
|
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
|
||||||
@ -4860,7 +4862,8 @@
|
|||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"roleDescriptions": {
|
"roleDescriptions": {
|
||||||
"admin": "Can manage settings and invite members, with full administrative access.",
|
"admin": "Can manage settings and invite members, with full administrative access.",
|
||||||
"member": "Can view and edit shared files, but cannot manage workspace settings or members."
|
"member": "Can view and edit shared files, but cannot manage workspace settings or members.",
|
||||||
|
"user": "User"
|
||||||
},
|
},
|
||||||
"editRole": "Edit Role",
|
"editRole": "Edit Role",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
@ -4941,6 +4944,7 @@
|
|||||||
"copied": "Link copied to clipboard",
|
"copied": "Link copied to clipboard",
|
||||||
"success": "Invite link generated successfully",
|
"success": "Invite link generated successfully",
|
||||||
"successWithEmail": "Invite link generated and sent via email",
|
"successWithEmail": "Invite link generated and sent via email",
|
||||||
|
"emailSent": "Invite link generated and sent via email",
|
||||||
"emailFailed": "Invite link generated, but email failed",
|
"emailFailed": "Invite link generated, but email failed",
|
||||||
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
|
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
|
||||||
"error": "Failed to generate invite link",
|
"error": "Failed to generate invite link",
|
||||||
|
|||||||
@ -3967,7 +3967,7 @@
|
|||||||
"account": "Account",
|
"account": "Account",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
"adminSettings": "Admin Settings",
|
"adminSettings": "Admin Settings",
|
||||||
"allTools": "All Tools"
|
"allTools": "Tools"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
|||||||
@ -68,11 +68,11 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<PageEditorProvider>
|
<PageEditorProvider>
|
||||||
<SignatureProvider>
|
<SignatureProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<TourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
<AdminTourOrchestrationProvider>
|
<AdminTourOrchestrationProvider>
|
||||||
{children}
|
{children}
|
||||||
</AdminTourOrchestrationProvider>
|
</AdminTourOrchestrationProvider>
|
||||||
</TourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</SignatureProvider>
|
</SignatureProvider>
|
||||||
</PageEditorProvider>
|
</PageEditorProvider>
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export default function OnboardingTour() {
|
|||||||
const stepsConfig: Record<TourStep, StepType> = {
|
const stepsConfig: Record<TourStep, StepType> = {
|
||||||
[TourStep.ALL_TOOLS]: {
|
[TourStep.ALL_TOOLS]: {
|
||||||
selector: '[data-tour="tool-panel"]',
|
selector: '[data-tour="tool-panel"]',
|
||||||
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||||
position: 'center',
|
position: 'center',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|||||||
@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
|
|
||||||
const iconNode = (
|
const iconNode = (
|
||||||
<span className="iconContainer">
|
<span className="iconContainer">
|
||||||
<AppsIcon sx={{ fontSize: '2rem' }} />
|
<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
<Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="a"
|
component="a"
|
||||||
href={navProps.href}
|
href={navProps.href}
|
||||||
onClick={handleNavClick}
|
onClick={handleNavClick}
|
||||||
size={'lg'}
|
size={isActive ? 'lg' : 'md'}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
aria-label={t("quickAccess.allTools", "All Tools")}
|
aria-label={t("quickAccess.allTools", "Tools")}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||||
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||||
@ -61,7 +61,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
{iconNode}
|
{iconNode}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
|
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
|
||||||
{t("quickAccess.allTools", "All Tools")}
|
{t("quickAccess.allTools", "Tools")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
@ -7,6 +7,7 @@ import { useFileHandler } from '@app/hooks/useFileHandler';
|
|||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||||
|
import { useFileManager } from '@app/hooks/useFileManager';
|
||||||
|
|
||||||
const LandingPage = () => {
|
const LandingPage = () => {
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
@ -16,6 +17,8 @@ const LandingPage = () => {
|
|||||||
const { openFilesModal } = useFilesModalContext();
|
const { openFilesModal } = useFilesModalContext();
|
||||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||||
const logoPath = useLogoPath();
|
const logoPath = useLogoPath();
|
||||||
|
const { loadRecentFiles } = useFileManager();
|
||||||
|
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
||||||
|
|
||||||
const handleFileDrop = async (files: File[]) => {
|
const handleFileDrop = async (files: File[]) => {
|
||||||
await addFiles(files);
|
await addFiles(files);
|
||||||
@ -38,6 +41,22 @@ const LandingPage = () => {
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Determine if the user has any recent files (same source as File Manager)
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const files = await loadRecentFiles();
|
||||||
|
if (isMounted) {
|
||||||
|
setHasRecents((files?.length || 0) > 0);
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
if (isMounted) setHasRecents(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { isMounted = false; };
|
||||||
|
}, [loadRecentFiles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
|
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
|
||||||
{/* White PDF Page Background */}
|
{/* White PDF Page Background */}
|
||||||
@ -119,59 +138,89 @@ const LandingPage = () => {
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={() => setIsUploadHover(false)}
|
onMouseLeave={() => setIsUploadHover(false)}
|
||||||
>
|
>
|
||||||
<Button
|
{/* Show both buttons only when recents exist; otherwise show a single Upload button */}
|
||||||
style={{
|
{hasRecents && (
|
||||||
backgroundColor: 'var(--landing-button-bg)',
|
<>
|
||||||
color: 'var(--landing-button-color)',
|
<Button
|
||||||
border: '1px solid var(--landing-button-border)',
|
style={{
|
||||||
borderRadius: '2rem',
|
backgroundColor: 'var(--landing-button-bg)',
|
||||||
height: '38px',
|
color: 'var(--landing-button-color)',
|
||||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
border: '1px solid var(--landing-button-border)',
|
||||||
paddingRight: isUploadHover ? 0 : '1rem',
|
borderRadius: '2rem',
|
||||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
height: '38px',
|
||||||
minWidth: isUploadHover ? '58px' : undefined,
|
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||||
display: 'flex',
|
paddingRight: isUploadHover ? 0 : '1rem',
|
||||||
alignItems: 'center',
|
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||||
justifyContent: 'center',
|
minWidth: isUploadHover ? '58px' : undefined,
|
||||||
transition: 'width .5s ease, padding .5s ease'
|
display: 'flex',
|
||||||
}}
|
alignItems: 'center',
|
||||||
onClick={handleOpenFilesModal}
|
justifyContent: 'center',
|
||||||
onMouseEnter={() => setIsUploadHover(false)}
|
transition: 'width .5s ease, padding .5s ease'
|
||||||
>
|
}}
|
||||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
onClick={handleOpenFilesModal}
|
||||||
{!isUploadHover && (
|
onMouseEnter={() => setIsUploadHover(false)}
|
||||||
<span>
|
>
|
||||||
{t('landing.addFiles', 'Add Files')}
|
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||||
</span>
|
{!isUploadHover && (
|
||||||
)}
|
<span>
|
||||||
</Button>
|
{t('landing.addFiles', 'Add Files')}
|
||||||
<Button
|
</span>
|
||||||
aria-label="Upload"
|
)}
|
||||||
style={{
|
</Button>
|
||||||
backgroundColor: 'var(--landing-button-bg)',
|
<Button
|
||||||
color: 'var(--landing-button-color)',
|
aria-label="Upload"
|
||||||
border: '1px solid var(--landing-button-border)',
|
style={{
|
||||||
borderRadius: '1rem',
|
backgroundColor: 'var(--landing-button-bg)',
|
||||||
height: '38px',
|
color: 'var(--landing-button-color)',
|
||||||
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
border: '1px solid var(--landing-button-border)',
|
||||||
minWidth: '58px',
|
borderRadius: '1rem',
|
||||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
height: '38px',
|
||||||
paddingRight: isUploadHover ? '1rem' : 0,
|
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||||
display: 'flex',
|
minWidth: '58px',
|
||||||
alignItems: 'center',
|
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||||
justifyContent: 'center',
|
paddingRight: isUploadHover ? '1rem' : 0,
|
||||||
transition: 'width .5s ease, padding .5s ease'
|
display: 'flex',
|
||||||
}}
|
alignItems: 'center',
|
||||||
onClick={handleNativeUploadClick}
|
justifyContent: 'center',
|
||||||
onMouseEnter={() => setIsUploadHover(true)}
|
transition: 'width .5s ease, padding .5s ease'
|
||||||
>
|
}}
|
||||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
onClick={handleNativeUploadClick}
|
||||||
{isUploadHover && (
|
onMouseEnter={() => setIsUploadHover(true)}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||||
|
{isUploadHover && (
|
||||||
|
<span style={{ marginLeft: '.5rem' }}>
|
||||||
|
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!hasRecents && (
|
||||||
|
<Button
|
||||||
|
aria-label="Upload"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--landing-button-bg)',
|
||||||
|
color: 'var(--landing-button-color)',
|
||||||
|
border: '1px solid var(--landing-button-border)',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
height: '38px',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: '58px',
|
||||||
|
paddingLeft: '1rem',
|
||||||
|
paddingRight: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
onClick={handleNativeUploadClick}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||||
<span style={{ marginLeft: '.5rem' }}>
|
<span style={{ marginLeft: '.5rem' }}>
|
||||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden file input for native file picker */}
|
{/* Hidden file input for native file picker */}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
|
|||||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isNavButtonActive,
|
isNavButtonActive,
|
||||||
getNavButtonStyle,
|
getNavButtonStyle,
|
||||||
@ -88,7 +89,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
onClick: () => handleClick(),
|
onClick: () => handleClick(),
|
||||||
'aria-label': config.name
|
'aria-label': config.name
|
||||||
})}
|
})}
|
||||||
size={isActive ? (config.size || 'lg') : 'lg'}
|
size={isActive ? 'lg' : 'md'}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||||
className={isActive ? 'activeIconScale' : ''}
|
className={isActive ? 'activeIconScale' : ''}
|
||||||
@ -108,9 +109,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
const mainButtons: ButtonConfig[] = [
|
const mainButtons: ButtonConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'read',
|
id: 'read',
|
||||||
name: t("quickAccess.read", "Read"),
|
name: t("quickAccess.reader", "Reader"),
|
||||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
size: 'lg',
|
size: 'md',
|
||||||
isRound: false,
|
isRound: false,
|
||||||
type: 'navigation',
|
type: 'navigation',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@ -118,23 +119,11 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
handleReaderToggle();
|
handleReaderToggle();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: 'sign',
|
|
||||||
// name: t("quickAccess.sign", "Sign"),
|
|
||||||
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
|
|
||||||
// size: 'lg',
|
|
||||||
// isRound: false,
|
|
||||||
// type: 'navigation',
|
|
||||||
// onClick: () => {
|
|
||||||
// setActiveButton('sign');
|
|
||||||
// handleToolSelect('sign');
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: 'automate',
|
id: 'automate',
|
||||||
name: t("quickAccess.automate", "Automate"),
|
name: t("quickAccess.automate", "Automate"),
|
||||||
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
|
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
|
||||||
size: 'lg',
|
size: 'md',
|
||||||
isRound: false,
|
isRound: false,
|
||||||
type: 'navigation',
|
type: 'navigation',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@ -147,37 +136,36 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
|
||||||
|
|
||||||
const middleButtons: ButtonConfig[] = [
|
|
||||||
{
|
{
|
||||||
id: 'files',
|
id: 'files',
|
||||||
name: t("quickAccess.files", "Files"),
|
name: t("quickAccess.files", "Files"),
|
||||||
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
|
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
isRound: true,
|
isRound: true,
|
||||||
size: 'lg',
|
size: 'md',
|
||||||
type: 'modal',
|
type: 'modal',
|
||||||
onClick: handleFilesButtonClick
|
onClick: handleFilesButtonClick
|
||||||
},
|
},
|
||||||
//TODO: Activity
|
|
||||||
//{
|
|
||||||
// id: 'activity',
|
|
||||||
// name: t("quickAccess.activity", "Activity"),
|
|
||||||
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
|
||||||
// isRound: true,
|
|
||||||
// size: 'lg',
|
|
||||||
// type: 'navigation',
|
|
||||||
// onClick: () => setActiveButton('activity')
|
|
||||||
//},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const middleButtons: ButtonConfig[] = [];
|
||||||
|
//TODO: Activity
|
||||||
|
//{
|
||||||
|
// id: 'activity',
|
||||||
|
// name: t("quickAccess.activity", "Activity"),
|
||||||
|
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
|
// isRound: true,
|
||||||
|
// size: 'lg',
|
||||||
|
// type: 'navigation',
|
||||||
|
// onClick: () => setActiveButton('activity')
|
||||||
|
//},
|
||||||
|
|
||||||
const bottomButtons: ButtonConfig[] = [
|
const bottomButtons: ButtonConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'help',
|
id: 'help',
|
||||||
name: t("quickAccess.help", "Help"),
|
name: t("quickAccess.help", "Help"),
|
||||||
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
isRound: true,
|
isRound: true,
|
||||||
size: 'lg',
|
size: 'md',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
// This will be overridden by the wrapper logic
|
// This will be overridden by the wrapper logic
|
||||||
@ -185,9 +173,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config',
|
id: 'config',
|
||||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
name: t("quickAccess.settings", "Settings"),
|
||||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
size: 'lg',
|
size: 'md',
|
||||||
type: 'modal',
|
type: 'modal',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigate('/settings/overview');
|
navigate('/settings/overview');
|
||||||
@ -200,7 +188,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-sidebar="quick-access"
|
data-sidebar="quick-access"
|
||||||
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
borderRight: '1px solid var(--border-default)'
|
borderRight: '1px solid var(--border-default)'
|
||||||
}}
|
}}
|
||||||
@ -239,20 +227,30 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Divider after main buttons */}
|
{/* Divider after main buttons (creates gap) */}
|
||||||
<Divider
|
{middleButtons.length === 0 && (
|
||||||
size="xs"
|
<Divider
|
||||||
className="content-divider"
|
size="xs"
|
||||||
/>
|
className="content-divider"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Middle section */}
|
{/* Middle section */}
|
||||||
<Stack gap="lg" align="center">
|
{middleButtons.length > 0 && (
|
||||||
{middleButtons.map((config, index) => (
|
<>
|
||||||
<React.Fragment key={config.id}>
|
<Divider
|
||||||
{renderNavButton(config, index)}
|
size="xs"
|
||||||
</React.Fragment>
|
className="content-divider"
|
||||||
))}
|
/>
|
||||||
</Stack>
|
<Stack gap="lg" align="center">
|
||||||
|
{middleButtons.map((config, index) => (
|
||||||
|
<React.Fragment key={config.id}>
|
||||||
|
{renderNavButton(config, index)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spacer to push bottom buttons to bottom */}
|
{/* Spacer to push bottom buttons to bottom */}
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import { ViewerContext } from '@app/contexts/ViewerContext';
|
|||||||
import { useSignature } from '@app/contexts/SignatureContext';
|
import { useSignature } from '@app/contexts/SignatureContext';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||||
|
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||||
|
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||||
|
|
||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||||
@ -39,7 +41,7 @@ export default function RightRail() {
|
|||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewerContext = React.useContext(ViewerContext);
|
const viewerContext = React.useContext(ViewerContext);
|
||||||
const { toggleTheme } = useRainbowThemeContext();
|
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||||
|
|
||||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||||
@ -195,7 +197,11 @@ export default function RightRail() {
|
|||||||
className="right-rail-icon"
|
className="right-rail-icon"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
{themeMode === 'dark' ? (
|
||||||
|
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
|
||||||
|
) : (
|
||||||
|
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
|
||||||
|
)}
|
||||||
</ActionIcon>,
|
</ActionIcon>,
|
||||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
t('rightRail.toggleTheme', 'Toggle Theme')
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import React, { useState, useCallback, useMemo } from "react";
|
|||||||
import { SegmentedControl, Loader } from "@mantine/core";
|
import { SegmentedControl, Loader } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||||
|
import GridViewIcon from "@mui/icons-material/GridView";
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
|
||||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||||
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
||||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||||
@ -55,7 +55,7 @@ const createViewOptions = (
|
|||||||
{switchingTo === "viewer" ? (
|
{switchingTo === "viewer" ? (
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<VisibilityIcon fontSize="medium" />
|
<InsertDriveFileIcon fontSize="medium" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -84,7 +84,7 @@ const createViewOptions = (
|
|||||||
{switchingTo === "pageEditor" ? (
|
{switchingTo === "pageEditor" ? (
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
|
<GridViewIcon fontSize="medium" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||||
*
|
*
|
||||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
* When a user selects a tool from the Tools list, this component displays the tool's
|
||||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||||
* tool is currently active and offers a back button to return to the All Tools list.
|
* tool is currently active and offers a back button to return to the Tools list.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Shows tool icon and name when a tool is selected
|
* - Shows tool icon and name when a tool is selected
|
||||||
* - Hover to reveal back arrow for returning to All Tools
|
* - Hover to reveal back arrow for returning to Tools
|
||||||
* - Smooth slide-down/slide-up animations
|
* - Smooth slide-down/slide-up animations
|
||||||
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
|
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
|
||||||
*/
|
*/
|
||||||
@ -149,7 +149,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
|||||||
handleBackToTools();
|
handleBackToTools();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
size={'xl'}
|
size={'lg'}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onMouseEnter={() => setIsBackHover(true)}
|
onMouseEnter={() => setIsBackHover(true)}
|
||||||
onMouseLeave={() => setIsBackHover(false)}
|
onMouseLeave={() => setIsBackHover(false)}
|
||||||
@ -165,7 +165,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
|||||||
>
|
>
|
||||||
<span className="iconContainer">
|
<span className="iconContainer">
|
||||||
{isBackHover ? (
|
{isBackHover ? (
|
||||||
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
|
<ArrowBackRoundedIcon sx={{ fontSize: '1.875rem' }} />
|
||||||
) : (
|
) : (
|
||||||
indicatorTool.icon
|
indicatorTool.icon
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,15 +1,29 @@
|
|||||||
.activeIconScale {
|
.activeIconScale {
|
||||||
transform: scale(1.3);
|
transform: scale(1);
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
width: calc(1.75rem + 10px) !important;
|
||||||
|
height: calc(1.75rem + 10px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeIconScale .iconContainer {
|
||||||
|
width: calc(1.5rem + 10px);
|
||||||
|
height: calc(1.5rem + 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeIconScale .iconContainer svg,
|
||||||
|
.activeIconScale .iconContainer img {
|
||||||
|
width: calc(1.25rem + 10px) !important;
|
||||||
|
height: calc(1.25rem + 10px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconContainer {
|
.iconContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2rem;
|
width: 1.5rem;
|
||||||
height: 2rem;
|
height: 1.5rem;
|
||||||
|
transition: width 0.2s, height 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action icon styles */
|
/* Action icon styles */
|
||||||
@ -24,9 +38,9 @@
|
|||||||
/* Main container styles */
|
/* Main container styles */
|
||||||
.quick-access-bar-main {
|
.quick-access-bar-main {
|
||||||
background-color: var(--bg-muted);
|
background-color: var(--bg-muted);
|
||||||
width: 5rem;
|
width: 4rem;
|
||||||
min-width: 5rem;
|
min-width: 4rem;
|
||||||
max-width: 5rem;
|
max-width: 4rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
@ -34,9 +48,9 @@
|
|||||||
/* Rainbow mode container */
|
/* Rainbow mode container */
|
||||||
.quick-access-bar-main.rainbow-mode {
|
.quick-access-bar-main.rainbow-mode {
|
||||||
background-color: var(--bg-muted);
|
background-color: var(--bg-muted);
|
||||||
width: 5rem;
|
width: 4rem;
|
||||||
min-width: 5rem;
|
min-width: 4rem;
|
||||||
max-width: 5rem;
|
max-width: 4rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
@ -57,7 +71,7 @@
|
|||||||
|
|
||||||
/* Nav header divider */
|
/* Nav header divider */
|
||||||
.nav-header-divider {
|
.nav-header-divider {
|
||||||
width: 3.75rem;
|
width: 3rem;
|
||||||
border-color: var(--color-gray-300);
|
border-color: var(--color-gray-300);
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@ -85,7 +99,7 @@
|
|||||||
|
|
||||||
/* Overflow divider */
|
/* Overflow divider */
|
||||||
.overflow-divider {
|
.overflow-divider {
|
||||||
width: 3.75rem;
|
width: 3rem;
|
||||||
border-color: var(--color-gray-300);
|
border-color: var(--color-gray-300);
|
||||||
margin: 0 0.5rem;
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
@ -143,7 +157,7 @@
|
|||||||
|
|
||||||
/* Content divider */
|
/* Content divider */
|
||||||
.content-divider {
|
.content-divider {
|
||||||
width: 3.75rem;
|
width: 3rem;
|
||||||
border-color: var(--color-gray-300);
|
border-color: var(--color-gray-300);
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
@ -241,7 +255,7 @@
|
|||||||
|
|
||||||
/* Divider that animates growing from top */
|
/* Divider that animates growing from top */
|
||||||
.current-tool-divider {
|
.current-tool-divider {
|
||||||
width: 3.75rem;
|
width: 3rem;
|
||||||
border-color: var(--color-gray-300);
|
border-color: var(--color-gray-300);
|
||||||
margin: 0.5rem auto 0.5rem auto;
|
margin: 0.5rem auto 0.5rem auto;
|
||||||
transform-origin: top;
|
transform-origin: top;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { Box, Stack } from "@mantine/core";
|
import { Box, Stack } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||||
@ -8,11 +8,10 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
|||||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||||
import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
|
import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
|
||||||
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
|
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
|
||||||
import Badge from "@app/components/shared/Badge";
|
|
||||||
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
|
|
||||||
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
|
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { ToolId } from "@app/types/toolId";
|
import { ToolId } from "@app/types/toolId";
|
||||||
|
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
|
||||||
|
|
||||||
interface ToolPickerProps {
|
interface ToolPickerProps {
|
||||||
selectedToolKey: string | null;
|
selectedToolKey: string | null;
|
||||||
@ -23,49 +22,8 @@ interface ToolPickerProps {
|
|||||||
|
|
||||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
|
|
||||||
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
|
|
||||||
|
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
const quickHeaderRef = useRef<HTMLDivElement>(null);
|
|
||||||
const allHeaderRef = useRef<HTMLDivElement>(null);
|
|
||||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
|
||||||
const allToolsRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Keep header heights in sync with any dynamic size changes
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const update = () => {
|
|
||||||
if (quickHeaderRef.current) {
|
|
||||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
|
|
||||||
}
|
|
||||||
if (allHeaderRef.current) {
|
|
||||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
update();
|
|
||||||
|
|
||||||
// Update on window resize
|
|
||||||
window.addEventListener("resize", update);
|
|
||||||
|
|
||||||
// Update on element resize (e.g., font load, badge count change, zoom)
|
|
||||||
const observers: ResizeObserver[] = [];
|
|
||||||
if (typeof ResizeObserver !== "undefined") {
|
|
||||||
const observe = (el: HTMLDivElement | null, cb: () => void) => {
|
|
||||||
if (!el) return;
|
|
||||||
const ro = new ResizeObserver(() => cb());
|
|
||||||
ro.observe(el);
|
|
||||||
observers.push(ro);
|
|
||||||
};
|
|
||||||
observe(quickHeaderRef.current, update);
|
|
||||||
observe(allHeaderRef.current, update);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", update);
|
|
||||||
observers.forEach(o => o.disconnect());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||||
const { favoriteTools, toolRegistry } = useToolWorkflow();
|
const { favoriteTools, toolRegistry } = useToolWorkflow();
|
||||||
@ -84,31 +42,25 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
return items;
|
return items;
|
||||||
}, [quickSection]);
|
}, [quickSection]);
|
||||||
|
|
||||||
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
|
|
||||||
const allSection = useMemo(
|
const allSection = useMemo(
|
||||||
() => visibleSections.find(s => s.key === 'all'),
|
() => visibleSections.find(s => s.key === 'all'),
|
||||||
[visibleSections]
|
[visibleSections]
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
|
|
||||||
const container = scrollableRef.current;
|
|
||||||
const target = ref.current;
|
|
||||||
if (container && target) {
|
|
||||||
const stackedOffset = ref === allToolsRef
|
|
||||||
? (quickHeaderHeight + allHeaderHeight)
|
|
||||||
: quickHeaderHeight;
|
|
||||||
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
|
|
||||||
container.scrollTo({
|
|
||||||
top: Math.max(0, top),
|
|
||||||
behavior: "smooth"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build flat list by subcategory for search mode
|
// Build flat list by subcategory for search mode
|
||||||
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
|
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
|
||||||
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
|
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
|
||||||
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
||||||
|
const headerTextStyle: React.CSSProperties = {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "0.5rem 0 0.25rem 0.5rem",
|
||||||
|
textTransform: "none",
|
||||||
|
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
|
||||||
|
opacity: 0.7
|
||||||
|
};
|
||||||
|
const toTitleCase = (s: string) =>
|
||||||
|
s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@ -141,110 +93,55 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{quickSection && (
|
{/* Flat list: favorites and recommended first, then all subcategories */}
|
||||||
<>
|
<Stack p="sm" gap="xs">
|
||||||
<div
|
{favoriteToolItems.length > 0 && (
|
||||||
ref={quickHeaderRef}
|
<Box w="100%">
|
||||||
style={{
|
<div style={headerTextStyle}>
|
||||||
position: "sticky",
|
{t('toolPanel.fullscreen.favorites', 'Favourites')}
|
||||||
top: 0,
|
</div>
|
||||||
zIndex: 2,
|
<div>
|
||||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
{favoriteToolItems.map(({ id, tool }) => (
|
||||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
<ToolButton
|
||||||
padding: "0.5rem 1rem",
|
key={`fav-${id}`}
|
||||||
fontWeight: 600,
|
id={id}
|
||||||
background: "var(--tool-header-bg)",
|
tool={tool}
|
||||||
color: "var(--tool-header-text)",
|
isSelected={selectedToolKey === id}
|
||||||
cursor: "pointer",
|
onSelect={onSelect}
|
||||||
display: "flex",
|
hasStars
|
||||||
alignItems: "center",
|
/>
|
||||||
justifyContent: "space-between",
|
))}
|
||||||
}}
|
</div>
|
||||||
onClick={() => scrollTo(quickAccessRef)}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
|
|
||||||
<Badge>
|
|
||||||
{recommendedCount}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Box ref={quickAccessRef} w="100%" my="sm">
|
|
||||||
<Stack p="sm" gap="xs">
|
|
||||||
{favoriteToolItems.length > 0 && (
|
|
||||||
<Box w="100%">
|
|
||||||
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
|
|
||||||
<div>
|
|
||||||
{favoriteToolItems.map(({ id, tool }) => (
|
|
||||||
<ToolButton
|
|
||||||
key={`fav-${id}`}
|
|
||||||
id={id}
|
|
||||||
tool={tool}
|
|
||||||
isSelected={selectedToolKey === id}
|
|
||||||
onSelect={onSelect}
|
|
||||||
hasStars
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{recommendedItems.length > 0 && (
|
|
||||||
<Box w="100%">
|
|
||||||
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
|
|
||||||
<div>
|
|
||||||
{recommendedItems.map(({ id, tool }) => (
|
|
||||||
<ToolButton
|
|
||||||
key={`rec-${id}`}
|
|
||||||
id={id as ToolId}
|
|
||||||
tool={tool}
|
|
||||||
isSelected={selectedToolKey === id}
|
|
||||||
onSelect={onSelect}
|
|
||||||
hasStars
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
)}
|
||||||
)}
|
{recommendedItems.length > 0 && (
|
||||||
|
<Box w="100%">
|
||||||
{allSection && (
|
<div style={headerTextStyle}>
|
||||||
<>
|
{t('toolPanel.fullscreen.recommended', 'Recommended')}
|
||||||
<div
|
</div>
|
||||||
ref={allHeaderRef}
|
<div>
|
||||||
style={{
|
{recommendedItems.map(({ id, tool }) => (
|
||||||
position: "sticky",
|
<ToolButton
|
||||||
top: quickSection ? quickHeaderHeight -1 : 0,
|
key={`rec-${id}`}
|
||||||
zIndex: 2,
|
id={id as ToolId}
|
||||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
tool={tool}
|
||||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
isSelected={selectedToolKey === id}
|
||||||
padding: "0.5rem 1rem",
|
onSelect={onSelect}
|
||||||
fontWeight: 600,
|
hasStars
|
||||||
background: "var(--tool-header-bg)",
|
/>
|
||||||
color: "var(--tool-header-text)",
|
))}
|
||||||
cursor: "pointer",
|
</div>
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between"
|
|
||||||
}}
|
|
||||||
onClick={() => scrollTo(allToolsRef)}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
|
|
||||||
<Badge>
|
|
||||||
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Box ref={allToolsRef} w="100%">
|
|
||||||
<Stack p="sm" gap="xs">
|
|
||||||
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
|
|
||||||
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
)}
|
||||||
)}
|
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
|
||||||
|
<Box key={sc.subcategoryId} w="100%">
|
||||||
|
<div style={headerTextStyle}>
|
||||||
|
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
|
||||||
|
</div>
|
||||||
|
{renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{!quickSection && !allSection && <NoToolsFound />}
|
{!quickSection && !allSection && <NoToolsFound />}
|
||||||
|
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export default function ToolSelector({
|
|||||||
// If no sections, create a simple group from filtered tools
|
// If no sections, create a simple group from filtered tools
|
||||||
if (baseFilteredTools.length > 0) {
|
if (baseFilteredTools.length > 0) {
|
||||||
return [{
|
return [{
|
||||||
name: 'All Tools',
|
name: 'Tools',
|
||||||
subcategoryId: 'all' as any,
|
subcategoryId: 'all' as any,
|
||||||
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
|
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
|
||||||
}];
|
}];
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
const restoreWorkbenchState = useCallback(async () => {
|
const restoreWorkbenchState = useCallback(async () => {
|
||||||
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
|
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
|
||||||
|
|
||||||
// Go back to All Tools
|
// Go back to Tools
|
||||||
handleBackToTools();
|
handleBackToTools();
|
||||||
|
|
||||||
// Clear all files (including tour sample)
|
// Clear all files (including tour sample)
|
||||||
|
|||||||
@ -64,8 +64,12 @@ export function useToolSections(
|
|||||||
|
|
||||||
Object.entries(subs).forEach(([s, tools]) => {
|
Object.entries(subs).forEach(([s, tools]) => {
|
||||||
const subcategoryId = s as SubcategoryId;
|
const subcategoryId = s as SubcategoryId;
|
||||||
if (!all[subcategoryId]) all[subcategoryId] = [];
|
// Build the 'all' collection without duplicating recommended tools
|
||||||
all[subcategoryId].push(...tools);
|
// Recommended tools are shown in the Quick section only
|
||||||
|
if (categoryId !== ToolCategoryId.RECOMMENDED_TOOLS) {
|
||||||
|
if (!all[subcategoryId]) all[subcategoryId] = [];
|
||||||
|
all[subcategoryId].push(...tools);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) {
|
if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) {
|
||||||
|
|||||||
@ -218,7 +218,7 @@ export default function HomePage() {
|
|||||||
<div className="mobile-bottom-bar">
|
<div className="mobile-bottom-bar">
|
||||||
<button
|
<button
|
||||||
className="mobile-bottom-button"
|
className="mobile-bottom-button"
|
||||||
aria-label={t('quickAccess.allTools', 'All Tools')}
|
aria-label={t('quickAccess.allTools', 'Tools')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleBackToTools();
|
handleBackToTools();
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@ -227,7 +227,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppsIcon sx={{ fontSize: '1.5rem' }} />
|
<AppsIcon sx={{ fontSize: '1.5rem' }} />
|
||||||
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'All Tools')}</span>
|
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'Tools')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="mobile-bottom-button"
|
className="mobile-bottom-button"
|
||||||
|
|||||||
@ -0,0 +1,515 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
TextInput,
|
||||||
|
Select,
|
||||||
|
Paper,
|
||||||
|
Checkbox,
|
||||||
|
Textarea,
|
||||||
|
SegmentedControl,
|
||||||
|
Tooltip,
|
||||||
|
CloseButton,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
import { alert } from '@app/components/toast';
|
||||||
|
import { userManagementService } from '@app/services/userManagementService';
|
||||||
|
import { teamService, Team } from '@app/services/teamService';
|
||||||
|
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
|
||||||
|
interface InviteMembersModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
|
||||||
|
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// License information
|
||||||
|
const [licenseInfo, setLicenseInfo] = useState<{
|
||||||
|
maxAllowedUsers: number;
|
||||||
|
availableSlots: number;
|
||||||
|
grandfatheredUserCount: number;
|
||||||
|
licenseMaxUsers: number;
|
||||||
|
premiumEnabled: boolean;
|
||||||
|
totalUsers: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Form state for direct invite
|
||||||
|
const [inviteForm, setInviteForm] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined as number | undefined,
|
||||||
|
forceChange: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form state for email invite
|
||||||
|
const [emailInviteForm, setEmailInviteForm] = useState({
|
||||||
|
emails: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined as number | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form state for invite link
|
||||||
|
const [inviteLinkForm, setInviteLinkForm] = useState({
|
||||||
|
email: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined as number | undefined,
|
||||||
|
expiryHours: 72,
|
||||||
|
sendEmail: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch teams and license info
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [adminData, teamsData] = await Promise.all([
|
||||||
|
userManagementService.getUsers(),
|
||||||
|
teamService.getTeams(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTeams(teamsData);
|
||||||
|
|
||||||
|
setLicenseInfo({
|
||||||
|
maxAllowedUsers: adminData.maxAllowedUsers,
|
||||||
|
availableSlots: adminData.availableSlots,
|
||||||
|
grandfatheredUserCount: adminData.grandfatheredUserCount,
|
||||||
|
licenseMaxUsers: adminData.licenseMaxUsers,
|
||||||
|
premiumEnabled: adminData.premiumEnabled,
|
||||||
|
totalUsers: adminData.totalUsers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{
|
||||||
|
value: 'ROLE_USER',
|
||||||
|
label: t('workspace.people.roleDescriptions.user', 'User'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ROLE_ADMIN',
|
||||||
|
label: t('workspace.people.roleDescriptions.admin', 'Admin'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const teamOptions = teams.map((team) => ({
|
||||||
|
value: team.id.toString(),
|
||||||
|
label: team.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleInviteUser = async () => {
|
||||||
|
if (!inviteForm.username || !inviteForm.password) {
|
||||||
|
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessing(true);
|
||||||
|
await userManagementService.createUser({
|
||||||
|
username: inviteForm.username,
|
||||||
|
password: inviteForm.password,
|
||||||
|
role: inviteForm.role,
|
||||||
|
teamId: inviteForm.teamId,
|
||||||
|
authType: 'password',
|
||||||
|
forceChange: inviteForm.forceChange,
|
||||||
|
});
|
||||||
|
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
|
||||||
|
onClose();
|
||||||
|
// Reset form
|
||||||
|
setInviteForm({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined,
|
||||||
|
forceChange: false,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to invite user:', error);
|
||||||
|
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.addMember.error');
|
||||||
|
alert({ alertType: 'error', title: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailInvite = async () => {
|
||||||
|
if (!emailInviteForm.emails.trim()) {
|
||||||
|
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'Email addresses are required') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessing(true);
|
||||||
|
const response = await userManagementService.inviteUsers({
|
||||||
|
emails: emailInviteForm.emails, // comma-separated string as required by API
|
||||||
|
role: emailInviteForm.role,
|
||||||
|
teamId: emailInviteForm.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.successCount > 0) {
|
||||||
|
alert({
|
||||||
|
alertType: 'success',
|
||||||
|
title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` })
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
setEmailInviteForm({
|
||||||
|
emails: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert({
|
||||||
|
alertType: 'error',
|
||||||
|
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
|
||||||
|
body: response.errors || response.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to invite users:', error);
|
||||||
|
const errorMessage = error.response?.data?.message ||
|
||||||
|
error.response?.data?.error ||
|
||||||
|
error.message ||
|
||||||
|
t('workspace.people.emailInvite.error', 'Failed to send invites');
|
||||||
|
alert({ alertType: 'error', title: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateInviteLink = async () => {
|
||||||
|
try {
|
||||||
|
setProcessing(true);
|
||||||
|
const response = await userManagementService.generateInviteLink({
|
||||||
|
email: inviteLinkForm.email || undefined,
|
||||||
|
role: inviteLinkForm.role,
|
||||||
|
teamId: inviteLinkForm.teamId,
|
||||||
|
expiryHours: inviteLinkForm.expiryHours,
|
||||||
|
sendEmail: inviteLinkForm.sendEmail,
|
||||||
|
});
|
||||||
|
setGeneratedInviteLink(response.inviteUrl);
|
||||||
|
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
|
||||||
|
alert({ alertType: 'success', title: t('workspace.people.inviteLink.emailSent', 'Invite link generated and sent via email') });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to generate invite link:', error);
|
||||||
|
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.inviteLink.error', 'Failed to generate invite link');
|
||||||
|
alert({ alertType: 'error', title: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setGeneratedInviteLink(null);
|
||||||
|
setInviteMode('direct');
|
||||||
|
setInviteForm({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined,
|
||||||
|
forceChange: false,
|
||||||
|
});
|
||||||
|
setEmailInviteForm({
|
||||||
|
emails: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined,
|
||||||
|
});
|
||||||
|
setInviteLinkForm({
|
||||||
|
email: '',
|
||||||
|
role: 'ROLE_USER',
|
||||||
|
teamId: undefined,
|
||||||
|
expiryHours: 72,
|
||||||
|
sendEmail: false,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={handleClose}
|
||||||
|
size="md"
|
||||||
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||||
|
centered
|
||||||
|
padding="xl"
|
||||||
|
withCloseButton={false}
|
||||||
|
>
|
||||||
|
<Box pos="relative">
|
||||||
|
<CloseButton
|
||||||
|
onClick={handleClose}
|
||||||
|
size="lg"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack gap="lg" pt="md">
|
||||||
|
{/* Header with Icon */}
|
||||||
|
<Stack gap="md" align="center">
|
||||||
|
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||||
|
<Text size="xl" fw={600} ta="center">
|
||||||
|
{t('workspace.people.inviteMembers.label', 'Invite Members')}
|
||||||
|
</Text>
|
||||||
|
{inviteMode === 'email' && (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" px="md">
|
||||||
|
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* License Warning/Info */}
|
||||||
|
{licenseInfo && (
|
||||||
|
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="xs">
|
||||||
|
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{licenseInfo.availableSlots > 0
|
||||||
|
? t('workspace.people.license.slotsAvailable', {
|
||||||
|
count: licenseInfo.availableSlots,
|
||||||
|
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
|
||||||
|
})
|
||||||
|
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('workspace.people.license.currentUsage', {
|
||||||
|
current: licenseInfo.totalUsers,
|
||||||
|
max: licenseInfo.maxAllowedUsers,
|
||||||
|
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<Tooltip
|
||||||
|
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
|
||||||
|
disabled={!!config?.enableEmailInvites}
|
||||||
|
position="bottom"
|
||||||
|
withArrow
|
||||||
|
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={inviteMode}
|
||||||
|
onChange={(value) => {
|
||||||
|
setInviteMode(value as 'email' | 'direct' | 'link');
|
||||||
|
setGeneratedInviteLink(null);
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: t('workspace.people.inviteMode.username', 'Username'),
|
||||||
|
value: 'direct',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('workspace.people.inviteMode.link', 'Link'),
|
||||||
|
value: 'link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('workspace.people.inviteMode.email', 'Email'),
|
||||||
|
value: 'email',
|
||||||
|
disabled: !config?.enableEmailInvites,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Link Mode */}
|
||||||
|
{inviteMode === 'link' && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
|
||||||
|
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
|
||||||
|
value={inviteLinkForm.email}
|
||||||
|
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
|
||||||
|
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('workspace.people.addMember.role')}
|
||||||
|
data={roleOptions}
|
||||||
|
value={inviteLinkForm.role}
|
||||||
|
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
|
||||||
|
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('workspace.people.addMember.team')}
|
||||||
|
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
||||||
|
data={teamOptions}
|
||||||
|
value={inviteLinkForm.teamId?.toString()}
|
||||||
|
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
|
||||||
|
clearable
|
||||||
|
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
|
||||||
|
type="number"
|
||||||
|
value={inviteLinkForm.expiryHours}
|
||||||
|
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
|
||||||
|
min={1}
|
||||||
|
max={720}
|
||||||
|
/>
|
||||||
|
{inviteLinkForm.email && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
|
||||||
|
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
|
||||||
|
checked={inviteLinkForm.sendEmail}
|
||||||
|
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display generated link */}
|
||||||
|
{generatedInviteLink && (
|
||||||
|
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<TextInput
|
||||||
|
value={generatedInviteLink}
|
||||||
|
readOnly
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(generatedInviteLink);
|
||||||
|
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
|
||||||
|
} catch {
|
||||||
|
// Fallback for browsers without clipboard API
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = generatedInviteLink;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Mode */}
|
||||||
|
{inviteMode === 'email' && config?.enableEmailInvites && (
|
||||||
|
<>
|
||||||
|
<Textarea
|
||||||
|
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
|
||||||
|
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
|
||||||
|
value={emailInviteForm.emails}
|
||||||
|
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
|
||||||
|
minRows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('workspace.people.addMember.role')}
|
||||||
|
data={roleOptions}
|
||||||
|
value={emailInviteForm.role}
|
||||||
|
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
|
||||||
|
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('workspace.people.addMember.team')}
|
||||||
|
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
||||||
|
data={teamOptions}
|
||||||
|
value={emailInviteForm.teamId?.toString()}
|
||||||
|
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
|
||||||
|
clearable
|
||||||
|
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Direct/Username Mode */}
|
||||||
|
{inviteMode === 'direct' && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={t('workspace.people.addMember.username')}
|
||||||
|
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
|
||||||
|
value={inviteForm.username}
|
||||||
|
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t('workspace.people.addMember.password')}
|
||||||
|
type="password"
|
||||||
|
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
|
||||||
|
value={inviteForm.password}
|
||||||
|
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('workspace.people.addMember.role')}
|
||||||
|
data={roleOptions}
|
||||||
|
value={inviteForm.role}
|
||||||
|
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
|
||||||
|
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={t('workspace.people.addMember.team')}
|
||||||
|
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
||||||
|
data={teamOptions}
|
||||||
|
value={inviteForm.teamId?.toString()}
|
||||||
|
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
|
||||||
|
clearable
|
||||||
|
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
|
||||||
|
checked={inviteForm.forceChange}
|
||||||
|
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
|
||||||
|
loading={processing}
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
|
{inviteMode === 'email'
|
||||||
|
? t('workspace.people.emailInvite.submit', 'Send Invites')
|
||||||
|
: inviteMode === 'link'
|
||||||
|
? t('workspace.people.inviteLink.submit', 'Generate Link')
|
||||||
|
: t('workspace.people.addMember.submit')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -13,10 +13,6 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Paper,
|
|
||||||
Checkbox,
|
|
||||||
Textarea,
|
|
||||||
SegmentedControl,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
CloseButton,
|
CloseButton,
|
||||||
Avatar,
|
Avatar,
|
||||||
@ -28,6 +24,7 @@ import { userManagementService, User } from '@app/services/userManagementService
|
|||||||
import { teamService, Team } from '@app/services/teamService';
|
import { teamService, Team } from '@app/services/teamService';
|
||||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
|
|
||||||
@ -43,8 +40,6 @@ export default function PeopleSection() {
|
|||||||
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
|
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
|
|
||||||
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// License information
|
// License information
|
||||||
const [licenseInfo, setLicenseInfo] = useState<{
|
const [licenseInfo, setLicenseInfo] = useState<{
|
||||||
@ -56,31 +51,6 @@ export default function PeopleSection() {
|
|||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Form state for direct invite
|
|
||||||
const [inviteForm, setInviteForm] = useState({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
role: 'ROLE_USER',
|
|
||||||
teamId: undefined as number | undefined,
|
|
||||||
forceChange: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form state for email invite
|
|
||||||
const [emailInviteForm, setEmailInviteForm] = useState({
|
|
||||||
emails: '',
|
|
||||||
role: 'ROLE_USER',
|
|
||||||
teamId: undefined as number | undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form state for invite link
|
|
||||||
const [inviteLinkForm, setInviteLinkForm] = useState({
|
|
||||||
email: '',
|
|
||||||
role: 'ROLE_USER',
|
|
||||||
teamId: undefined as number | undefined,
|
|
||||||
expiryHours: 72,
|
|
||||||
sendEmail: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form state for edit user modal
|
// Form state for edit user modal
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
role: 'ROLE_USER',
|
role: 'ROLE_USER',
|
||||||
@ -205,143 +175,6 @@ export default function PeopleSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInviteUser = async () => {
|
|
||||||
if (!inviteForm.username || !inviteForm.password) {
|
|
||||||
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setProcessing(true);
|
|
||||||
await userManagementService.createUser({
|
|
||||||
username: inviteForm.username,
|
|
||||||
password: inviteForm.password,
|
|
||||||
role: inviteForm.role,
|
|
||||||
teamId: inviteForm.teamId,
|
|
||||||
authType: 'password',
|
|
||||||
forceChange: inviteForm.forceChange,
|
|
||||||
});
|
|
||||||
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
|
|
||||||
setInviteModalOpened(false);
|
|
||||||
setInviteForm({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
role: 'ROLE_USER',
|
|
||||||
teamId: undefined,
|
|
||||||
forceChange: false,
|
|
||||||
});
|
|
||||||
fetchData();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to create user:', error);
|
|
||||||
const errorMessage = error.response?.data?.message ||
|
|
||||||
error.response?.data?.error ||
|
|
||||||
error.message ||
|
|
||||||
t('workspace.people.addMember.error');
|
|
||||||
alert({ alertType: 'error', title: errorMessage });
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmailInvite = async () => {
|
|
||||||
if (!emailInviteForm.emails.trim()) {
|
|
||||||
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'At least one email address is required') });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setProcessing(true);
|
|
||||||
const response = await userManagementService.inviteUsers({
|
|
||||||
emails: emailInviteForm.emails,
|
|
||||||
role: emailInviteForm.role,
|
|
||||||
teamId: emailInviteForm.teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.successCount > 0) {
|
|
||||||
alert({
|
|
||||||
alertType: 'success',
|
|
||||||
title: t('workspace.people.emailInvite.success', `${response.successCount} user(s) invited successfully`)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.failureCount > 0 && response.errors) {
|
|
||||||
alert({
|
|
||||||
alertType: 'warning',
|
|
||||||
title: t('workspace.people.emailInvite.partialSuccess', 'Some invites failed'),
|
|
||||||
body: response.errors
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setInviteModalOpened(false);
|
|
||||||
setEmailInviteForm({
|
|
||||||
emails: '',
|
|
||||||
role: 'ROLE_USER',
|
|
||||||
teamId: undefined,
|
|
||||||
});
|
|
||||||
fetchData();
|
|
||||||
} else {
|
|
||||||
alert({
|
|
||||||
alertType: 'error',
|
|
||||||
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
|
|
||||||
body: response.errors || response.error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to invite users:', error);
|
|
||||||
const errorMessage = error.response?.data?.message ||
|
|
||||||
error.response?.data?.error ||
|
|
||||||
error.message ||
|
|
||||||
t('workspace.people.emailInvite.error', 'Failed to send invites');
|
|
||||||
alert({ alertType: 'error', title: errorMessage });
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateInviteLink = async () => {
|
|
||||||
try {
|
|
||||||
setProcessing(true);
|
|
||||||
const response = await userManagementService.generateInviteLink({
|
|
||||||
email: inviteLinkForm.email,
|
|
||||||
role: inviteLinkForm.role,
|
|
||||||
teamId: inviteLinkForm.teamId,
|
|
||||||
expiryHours: inviteLinkForm.expiryHours,
|
|
||||||
sendEmail: inviteLinkForm.sendEmail,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Construct full frontend URL
|
|
||||||
const frontendUrl = `${window.location.origin}/invite/${response.token}`;
|
|
||||||
setGeneratedInviteLink(frontendUrl);
|
|
||||||
|
|
||||||
if (response.emailSent) {
|
|
||||||
alert({
|
|
||||||
alertType: 'success',
|
|
||||||
title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and email sent!')
|
|
||||||
});
|
|
||||||
} else if (inviteLinkForm.sendEmail && response.emailError) {
|
|
||||||
// Email was requested but failed
|
|
||||||
alert({
|
|
||||||
alertType: 'warning',
|
|
||||||
title: t('workspace.people.inviteLink.emailFailed', 'Invite link generated, but email failed'),
|
|
||||||
body: t('workspace.people.inviteLink.emailFailedDetails', 'Error: {0}. Please share the invite link manually.').replace('{0}', response.emailError)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert({
|
|
||||||
alertType: 'success',
|
|
||||||
title: t('workspace.people.inviteLink.success', 'Invite link generated successfully!')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to generate invite link:', error);
|
|
||||||
const errorMessage = error.response?.data?.message ||
|
|
||||||
error.response?.data?.error ||
|
|
||||||
error.message ||
|
|
||||||
t('workspace.people.inviteLink.error', 'Failed to generate invite link');
|
|
||||||
alert({ alertType: 'error', title: errorMessage });
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateUserRole = async () => {
|
const handleUpdateUserRole = async () => {
|
||||||
if (!selectedUser) return;
|
if (!selectedUser) return;
|
||||||
|
|
||||||
@ -420,18 +253,6 @@ export default function PeopleSection() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeInviteModal = () => {
|
|
||||||
setInviteModalOpened(false);
|
|
||||||
setGeneratedInviteLink(null);
|
|
||||||
setInviteLinkForm({
|
|
||||||
email: '',
|
|
||||||
role: 'ROLE_USER',
|
|
||||||
teamId: undefined,
|
|
||||||
expiryHours: 72,
|
|
||||||
sendEmail: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredUsers = users.filter((user) =>
|
const filteredUsers = users.filter((user) =>
|
||||||
user.username.toLowerCase().includes(searchQuery.toLowerCase())
|
user.username.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
@ -720,277 +541,11 @@ export default function PeopleSection() {
|
|||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* Add Member Modal */}
|
{/* Invite Members Modal (reusable) */}
|
||||||
<Modal
|
<InviteMembersModal
|
||||||
opened={inviteModalOpened}
|
opened={inviteModalOpened}
|
||||||
onClose={closeInviteModal}
|
onClose={() => setInviteModalOpened(false)}
|
||||||
size="md"
|
/>
|
||||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
|
||||||
centered
|
|
||||||
padding="xl"
|
|
||||||
withCloseButton={false}
|
|
||||||
>
|
|
||||||
<Box pos="relative">
|
|
||||||
<CloseButton
|
|
||||||
onClick={closeInviteModal}
|
|
||||||
size="lg"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -8,
|
|
||||||
right: -8,
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack gap="lg" pt="md">
|
|
||||||
{/* Header with Icon */}
|
|
||||||
<Stack gap="md" align="center">
|
|
||||||
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
|
||||||
<Text size="xl" fw={600} ta="center">
|
|
||||||
{t('workspace.people.inviteMembers.label', 'Invite Members')}
|
|
||||||
</Text>
|
|
||||||
{inviteMode === 'email' && (
|
|
||||||
<Text size="sm" c="dimmed" ta="center" px="md">
|
|
||||||
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* License Warning/Info */}
|
|
||||||
{licenseInfo && (
|
|
||||||
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{licenseInfo.availableSlots > 0
|
|
||||||
? t('workspace.people.license.slotsAvailable', {
|
|
||||||
count: licenseInfo.availableSlots,
|
|
||||||
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
|
|
||||||
})
|
|
||||||
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('workspace.people.license.currentUsage', {
|
|
||||||
current: licenseInfo.totalUsers,
|
|
||||||
max: licenseInfo.maxAllowedUsers,
|
|
||||||
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mode Toggle */}
|
|
||||||
<Tooltip
|
|
||||||
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
|
|
||||||
disabled={!!config?.enableEmailInvites}
|
|
||||||
position="bottom"
|
|
||||||
withArrow
|
|
||||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<SegmentedControl
|
|
||||||
value={inviteMode}
|
|
||||||
onChange={(value) => {
|
|
||||||
setInviteMode(value as 'email' | 'direct' | 'link');
|
|
||||||
setGeneratedInviteLink(null);
|
|
||||||
}}
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
label: t('workspace.people.inviteMode.username', 'Username'),
|
|
||||||
value: 'direct',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('workspace.people.inviteMode.link', 'Link'),
|
|
||||||
value: 'link',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('workspace.people.inviteMode.email', 'Email'),
|
|
||||||
value: 'email',
|
|
||||||
disabled: !config?.enableEmailInvites,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Link Mode */}
|
|
||||||
{inviteMode === 'link' && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
|
|
||||||
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
|
|
||||||
value={inviteLinkForm.email}
|
|
||||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
|
|
||||||
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label={t('workspace.people.addMember.role')}
|
|
||||||
data={roleOptions}
|
|
||||||
value={inviteLinkForm.role}
|
|
||||||
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
|
|
||||||
renderOption={renderRoleOption}
|
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label={t('workspace.people.addMember.team')}
|
|
||||||
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
|
||||||
data={teamOptions}
|
|
||||||
value={inviteLinkForm.teamId?.toString()}
|
|
||||||
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
|
|
||||||
clearable
|
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
|
|
||||||
type="number"
|
|
||||||
value={inviteLinkForm.expiryHours}
|
|
||||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
|
|
||||||
min={1}
|
|
||||||
max={720}
|
|
||||||
/>
|
|
||||||
{inviteLinkForm.email && (
|
|
||||||
<Checkbox
|
|
||||||
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
|
|
||||||
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
|
|
||||||
checked={inviteLinkForm.sendEmail}
|
|
||||||
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Display generated link */}
|
|
||||||
{generatedInviteLink && (
|
|
||||||
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
<TextInput
|
|
||||||
value={generatedInviteLink}
|
|
||||||
readOnly
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(generatedInviteLink);
|
|
||||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
|
|
||||||
} catch {
|
|
||||||
// Fallback for browsers without clipboard API
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = generatedInviteLink;
|
|
||||||
textArea.style.position = 'fixed';
|
|
||||||
textArea.style.opacity = '0';
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Email Mode */}
|
|
||||||
{inviteMode === 'email' && config?.enableEmailInvites && (
|
|
||||||
<>
|
|
||||||
<Textarea
|
|
||||||
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
|
|
||||||
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
|
|
||||||
value={emailInviteForm.emails}
|
|
||||||
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
|
|
||||||
minRows={3}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label={t('workspace.people.addMember.role')}
|
|
||||||
data={roleOptions}
|
|
||||||
value={emailInviteForm.role}
|
|
||||||
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
|
|
||||||
renderOption={renderRoleOption}
|
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label={t('workspace.people.addMember.team')}
|
|
||||||
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
|
||||||
data={teamOptions}
|
|
||||||
value={emailInviteForm.teamId?.toString()}
|
|
||||||
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
|
|
||||||
clearable
|
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Direct/Username Mode */}
|
|
||||||
{inviteMode === 'direct' && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label={t('workspace.people.addMember.username')}
|
|
||||||
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
|
|
||||||
value={inviteForm.username}
|
|
||||||
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label={t('workspace.people.addMember.password')}
|
|
||||||
type="password"
|
|
||||||
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
|
|
||||||
value={inviteForm.password}
|
|
||||||
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label={t('workspace.people.addMember.role')}
|
|
||||||
data={roleOptions}
|
|
||||||
value={inviteForm.role}
|
|
||||||
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
|
|
||||||
renderOption={renderRoleOption}
|
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label={t('workspace.people.addMember.team')}
|
|
||||||
placeholder={t('workspace.people.addMember.teamPlaceholder')}
|
|
||||||
data={teamOptions}
|
|
||||||
value={inviteForm.teamId?.toString()}
|
|
||||||
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
|
|
||||||
clearable
|
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
|
|
||||||
checked={inviteForm.forceChange}
|
|
||||||
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<Button
|
|
||||||
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
|
|
||||||
loading={processing}
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
mt="md"
|
|
||||||
>
|
|
||||||
{inviteMode === 'email'
|
|
||||||
? t('workspace.people.emailInvite.submit', 'Send Invites')
|
|
||||||
: inviteMode === 'link'
|
|
||||||
? t('workspace.people.inviteLink.submit', 'Generate Link')
|
|
||||||
: t('workspace.people.addMember.submit')}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Edit User Modal */}
|
{/* Edit User Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export interface InviteUsersResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InviteLinkRequest {
|
export interface InviteLinkRequest {
|
||||||
email: string;
|
email?: string;
|
||||||
role: string;
|
role: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
expiryHours?: number;
|
expiryHours?: number;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user