mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
photo scan V2 (#5255)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
@@ -13,15 +17,44 @@ import "@app/styles/index.css";
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
{children}
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
<Routes>
|
||||
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import PhonelinkIcon from '@mui/icons-material/Phonelink';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
@@ -15,12 +17,13 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const UploadIcon = icons.upload;
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
@@ -33,6 +36,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileUploadClick = () => {
|
||||
setMobileUploadModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFilesReceivedFromMobile = (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
onNewFilesSelect(files);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
|
||||
@@ -105,24 +118,59 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<PhonelinkIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={handleMobileUploadClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (horizontal) {
|
||||
return (
|
||||
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||
{buttons}
|
||||
</Group>
|
||||
<>
|
||||
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||
{buttons}
|
||||
</Group>
|
||||
<MobileUploadModal
|
||||
opened={mobileUploadModalOpen}
|
||||
onClose={() => setMobileUploadModalOpen(false)}
|
||||
onFilesReceived={handleFilesReceivedFromMobile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||
{t('fileManager.myFiles', 'My Files')}
|
||||
</Text>
|
||||
{buttons}
|
||||
</Stack>
|
||||
<>
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||
{t('fileManager.myFiles', 'My Files')}
|
||||
</Text>
|
||||
{buttons}
|
||||
</Stack>
|
||||
<MobileUploadModal
|
||||
opened={mobileUploadModalOpen}
|
||||
onClose={() => setMobileUploadModalOpen(false)}
|
||||
onFilesReceived={handleFilesReceivedFromMobile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { Container, Button, Group, useMantineColorScheme, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -11,6 +11,9 @@ import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -24,8 +27,11 @@ const LandingPage = () => {
|
||||
const { wordmark } = useLogoAssets();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = React.useState(false);
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const { config } = useAppConfig();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addFiles(files);
|
||||
@@ -48,6 +54,16 @@ const LandingPage = () => {
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleMobileUploadClick = () => {
|
||||
setMobileUploadModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFilesReceivedFromMobile = async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine if the user has any recent files (same source as File Manager)
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -202,32 +218,72 @@ const LandingPage = () => {
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{config?.enableMobileScanner && !isMobile && (
|
||||
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
|
||||
<ActionIcon
|
||||
size={38}
|
||||
variant="subtle"
|
||||
onClick={handleMobileUploadClick}
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--accent-interactive)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
paddingLeft: '0.5rem',
|
||||
paddingRight: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!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' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: 'calc(100% - 38px - 0.6rem)',
|
||||
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' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
</Button>
|
||||
{config?.enableMobileScanner && !isMobile && (
|
||||
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
|
||||
<ActionIcon
|
||||
size={38}
|
||||
variant="subtle"
|
||||
onClick={handleMobileUploadClick}
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--accent-interactive)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
paddingLeft: '0.5rem',
|
||||
paddingRight: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -251,6 +307,11 @@ const LandingPage = () => {
|
||||
</span>
|
||||
</div>
|
||||
</Dropzone>
|
||||
<MobileUploadModal
|
||||
opened={mobileUploadModalOpen}
|
||||
onClose={() => setMobileUploadModalOpen(false)}
|
||||
onFilesReceived={handleFilesReceivedFromMobile}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
|
||||
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { withBasePath } from '@app/constants/app';
|
||||
|
||||
interface MobileUploadModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onFilesReceived: (files: File[]) => void;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
|
||||
// Set version (4) and variant bits per RFC 4122
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
|
||||
|
||||
// Convert bytes to hex string in UUID format
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
|
||||
return [
|
||||
hex.slice(0, 4).join(''),
|
||||
hex.slice(4, 6).join(''),
|
||||
hex.slice(6, 8).join(''),
|
||||
hex.slice(8, 10).join(''),
|
||||
hex.slice(10, 16).join(''),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MobileUploadModal
|
||||
*
|
||||
* 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) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
const [sessionId, setSessionId] = useState(() => generateSessionId());
|
||||
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||||
const [filesReceived, setFilesReceived] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
||||
const [showExpiryWarning, setShowExpiryWarning] = useState(false);
|
||||
const pollIntervalRef = useRef<number | null>(null);
|
||||
const timerIntervalRef = useRef<number | null>(null);
|
||||
const processedFiles = useRef<Set<string>>(new Set());
|
||||
|
||||
// Use configured frontendUrl if set, otherwise use current origin
|
||||
// Combine with base path and mobile-scanner route
|
||||
const frontendUrl = config?.frontendUrl || window.location.origin;
|
||||
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
|
||||
|
||||
// Create session on backend
|
||||
const createSession = useCallback(async (newSessionId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/mobile-scanner/create-session/${newSessionId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSessionInfo(data);
|
||||
setError(null);
|
||||
console.log('Session created:', data);
|
||||
} catch (err) {
|
||||
console.error('Failed to create session:', err);
|
||||
setError(t('mobileUpload.sessionCreateError', 'Failed to create session'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Regenerate session (when expired or warned)
|
||||
const regenerateSession = useCallback(() => {
|
||||
const newSessionId = generateSessionId();
|
||||
setSessionId(newSessionId);
|
||||
setShowExpiryWarning(false);
|
||||
setFilesReceived(0);
|
||||
processedFiles.current.clear();
|
||||
createSession(newSessionId);
|
||||
}, [createSession]);
|
||||
|
||||
const pollForFiles = useCallback(async () => {
|
||||
if (!opened) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check for files');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const files = data.files || [];
|
||||
|
||||
// Download only files we haven't processed yet
|
||||
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
for (const fileMetadata of newFiles) {
|
||||
try {
|
||||
const downloadResponse = await fetch(
|
||||
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`
|
||||
);
|
||||
|
||||
if (downloadResponse.ok) {
|
||||
const blob = await downloadResponse.blob();
|
||||
const file = new File([blob], fileMetadata.filename, {
|
||||
type: fileMetadata.contentType || 'image/jpeg'
|
||||
});
|
||||
|
||||
processedFiles.current.add(fileMetadata.filename);
|
||||
setFilesReceived((prev) => prev + 1);
|
||||
onFilesReceived([file]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', fileMetadata.filename, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the entire session immediately after downloading all files
|
||||
// This ensures files are only on server for ~1 second
|
||||
try {
|
||||
await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' });
|
||||
console.log('Session cleaned up after file download');
|
||||
} catch (cleanupErr) {
|
||||
console.warn('Failed to cleanup session after download:', cleanupErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error polling for files:', err);
|
||||
setError(t('mobileUpload.pollingError', 'Error checking for files'));
|
||||
}
|
||||
}, [opened, sessionId, onFilesReceived, t]);
|
||||
|
||||
// Create session when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
createSession(sessionId);
|
||||
setFilesReceived(0);
|
||||
setError(null);
|
||||
setShowExpiryWarning(false);
|
||||
processedFiles.current.clear();
|
||||
} else {
|
||||
// Clean up session when modal closes
|
||||
if (sessionId) {
|
||||
fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' })
|
||||
.catch(err => console.warn('Failed to cleanup session on close:', err));
|
||||
}
|
||||
}
|
||||
}, [opened]); // Only run when opened changes
|
||||
|
||||
// Start polling for files when modal opens
|
||||
useEffect(() => {
|
||||
if (opened && sessionInfo) {
|
||||
// Poll every 2 seconds
|
||||
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
|
||||
|
||||
// Initial poll
|
||||
pollForFiles();
|
||||
} else {
|
||||
// Stop polling when modal closes
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [opened, sessionInfo, pollForFiles]);
|
||||
|
||||
// Session timeout timer
|
||||
useEffect(() => {
|
||||
if (!opened || !sessionInfo) return;
|
||||
|
||||
const updateTimer = () => {
|
||||
const now = Date.now();
|
||||
const remaining = sessionInfo.expiresAt - now;
|
||||
|
||||
if (remaining <= 0) {
|
||||
// Session expired - regenerate
|
||||
setShowExpiryWarning(false);
|
||||
regenerateSession();
|
||||
} else if (remaining <= 60000 && !showExpiryWarning) {
|
||||
// Less than 1 minute remaining - show warning
|
||||
setShowExpiryWarning(true);
|
||||
}
|
||||
|
||||
setTimeRemaining(Math.max(0, remaining));
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateTimer();
|
||||
|
||||
// Update every second
|
||||
timerIntervalRef.current = window.setInterval(updateTimer, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerIntervalRef.current) {
|
||||
clearInterval(timerIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [opened, sessionInfo, showExpiryWarning, regenerateSession]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('mobileUpload.title', 'Upload from Mobile')}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
styles={{
|
||||
body: {
|
||||
paddingTop: '1.5rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'mobileUpload.description',
|
||||
'Scan this QR code with your mobile device to upload photos directly to this page.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{showExpiryWarning && timeRemaining !== null && (
|
||||
<Alert
|
||||
icon={<WarningRoundedIcon style={{ fontSize: '1rem' }} />}
|
||||
title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')}
|
||||
color="orange"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'mobileUpload.expiryWarningMessage',
|
||||
'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.',
|
||||
{ seconds: Math.ceil(timeRemaining / 1000) }
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
|
||||
title={t('mobileUpload.error', 'Connection Error')}
|
||||
color="red"
|
||||
>
|
||||
<Text size="sm">{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Box
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
|
||||
</Box>
|
||||
|
||||
{filesReceived > 0 && (
|
||||
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
|
||||
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
|
||||
{t(
|
||||
'mobileUpload.instructions',
|
||||
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
wordBreak: 'break-all',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{mobileUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -326,20 +326,22 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position="right"
|
||||
arrow
|
||||
offset={8}
|
||||
open={tooltipOpen}
|
||||
manualCloseOnly={manualCloseOnly}
|
||||
showCloseButton={showCloseButton}
|
||||
closeOnOutside={false}
|
||||
openOnFocus={false}
|
||||
content={toursTooltipContent}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
>
|
||||
{helpButtonNode}
|
||||
</Tooltip>
|
||||
<React.Fragment key={buttonConfig.id}>
|
||||
<Tooltip
|
||||
position="right"
|
||||
arrow
|
||||
offset={8}
|
||||
open={tooltipOpen}
|
||||
manualCloseOnly={manualCloseOnly}
|
||||
showCloseButton={showCloseButton}
|
||||
closeOnOutside={false}
|
||||
openOnFocus={false}
|
||||
content={toursTooltipContent}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
>
|
||||
{helpButtonNode}
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps)
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
|
||||
icon={<LocalIcon icon="lock" width={20} height={20} />}
|
||||
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
|
||||
@@ -193,7 +193,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
size="sm"
|
||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||
onClick={() => setUpdateModalOpened(true)}
|
||||
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
|
||||
leftSection={<LocalIcon icon="system-update-alt-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('settings.general.updates.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
|
||||
@@ -28,12 +28,12 @@ function getDefaultIconName(t: ToastInstance): string {
|
||||
case 'success':
|
||||
return 'check-circle-rounded';
|
||||
case 'error':
|
||||
return 'close-rounded';
|
||||
return 'cancel';
|
||||
case 'warning':
|
||||
return 'warning-rounded';
|
||||
return 'warning';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'info-rounded';
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function ToastRenderer() {
|
||||
{/* Icon */}
|
||||
<div className="toast-icon">
|
||||
{t.icon ?? (
|
||||
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
|
||||
<LocalIcon icon={getDefaultIconName(t)} width={20} height={20} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function ToastRenderer() {
|
||||
}}
|
||||
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
||||
<LocalIcon icon="expand-more-rounded" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -94,7 +94,7 @@ export default function ToastRenderer() {
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="toast-button"
|
||||
>
|
||||
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
||||
<LocalIcon icon="close" width={20} height={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
component="label"
|
||||
htmlFor="attachments-input"
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
|
||||
leftSection={<LocalIcon icon="add" width="14" height="14" />}
|
||||
>
|
||||
{parameters.attachments?.length > 0
|
||||
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
|
||||
|
||||
@@ -234,7 +234,7 @@ export const SavedSignaturesSection = ({
|
||||
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<LocalIcon icon="material-symbols:person-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="person-rounded" width={18} height={18} />
|
||||
<Text fw={600} size="sm">
|
||||
{translate('saved.personalHeading', 'Personal Signatures')}
|
||||
</Text>
|
||||
@@ -257,7 +257,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
|
||||
disabled={disabled || activePersonalIndex === 0}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
@@ -265,7 +265,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
|
||||
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -284,7 +284,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => onUseSignature(activePersonalSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||
<ActionIcon
|
||||
@@ -294,7 +294,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => onDeleteSignature(activePersonalSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -317,7 +317,7 @@ export const SavedSignaturesSection = ({
|
||||
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<LocalIcon icon="material-symbols:groups-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="groups-rounded" width={18} height={18} />
|
||||
<Text fw={600} size="sm">
|
||||
{translate('saved.sharedHeading', 'Shared Signatures')}
|
||||
</Text>
|
||||
@@ -340,7 +340,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
|
||||
disabled={disabled || activeSharedIndex === 0}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
@@ -348,7 +348,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
|
||||
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -367,7 +367,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => onUseSignature(activeSharedSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
{isAdmin && (
|
||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||
@@ -378,7 +378,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => onDeleteSignature(activeSharedSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -424,7 +424,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
|
||||
disabled={disabled || activeLocalStorageIndex === 0}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="chevron-left-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
@@ -432,7 +432,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
|
||||
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="chevron-right-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -451,7 +451,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => onUseSignature(activeLocalStorageSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="check-circle-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||
<ActionIcon
|
||||
@@ -461,7 +461,7 @@ export const SavedSignaturesSection = ({
|
||||
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||
<LocalIcon icon="delete-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -370,7 +370,7 @@ const SignSettings = ({
|
||||
isReady,
|
||||
onClick,
|
||||
'personal',
|
||||
'material-symbols:person-rounded',
|
||||
'person-rounded',
|
||||
translate('saved.savePersonal', 'Save Personal')
|
||||
);
|
||||
|
||||
@@ -379,7 +379,7 @@ const SignSettings = ({
|
||||
isReady,
|
||||
onClick,
|
||||
'shared',
|
||||
'material-symbols:groups-rounded',
|
||||
'groups-rounded',
|
||||
translate('saved.saveShared', 'Save Shared')
|
||||
);
|
||||
|
||||
@@ -488,7 +488,7 @@ const SignSettings = ({
|
||||
const handleCanvasSignatureChange = useCallback((data: string | null) => {
|
||||
const nextValue = data ?? undefined;
|
||||
setCanvasSignatureData(prevData => {
|
||||
// Reset pause state and trigger placement for signature changes
|
||||
// Reset pause-rounded state and trigger placement for signature changes
|
||||
// (onDrawingComplete handles initial activation)
|
||||
if (prevData && prevData !== nextValue && nextValue) {
|
||||
setPlacementManuallyPaused(false);
|
||||
@@ -899,7 +899,7 @@ const SignSettings = ({
|
||||
color: isPlacementMode ? 'blue' : 'teal',
|
||||
title: isPlacementMode
|
||||
? translate('instructions.title', 'How to add your signature')
|
||||
: translate('instructions.paused', 'Placement paused'),
|
||||
: translate('instructions.pause-roundedd', 'Placement pause-roundedd'),
|
||||
message: isPlacementMode
|
||||
? placementInstructions()
|
||||
: translate('instructions.resumeHint', 'Resume placement to click and add your signature.'),
|
||||
@@ -920,7 +920,7 @@ const SignSettings = ({
|
||||
onActivateSignaturePlacement?.();
|
||||
};
|
||||
|
||||
// Handle Escape key to toggle pause/resume
|
||||
// Handle Escape key to toggle pause-rounded/resume
|
||||
useEffect(() => {
|
||||
if (!isCurrentTypeReady) return;
|
||||
|
||||
@@ -943,11 +943,11 @@ const SignSettings = ({
|
||||
onActivateSignaturePlacement || onDeactivateSignature
|
||||
? isPlacementMode
|
||||
? (
|
||||
<Tooltip label={translate('mode.pause', 'Pause placement')}>
|
||||
<Tooltip label={translate('mode.pause-rounded', 'Pause placement')}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={translate('mode.pause', 'Pause placement')}
|
||||
aria-label={translate('mode.pause-rounded', 'Pause placement')}
|
||||
onClick={handlePausePlacement}
|
||||
disabled={disabled || !onDeactivateSignature}
|
||||
style={{
|
||||
@@ -958,9 +958,9 @@ const SignSettings = ({
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
|
||||
<LocalIcon icon="pause-rounded" width={20} height={20} />
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{translate('mode.pause', 'Pause placement')}
|
||||
{translate('mode.pause-rounded', 'Pause placement')}
|
||||
</Text>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -981,7 +981,7 @@ const SignSettings = ({
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
|
||||
<LocalIcon icon="play-arrow-rounded" width={20} height={20} />
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{translate('mode.resume', 'Resume placement')}
|
||||
</Text>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AppConfig {
|
||||
baseUrl?: string;
|
||||
contextPath?: string;
|
||||
serverPort?: number;
|
||||
frontendUrl?: string;
|
||||
appNameNavbar?: string;
|
||||
languages?: string[];
|
||||
defaultLocale?: string;
|
||||
@@ -43,6 +44,7 @@ export interface AppConfig {
|
||||
license?: string;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
enableMobileScanner?: boolean;
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
|
||||
@@ -224,7 +224,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
supportsAutomate: false, //TODO make support Sign
|
||||
},
|
||||
addText: {
|
||||
icon: <LocalIcon icon="material-symbols:text-fields-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="text-fields-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t('home.addText.title', 'Add Text'),
|
||||
component: AddText,
|
||||
description: t('home.addText.desc', 'Add custom text anywhere in your PDF'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1250,7 +1250,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:touch-app-rounded" width={20} height={20} />
|
||||
<LocalIcon icon="touch-app-rounded" width={20} height={20} />
|
||||
<Text component="span" size="sm" fw={500}>
|
||||
{t('annotation.selectAndMove', 'Select and Edit')}
|
||||
</Text>
|
||||
|
||||
Reference in New Issue
Block a user