diff --git a/package.json b/package.json index eec80c9..0ab5945 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dev": "vite", "bindings": "cd src-tauri && cargo test export_bindings --lib", "build": "tsc && vite build", + "check:i18n": "vitest run src/i18n/resources.test.ts", "check:updates": "node scripts/check-updates.js", "verify:macos-signing": "node scripts/verify-macos-signing.js", "preview": "vite preview", diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 992b3cb..cab3037 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -6,6 +6,10 @@ fn default_speed_limit_unit() -> String { "MB/s".to_string() } +fn default_language_preference() -> String { + "system".to_string() +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] @@ -257,6 +261,8 @@ pub struct SchedulerSettings { #[ts(export, export_to = "../../src/bindings/")] pub struct PersistedSettings { pub theme: Theme, + #[serde(default = "default_language_preference")] + pub language: String, pub base_download_folder: String, pub category_subfolders_enabled: bool, pub category_subfolders: HashMap, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 58edaa1..1fd6b71 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -192,6 +192,11 @@ fn sanitize_persisted_setting_values(state: &mut Value) { "theme", &["system", "light", "dark", "dracula", "nord"], ); + sanitize_allowed_string( + state, + "language", + &["system", "en", "zh-CN", "he", "fa", "uk", "ru"], + ); sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]); sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]); sanitize_allowed_string(state, "activeSettingsTab", &[ @@ -410,6 +415,7 @@ fn derived_location_path(base: &str, subfolder: &str) -> String { fn default_settings() -> PersistedSettings { PersistedSettings { theme: Theme::System, + language: "system".to_string(), base_download_folder: "~/Downloads".to_string(), category_subfolders_enabled: true, category_subfolders: default_category_subfolders(), diff --git a/src/App.tsx b/src/App.tsx index 7804760..a1accec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,8 @@ import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads'; import { schedulerCompletionState } from './utils/schedulerCompletion'; -import { useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { DownloadTable } from "./components/DownloadTable"; -import { AddDownloadsModal } from "./components/AddDownloadsModal"; -import SettingsView from "./components/SettingsView"; -import { PropertiesModal } from "./components/PropertiesModal"; -import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal"; import { extractValidDownloadUrls } from './utils/url'; import { readClipboardDownloadUrls } from './utils/clipboard'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; @@ -14,10 +10,6 @@ import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from ' import { initDownloadListener } from './store/downloadStore'; import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; -import SchedulerView from "./components/SchedulerView"; -import SpeedLimiterView from "./components/SpeedLimiterView"; -import LogsView from "./components/LogsView"; -import { KeychainPermissionModal } from "./components/KeychainPermissionModal"; import { WindowControls } from "./components/WindowControls"; import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; @@ -32,9 +24,26 @@ import { getVersion } from '@tauri-apps/api/app'; import type { PostQueueAction } from './bindings/PostQueueAction'; import { PanelLeft } from 'lucide-react'; import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls'; -import { syncDocumentLocale } from './i18n'; +import { changeAppLocale, resolveAppLocale, syncDocumentLocale } from './i18n'; import { useTranslation } from 'react-i18next'; +const SettingsView = lazy(() => import('./components/SettingsView')); +const SchedulerView = lazy(() => import('./components/SchedulerView')); +const SpeedLimiterView = lazy(() => import('./components/SpeedLimiterView')); +const LogsView = lazy(() => import('./components/LogsView')); +const AddDownloadsModal = lazy(() => import('./components/AddDownloadsModal').then(module => ({ + default: module.AddDownloadsModal, +}))); +const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({ + default: module.PropertiesModal, +}))); +const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({ + default: module.DeleteConfirmationModal, +}))); +const KeychainPermissionModal = lazy(() => import('./components/KeychainPermissionModal').then(module => ({ + default: module.KeychainPermissionModal, +}))); + let automaticUpdateCheckStarted = false; const processingScheduleKeys = new Set(); @@ -123,11 +132,21 @@ function App() { }); const theme = useSettingsStore(state => state.theme); + const languagePreference = useSettingsStore(state => state.language); const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); const toggleSidebar = useSettingsStore(state => state.toggleSidebar); const activeView = useSettingsStore(state => state.activeView); const appFontSize = useSettingsStore(state => state.appFontSize); const listRowDensity = useSettingsStore(state => state.listRowDensity); + + useEffect(() => { + const locale = languagePreference === 'system' + ? resolveAppLocale(typeof navigator === 'undefined' ? undefined : navigator.language) + : languagePreference; + if (i18n.language !== locale) { + void changeAppLocale(locale); + } + }, [i18n, languagePreference]); const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates); const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks); const showNotifications = useSettingsStore(state => state.showNotifications); @@ -135,6 +154,9 @@ function App() { const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon); const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); const showKeychainModal = useSettingsStore(state => state.showKeychainModal); + const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen); + const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId); + const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen); const downloads = useDownloadStore(state => state.downloads); const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; const queuedCount = downloads.filter(download => @@ -920,7 +942,7 @@ function App() { }`} style={{ width: sidebarWidth, - marginLeft: isSidebarVisible ? 0 : -sidebarWidth + marginInlineStart: isSidebarVisible ? 0 : -sidebarWidth }} >
@@ -956,11 +978,13 @@ function App() { )}
- {activeView === 'downloads' && } - {activeView === 'settings' && } - {activeView === 'scheduler' && } - {activeView === 'speedLimiter' && } - {activeView === 'logs' && } + + {activeView === 'downloads' && } + {activeView === 'settings' && } + {activeView === 'scheduler' && } + {activeView === 'speedLimiter' && } + {activeView === 'logs' && } +
{/* Status Bar */} @@ -974,10 +998,12 @@ function App() {
- - - - + + {isAddModalOpen && } + {selectedPropertiesDownloadId !== null && } + {isDeleteModalOpen && } + {showKeychainModal && } + ); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 5c5e8c7..64d3eb3 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab"; import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; -export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 882f8be..e904330 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -25,6 +25,7 @@ import { getPlatformInfo } from '../utils/platform'; import { isTransferLocked } from '../utils/downloadActions'; import { useToast } from '../contexts/ToastContext'; import { useTranslation } from 'react-i18next'; +import { localePluralVariant } from '../i18n/locales'; import { canSubmitMetadataRows, appendRequestUrlsAfterVersion, @@ -114,7 +115,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [ ].filter(Boolean).join('\n'); export const AddDownloadsModal = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { addToast } = useToast(); const { isAddModalOpen, @@ -1132,19 +1133,60 @@ export const AddDownloadsModal = () => { ); const playlistSummaries = Object.entries(playlistExpansions) .filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl)); + const pluralMessage = ( + count: number, + one: () => string, + few: () => string, + many: () => string + ): string => { + switch (localePluralVariant(i18n.language, count)) { + case 'one': return one(); + case 'few': return few(); + case 'many': return many(); + } + }; const metadataSummary = (() => { const summary = metadataSummaryState(parsedItems); - const plural = (count: number) => count === 1 ? '' : 's'; switch (summary.type) { case 'empty': return t($ => $.addDownloads.pasteOneOrMore); case 'none-selected': return t($ => $.addDownloads.selectAtLeastOne); - case 'invalid': return t($ => $.addDownloads.correctInvalid, { count: summary.count, plural: plural(summary.count) }); - case 'loading': return t($ => $.addDownloads.waitingForMetadata, { count: summary.count, plural: plural(summary.count) }); - case 'unsafe': return t($ => $.addDownloads.removeUnsafe, { count: summary.count, plural: plural(summary.count) }); - case 'media-error': return t($ => $.addDownloads.mediaMetadataUnavailableSummary, { count: summary.count, plural: plural(summary.count) }); + case 'invalid': return pluralMessage( + summary.count, + () => t($ => $.addDownloads.correctInvalidOne, { count: summary.count }), + () => t($ => $.addDownloads.correctInvalidFew, { count: summary.count }), + () => t($ => $.addDownloads.correctInvalidMany, { count: summary.count }) + ); + case 'loading': return pluralMessage( + summary.count, + () => t($ => $.addDownloads.waitingForMetadataOne, { count: summary.count }), + () => t($ => $.addDownloads.waitingForMetadataFew, { count: summary.count }), + () => t($ => $.addDownloads.waitingForMetadataMany, { count: summary.count }) + ); + case 'unsafe': return pluralMessage( + summary.count, + () => t($ => $.addDownloads.removeUnsafeOne, { count: summary.count }), + () => t($ => $.addDownloads.removeUnsafeFew, { count: summary.count }), + () => t($ => $.addDownloads.removeUnsafeMany, { count: summary.count }) + ); + case 'media-error': return pluralMessage( + summary.count, + () => t($ => $.addDownloads.mediaMetadataUnavailableSummaryOne, { count: summary.count }), + () => t($ => $.addDownloads.mediaMetadataUnavailableSummaryFew, { count: summary.count }), + () => t($ => $.addDownloads.mediaMetadataUnavailableSummaryMany, { count: summary.count }) + ); case 'all-error': return t($ => $.addDownloads.metadataUnavailableFallback); - case 'fallback': return t($ => $.addDownloads.fallbackReady, { ready: summary.ready, readyPlural: plural(summary.ready), failed: summary.failed }); - case 'ready': return t($ => $.addDownloads.readyToAdd, { count: summary.count, plural: plural(summary.count) }); + case 'fallback': return pluralMessage( + summary.ready, + () => t($ => $.addDownloads.fallbackReadyOne, { ready: summary.ready, failed: summary.failed }), + () => t($ => $.addDownloads.fallbackReadyFew, { ready: summary.ready, failed: summary.failed }), + () => t($ => $.addDownloads.fallbackReadyMany, { ready: summary.ready, failed: summary.failed }) + ); + case 'ready': return pluralMessage( + summary.count, + () => t($ => $.addDownloads.readyToAddOne, { count: summary.count }), + () => t($ => $.addDownloads.readyToAddFew, { count: summary.count }), + () => t($ => $.addDownloads.readyToAddMany, { count: summary.count }) + ); } })(); @@ -1194,7 +1236,7 @@ export const AddDownloadsModal = () => {
{/* Left Column: URLs and Preview */} -
+
@@ -1245,7 +1287,7 @@ export const AddDownloadsModal = () => { type="button" onClick={toggleAllRows} disabled={parsedItems.length === 0} - className="add-download-link-button ml-3 text-[11px] font-medium" + className="add-download-link-button ms-3 text-[11px] font-medium" > {allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)} @@ -1302,9 +1344,9 @@ export const AddDownloadsModal = () => { onChange={() => toggleRowSelection(i)} onClick={event => event.stopPropagation()} aria-label={t($ => $.addDownloads.selectItem, { file: item.file })} - className="mr-2 shrink-0 accent-purple-500" + className="me-2 shrink-0 accent-purple-500" /> -
{item.file}
+
{item.file}
{item.size || t($ => $.addDownloads.unknown)}
{item.status === 'loading' ? ( @@ -1337,7 +1379,7 @@ export const AddDownloadsModal = () => { {/* Media Format (Dynamic) */} {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
-
+
@@ -1358,7 +1400,7 @@ export const AddDownloadsModal = () => {
-
$.addDownloads.availableMediaStreams)}> +
$.addDownloads.availableMediaStreams)}> {parsedItems[selectedItemIndex].formats!.map((f, idx) => { const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx; const Icon = f.type === 'Audio' ? Music : Film; @@ -1474,7 +1516,7 @@ export const AddDownloadsModal = () => { {useAuth && ( -
+
setUsername(e.target.value)} placeholder={t($ => $.addDownloads.username)} className="add-download-control w-full px-3 py-1.5 text-xs" /> setPassword(e.target.value)} placeholder={t($ => $.addDownloads.password)} className="add-download-control w-full px-3 py-1.5 text-xs" />
@@ -1493,7 +1535,7 @@ export const AddDownloadsModal = () => { {advancedExpanded && ( -
+