Merge branch 'V2' into feature/onboardingSlides

This commit is contained in:
EthanHealy01
2025-11-25 13:28:35 +00:00
committed by GitHub
30 changed files with 321 additions and 169 deletions

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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"

View File

@@ -43,6 +43,7 @@ export interface AppConfig {
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
dependenciesReady?: boolean;
error?: string;
isNewServer?: boolean;
isNewUser?: boolean;

View File

@@ -4,8 +4,6 @@ export function useBackendHealth(): BackendHealthState {
return {
status: 'healthy',
message: null,
isChecking: false,
lastChecked: Date.now(),
error: null,
isHealthy: true,
};

View 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,
};
}

View 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.'),
};
}

View File

@@ -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;
}

View File

@@ -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 === ' ') {

View File

@@ -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);

View File

@@ -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) {

View 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,
};
}

View 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.'),
};
}

View File

@@ -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;
}

View File

@@ -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;
}