mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
Merge branch 'V2' into feature/onboardingSlides
This commit is contained in:
@@ -6,6 +6,8 @@ import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
@@ -23,6 +25,8 @@ const AddFileCard = ({
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
|
||||
const handleCardClick = () => {
|
||||
openFilesModal();
|
||||
@@ -152,10 +156,10 @@ const AddFileCard = ({
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
@@ -166,7 +170,7 @@ const AddFileCard = ({
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
{terminology.dropFilesHere}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Sta
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
@@ -57,6 +58,9 @@ const FileEditorThumbnail = ({
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadOutlinedIcon = icons.download;
|
||||
const {
|
||||
pinFile,
|
||||
unpinFile,
|
||||
@@ -204,7 +208,7 @@ const FileEditorThumbnail = ({
|
||||
{
|
||||
id: 'download',
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
label: t('download', 'Download'),
|
||||
label: terminology.download,
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
|
||||
@@ -5,12 +5,16 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
|
||||
const EmptyFilesState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { onLocalFileClick } = useFileManagerContext();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
|
||||
const handleUploadClick = () => {
|
||||
onLocalFileClick();
|
||||
@@ -91,10 +95,10 @@ const EmptyFilesState: React.FC = () => {
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
@@ -105,7 +109,7 @@ const EmptyFilesState: React.FC = () => {
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
{terminology.dropFilesHere}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,16 @@ import React from "react";
|
||||
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
|
||||
const FileActions: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadIcon = icons.download;
|
||||
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
|
||||
useFileManagerContext();
|
||||
|
||||
@@ -95,7 +99,7 @@ const FileActions: React.FC = () => {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={t("fileManager.downloadSelected", "Download Selected")}>
|
||||
<Tooltip label={terminology.downloadSelected}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import UploadIcon from '@mui/icons-material/Upload';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
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';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
@@ -17,6 +18,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const UploadIcon = icons.upload;
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
@@ -76,7 +80,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{horizontal ? t('fileUpload.uploadFiles', 'Upload') : t('fileUpload.uploadFiles', 'Upload Files')}
|
||||
{horizontal ? terminology.upload : terminology.uploadFiles}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'
|
||||
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
@@ -13,6 +12,8 @@ import styles from '@app/components/pageEditor/PageEditor.module.css';
|
||||
import { useFileContext } from '@app/contexts/FileContext';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
@@ -51,6 +52,9 @@ const FileThumbnail = ({
|
||||
isSupported = true,
|
||||
}: FileThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadOutlinedIcon = icons.download;
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
|
||||
// ---- Drag state ----
|
||||
@@ -86,7 +90,7 @@ const FileThumbnail = ({
|
||||
}
|
||||
|
||||
// If we can't find a way to download, surface a status message
|
||||
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
|
||||
onSetStatus?.(terminology.downloadUnavailable);
|
||||
}, [file, onDownloadFile, onSetStatus, t]);
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
@@ -279,7 +283,7 @@ const FileThumbnail = ({
|
||||
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
<span>{terminology.download}</span>
|
||||
</button>
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
|
||||
interface FilePickerModalProps {
|
||||
opened: boolean;
|
||||
@@ -31,6 +32,7 @@ const FilePickerModal = ({
|
||||
onSelectFiles,
|
||||
}: FilePickerModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
|
||||
// Reset selection when modal opens
|
||||
@@ -130,7 +132,7 @@ const FilePickerModal = ({
|
||||
<Stack gap="md">
|
||||
{storedFiles.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")}
|
||||
{terminology.noFilesInStorage}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
@@ -253,7 +255,7 @@ const FilePickerModal = ({
|
||||
disabled={selectedFileIds.length === 0}
|
||||
>
|
||||
{selectedFileIds.length > 0
|
||||
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
|
||||
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${terminology.uploadFiles}`
|
||||
: t("fileUpload.loadFromStorage", "Load Files")
|
||||
}
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -19,6 +21,8 @@ const LandingPage = () => {
|
||||
const logoPath = useLogoPath();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addFiles(files);
|
||||
@@ -187,10 +191,10 @@ const LandingPage = () => {
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
@@ -239,7 +243,7 @@ const LandingPage = () => {
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
{terminology.dropFilesHere}
|
||||
</span>
|
||||
</div>
|
||||
</Dropzone>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useRightRail } from '@app/contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection } from '@app/contexts/FileContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
|
||||
import LanguageSelector from '@app/components/shared/LanguageSelector';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
@@ -44,6 +46,8 @@ export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
@@ -165,9 +169,9 @@ export default function RightRail() {
|
||||
return t('rightRail.exportAll', 'Export PDF');
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
return t('rightRail.downloadSelected', 'Download Selected Files');
|
||||
return terminology.downloadSelected;
|
||||
}
|
||||
return t('rightRail.downloadAll', 'Download All');
|
||||
return terminology.downloadAll;
|
||||
}, [currentView, selectedCount, t]);
|
||||
|
||||
return (
|
||||
@@ -232,7 +236,7 @@ export default function RightRail() {
|
||||
(currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
<LocalIcon icon={icons.downloadIconName} width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>,
|
||||
downloadTooltip,
|
||||
tooltipPosition,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||
import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
|
||||
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
|
||||
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
|
||||
export interface EditTableOfContentsWorkbenchViewData {
|
||||
bookmarks: BookmarkNode[];
|
||||
@@ -40,6 +41,7 @@ interface EditTableOfContentsWorkbenchViewProps {
|
||||
|
||||
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -180,7 +182,7 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
|
||||
download={downloadFilename ?? undefined}
|
||||
leftSection={<LocalIcon icon='download-rounded' />}
|
||||
>
|
||||
{t('download', 'Download')}
|
||||
{terminology.download}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import ErrorNotification from "@app/components/tools/shared/ErrorNotification";
|
||||
import ResultsPreview from "@app/components/tools/shared/ResultsPreview";
|
||||
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
|
||||
import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
|
||||
export interface ReviewToolStepProps<TParams = unknown> {
|
||||
isVisible: boolean;
|
||||
@@ -29,6 +30,9 @@ function ReviewStepContent<TParams = unknown>({
|
||||
onUndo?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadIcon = icons.download;
|
||||
const stepRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleUndo = async () => {
|
||||
@@ -96,7 +100,7 @@ function ReviewStepContent<TParams = unknown>({
|
||||
fullWidth
|
||||
mb="md"
|
||||
>
|
||||
{t("download", "Download")}
|
||||
{terminology.download}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
|
||||
import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
|
||||
import "@app/components/tools/showJS/ShowJSView.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
|
||||
import {
|
||||
tokenizeToLines,
|
||||
@@ -28,6 +29,7 @@ interface ShowJSViewProps {
|
||||
|
||||
const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const text = useMemo(() => {
|
||||
if (typeof data === "string") return data;
|
||||
return data?.scriptText ?? "";
|
||||
@@ -173,7 +175,7 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
|
||||
disabled={!downloadUrl}
|
||||
leftSection={<DownloadRoundedIcon fontSize="small" />}
|
||||
>
|
||||
{t("download", "Download")}
|
||||
{terminology.download}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface AppConfig {
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
dependenciesReady?: boolean;
|
||||
error?: string;
|
||||
isNewServer?: boolean;
|
||||
isNewUser?: boolean;
|
||||
|
||||
@@ -4,8 +4,6 @@ export function useBackendHealth(): BackendHealthState {
|
||||
return {
|
||||
status: 'healthy',
|
||||
message: null,
|
||||
isChecking: false,
|
||||
lastChecked: Date.now(),
|
||||
error: null,
|
||||
isHealthy: true,
|
||||
};
|
||||
|
||||
15
frontend/src/core/hooks/useFileActionIcons.ts
Normal file
15
frontend/src/core/hooks/useFileActionIcons.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import UploadIcon from '@mui/icons-material/Upload';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
|
||||
/**
|
||||
* File action icons for web builds
|
||||
* Desktop builds override this with different icons
|
||||
*/
|
||||
export function useFileActionIcons() {
|
||||
return {
|
||||
upload: UploadIcon,
|
||||
download: DownloadOutlinedIcon,
|
||||
uploadIconName: 'upload' as const,
|
||||
downloadIconName: 'download' as const,
|
||||
};
|
||||
}
|
||||
22
frontend/src/core/hooks/useFileActionTerminology.ts
Normal file
22
frontend/src/core/hooks/useFileActionTerminology.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* File action terminology for web builds
|
||||
* Desktop builds override this with different terminology
|
||||
*/
|
||||
export function useFileActionTerminology() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
uploadFiles: t('fileUpload.uploadFiles', 'Upload Files'),
|
||||
uploadFile: t('fileUpload.uploadFile', 'Upload File'),
|
||||
upload: t('fileUpload.upload', 'Upload'),
|
||||
dropFilesHere: t('fileUpload.dropFilesHere', 'Drop files here or click the upload button'),
|
||||
uploadFromComputer: t('landing.uploadFromComputer', 'Upload from computer'),
|
||||
download: t('download', 'Download'),
|
||||
downloadAll: t('rightRail.downloadAll', 'Download All'),
|
||||
downloadSelected: t('fileManager.downloadSelected', 'Download Selected'),
|
||||
downloadUnavailable: t('downloadUnavailable', 'Download unavailable for this item'),
|
||||
noFilesInStorage: t('fileUpload.noFilesInStorage', 'No files available in storage. Upload some files first.'),
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,6 @@ export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
|
||||
export interface BackendHealthState {
|
||||
status: BackendStatus;
|
||||
message?: string | null;
|
||||
lastChecked?: number;
|
||||
isChecking: boolean;
|
||||
error: string | null;
|
||||
isHealthy: boolean;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const { isHealthy, isChecking, checkHealth } = useBackendHealth();
|
||||
const { status, isHealthy, checkHealth } = useBackendHealth();
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (isChecking) {
|
||||
if (status === 'starting') {
|
||||
return t('backendHealth.checking', 'Checking backend status...');
|
||||
}
|
||||
|
||||
@@ -25,17 +25,17 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
}
|
||||
|
||||
return t('backendHealth.offline', 'Backend Offline');
|
||||
}, [isChecking, isHealthy, t]);
|
||||
}, [status, isHealthy, t]);
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
if (isChecking) {
|
||||
if (status === 'starting') {
|
||||
return theme.colors.yellow?.[5] ?? '#fcc419';
|
||||
}
|
||||
if (isHealthy) {
|
||||
return theme.colors.green?.[5] ?? '#37b24d';
|
||||
}
|
||||
return theme.colors.red?.[6] ?? '#e03131';
|
||||
}, [isChecking, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]);
|
||||
}, [status, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
|
||||
@@ -25,7 +25,9 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
|
||||
|
||||
// Validation
|
||||
if (!username.trim()) {
|
||||
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
setValidationError(isSaaS
|
||||
? t('setup.login.error.emptyEmail', 'Please enter your email')
|
||||
: t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,8 +134,12 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false,
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t('setup.login.username.label', 'Username')}
|
||||
placeholder={t('setup.login.username.placeholder', 'Enter your username')}
|
||||
label={isSaaS
|
||||
? t('setup.login.email.label', 'Email')
|
||||
: t('setup.login.username.label', 'Username')}
|
||||
placeholder={isSaaS
|
||||
? t('setup.login.email.placeholder', 'Enter your email')
|
||||
: t('setup.login.username.placeholder', 'Enter your username')}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
import { connectionModeService } from '@desktop/services/connectionModeService';
|
||||
import type { AppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
|
||||
interface EndpointConfig {
|
||||
@@ -28,6 +29,17 @@ function getErrorMessage(err: unknown): string {
|
||||
return 'Unknown error occurred';
|
||||
}
|
||||
|
||||
async function checkDependenciesReady(): Promise<boolean> {
|
||||
try {
|
||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
return response.data?.dependenciesReady ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-specific endpoint checker that hits the backend directly via axios.
|
||||
*/
|
||||
@@ -38,7 +50,7 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState<boolean | null>(() => (endpoint ? true : null));
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -68,6 +80,11 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependenciesReady = await checkDependenciesReady();
|
||||
if (!dependenciesReady) {
|
||||
return; // Health monitor will trigger retry when truly ready
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
@@ -76,27 +93,27 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
setEnabled(response.data);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
if (!isMountedRef.current) return;
|
||||
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||
setEnabled(true);
|
||||
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchEndpointStatus();
|
||||
}, RETRY_DELAY_MS);
|
||||
if (isBackendStarting) {
|
||||
setError(t('backendHealth.starting', 'Backend starting up...'));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchEndpointStatus();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
setError(message);
|
||||
setEnabled(false);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint, clearRetryTimeout]);
|
||||
}, [endpoint, clearRetryTimeout, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoint) {
|
||||
@@ -136,15 +153,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(() => {
|
||||
if (!endpoints || endpoints.length === 0) return {};
|
||||
return endpoints.reduce((acc, endpointName) => {
|
||||
acc[endpointName] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
});
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -167,26 +178,31 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
clearRetryTimeout();
|
||||
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
if (!isMountedRef.current) return;
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dependenciesReady = await checkDependenciesReady();
|
||||
if (!dependenciesReady) {
|
||||
return; // Health monitor will trigger retry when truly ready
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const endpointsParam = endpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', {
|
||||
params: { endpoints: endpointsParam },
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? true,
|
||||
enabled: detail?.enabled ?? false,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
@@ -198,34 +214,34 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
}, {} as Record<string, boolean>);
|
||||
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(statusMap);
|
||||
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
if (!isMountedRef.current) return;
|
||||
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||
|
||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||
const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpointName] = true;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
|
||||
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchAllEndpointStatuses();
|
||||
}, RETRY_DELAY_MS);
|
||||
if (isBackendStarting) {
|
||||
setError(t('backendHealth.starting', 'Backend starting up...'));
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
fetchAllEndpointStatuses();
|
||||
}, RETRY_DELAY_MS);
|
||||
}
|
||||
} else {
|
||||
setError(message);
|
||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||
const fallbackDetail: EndpointAvailabilityDetails = { enabled: false, reason: 'UNKNOWN' };
|
||||
acc.status[endpointName] = false;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoints, clearRetryTimeout]);
|
||||
}, [endpoints, clearRetryTimeout, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
|
||||
15
frontend/src/desktop/hooks/useFileActionIcons.ts
Normal file
15
frontend/src/desktop/hooks/useFileActionIcons.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
|
||||
/**
|
||||
* File action icons for desktop builds
|
||||
* Overrides core implementation with desktop-appropriate icons
|
||||
*/
|
||||
export function useFileActionIcons() {
|
||||
return {
|
||||
upload: FolderOpenOutlinedIcon,
|
||||
download: SaveOutlinedIcon,
|
||||
uploadIconName: 'folder-rounded' as const,
|
||||
downloadIconName: 'save-rounded' as const,
|
||||
};
|
||||
}
|
||||
22
frontend/src/desktop/hooks/useFileActionTerminology.ts
Normal file
22
frontend/src/desktop/hooks/useFileActionTerminology.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* File action terminology for desktop builds
|
||||
* Overrides core implementation with desktop-appropriate terminology
|
||||
*/
|
||||
export function useFileActionTerminology() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
uploadFiles: t('fileUpload.openFiles', 'Open Files'),
|
||||
uploadFile: t('fileUpload.openFile', 'Open File'),
|
||||
upload: t('fileUpload.open', 'Open'),
|
||||
dropFilesHere: t('fileUpload.dropFilesHereOpen', 'Drop files here or click the open button'),
|
||||
uploadFromComputer: t('landing.openFromComputer', 'Open from computer'),
|
||||
download: t('save', 'Save'),
|
||||
downloadAll: t('rightRail.saveAll', 'Save All'),
|
||||
downloadSelected: t('fileManager.saveSelected', 'Save Selected'),
|
||||
downloadUnavailable: t('saveUnavailable', 'Save unavailable for this item'),
|
||||
noFilesInStorage: t('fileUpload.noFilesInStorageOpen', 'No files available in storage. Open some files first.'),
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,6 @@ class BackendHealthMonitor {
|
||||
private readonly intervalMs: number;
|
||||
private state: BackendHealthState = {
|
||||
status: tauriBackendService.getBackendStatus(),
|
||||
isChecking: false,
|
||||
error: null,
|
||||
isHealthy: tauriBackendService.getBackendStatus() === 'healthy',
|
||||
};
|
||||
@@ -26,20 +25,30 @@ class BackendHealthMonitor {
|
||||
message: status === 'healthy'
|
||||
? i18n.t('backendHealth.online', 'Backend Online')
|
||||
: this.state.message ?? i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
isChecking: status === 'healthy' ? false : this.state.isChecking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(partial: Partial<BackendHealthState>) {
|
||||
const nextStatus = partial.status ?? this.state.status;
|
||||
this.state = {
|
||||
const nextState = {
|
||||
...this.state,
|
||||
...partial,
|
||||
status: nextStatus,
|
||||
isHealthy: nextStatus === 'healthy',
|
||||
};
|
||||
this.listeners.forEach((listener) => listener(this.state));
|
||||
|
||||
// Only notify listeners if meaningful state changed
|
||||
const meaningfulChange =
|
||||
this.state.status !== nextState.status ||
|
||||
this.state.error !== nextState.error ||
|
||||
this.state.message !== nextState.message;
|
||||
|
||||
this.state = nextState;
|
||||
|
||||
if (meaningfulChange) {
|
||||
this.listeners.forEach((listener) => listener(this.state));
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePolling() {
|
||||
@@ -60,29 +69,19 @@ class BackendHealthMonitor {
|
||||
}
|
||||
|
||||
private async pollOnce(): Promise<boolean> {
|
||||
this.updateState({
|
||||
isChecking: true,
|
||||
lastChecked: Date.now(),
|
||||
error: this.state.error ?? 'Backend offline',
|
||||
});
|
||||
|
||||
try {
|
||||
const healthy = await tauriBackendService.checkBackendHealth();
|
||||
if (healthy) {
|
||||
this.updateState({
|
||||
status: 'healthy',
|
||||
isChecking: false,
|
||||
message: i18n.t('backendHealth.online', 'Backend Online'),
|
||||
error: null,
|
||||
lastChecked: Date.now(),
|
||||
});
|
||||
} else {
|
||||
this.updateState({
|
||||
status: 'unhealthy',
|
||||
isChecking: false,
|
||||
message: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
error: i18n.t('backendHealth.offline', 'Backend Offline'),
|
||||
lastChecked: Date.now(),
|
||||
});
|
||||
}
|
||||
return healthy;
|
||||
@@ -90,10 +89,8 @@ class BackendHealthMonitor {
|
||||
console.error('[BackendHealthMonitor] Health check failed:', error);
|
||||
this.updateState({
|
||||
status: 'unhealthy',
|
||||
isChecking: false,
|
||||
message: 'Backend is unavailable',
|
||||
error: 'Backend offline',
|
||||
lastChecked: Date.now(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@ export class TauriBackendService {
|
||||
async checkBackendHealth(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// For self-hosted mode, check the configured remote server
|
||||
// Determine base URL based on mode
|
||||
let baseUrl: string;
|
||||
if (mode === 'selfhosted') {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
@@ -141,47 +142,37 @@ export class TauriBackendService {
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
baseUrl = serverConfig.url.replace(/\/$/, '');
|
||||
} else {
|
||||
// SaaS mode - check bundled local backend
|
||||
if (!this.backendStarted) {
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
if (!this.backendPort) {
|
||||
return false;
|
||||
}
|
||||
baseUrl = `http://localhost:${this.backendPort}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = serverConfig.url.replace(/\/$/, '');
|
||||
const healthUrl = `${baseUrl}/api/v1/info/status`;
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
// Check if backend is ready (dependencies checked)
|
||||
try {
|
||||
const configUrl = `${baseUrl}/api/v1/config/app-config`;
|
||||
const response = await fetch(configUrl, {
|
||||
method: 'GET',
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
|
||||
const isHealthy = response.ok;
|
||||
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
|
||||
return isHealthy;
|
||||
} catch (error) {
|
||||
const errorStr = String(error);
|
||||
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
|
||||
console.error('[TauriBackendService] Self-hosted server health check failed:', error);
|
||||
}
|
||||
if (!response.ok) {
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For SaaS mode, check the bundled local backend via Rust
|
||||
if (!this.backendStarted) {
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.backendPort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const isHealthy = await invoke<boolean>('check_backend_health', { port: this.backendPort });
|
||||
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
|
||||
return isHealthy;
|
||||
} catch (error) {
|
||||
const errorStr = String(error);
|
||||
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
|
||||
console.error('[TauriBackendService] Bundled backend health check failed:', error);
|
||||
}
|
||||
const data = await response.json();
|
||||
const dependenciesReady = data.dependenciesReady === true;
|
||||
this.setStatus(dependenciesReady ? 'healthy' : 'starting');
|
||||
return dependenciesReady;
|
||||
} catch {
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user