diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index cab3037..c86d14f 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -10,6 +10,10 @@ fn default_language_preference() -> String { "system".to_string() } +fn default_sidebar_position() -> String { + "auto".to_string() +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] @@ -273,6 +277,8 @@ pub struct PersistedSettings { pub speed_limit_preset_values: Vec, pub logs_enabled: bool, pub is_sidebar_visible: bool, + #[serde(default = "default_sidebar_position")] + pub sidebar_position: String, pub active_settings_tab: SettingsTab, pub scheduler: SchedulerSettings, pub scheduler_running: bool, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1fd6b71..e49643d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -197,6 +197,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) { "language", &["system", "en", "zh-CN", "he", "fa", "uk", "ru"], ); + sanitize_allowed_string(state, "sidebarPosition", &["auto", "left", "right"]); sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]); sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]); sanitize_allowed_string(state, "activeSettingsTab", &[ @@ -426,6 +427,7 @@ fn default_settings() -> PersistedSettings { speed_limit_preset_values: vec![1.0, 5.0, 10.0], logs_enabled: false, is_sidebar_visible: true, + sidebar_position: "auto".to_string(), active_settings_tab: SettingsTab::Downloads, scheduler: SchedulerSettings { enabled: false, @@ -506,6 +508,7 @@ mod tests { "state": { "maxConcurrentDownloads": 7, "globalSpeedLimit": "2M", + "sidebarPosition": "right", "scheduler": { "enabled": true, "startTime": "06:30", @@ -523,6 +526,7 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 7); assert_eq!(settings.global_speed_limit, "2M"); + assert_eq!(settings.sidebar_position, "right"); assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]); assert!(!settings.logs_enabled); assert!(settings.scheduler.enabled); @@ -660,6 +664,18 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 3); } + #[test] + fn invalid_sidebar_position_uses_automatic_layout() { + let stored = json!({ + "state": {"sidebarPosition": "diagonal"}, + "version": 5 + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert_eq!(settings.sidebar_position, "auto"); + } + #[test] fn clamps_out_of_range_download_settings() { let stored = json!({ diff --git a/src/App.tsx b/src/App.tsx index a1accec..918dd23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { WindowControls } from "./components/WindowControls"; import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; import { openUrl } from '@tauri-apps/plugin-opener'; -import { getPlatformInfo, usePlatformInfo } from './utils/platform'; +import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform'; import { getKeychainAccessReady, getKeychainConsentVersion, @@ -24,7 +24,7 @@ import { getVersion } from '@tauri-apps/api/app'; import type { PostQueueAction } from './bindings/PostQueueAction'; import { PanelLeft } from 'lucide-react'; import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls'; -import { changeAppLocale, resolveAppLocale, syncDocumentLocale } from './i18n'; +import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n'; import { useTranslation } from 'react-i18next'; const SettingsView = lazy(() => import('./components/SettingsView')); @@ -134,6 +134,7 @@ function App() { const theme = useSettingsStore(state => state.theme); const languagePreference = useSettingsStore(state => state.language); const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); + const sidebarPosition = useSettingsStore(state => state.sidebarPosition); const toggleSidebar = useSettingsStore(state => state.toggleSidebar); const activeView = useSettingsStore(state => state.activeView); const appFontSize = useSettingsStore(state => state.appFontSize); @@ -178,7 +179,9 @@ function App() { const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; const { addToast, removeToast } = useToast(); const isMacUserAgent = navigator.userAgent.includes('Mac'); - const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos'; + const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); + const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; + const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl); // Keep dialogs out of the titlebar area while platform detection is still // resolving. The conservative fallback prevents a startup handoff from // briefly rendering underneath native or custom window controls. @@ -285,7 +288,10 @@ function App() { const startWidth = sidebarWidth; const handlePointerMove = (moveEvent: PointerEvent) => { - const nextWidth = Math.min(260, Math.max(190, startWidth + moveEvent.clientX - startX)); + const delta = isSidebarOnRight + ? startX - moveEvent.clientX + : moveEvent.clientX - startX; + const nextWidth = Math.min(260, Math.max(190, startWidth + delta)); setSidebarWidth(nextWidth); }; @@ -933,16 +939,21 @@ function App() { return (
- {(platform.os === 'windows' || platform.os === 'linux') && } + {usesCustomWindowControls && }
@@ -963,6 +974,8 @@ function App() {
diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 64d3eb3..ea8abb8 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, 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, }; +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, sidebarPosition: string, 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/SettingsView.tsx b/src/components/SettingsView.tsx index 3a830ad..459fbfa 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState, useEffect } from 'react'; import { type AppFontSize, type ListRowDensity, + type SidebarPosition, type SettingsState, SettingsTab, runSettingsPersistenceTransaction, @@ -810,6 +811,21 @@ runEngineChecks(false); ))}
+
+
+ {t($ => $.settings.lookAndFeel.sidebarPosition)} + {t($ => $.settings.lookAndFeel.sidebarPositionDescription)} +
+ +

{t($ => $.settings.lookAndFeel.appTheme)}

diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 0a0513a..a1caaf1 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -532,6 +532,11 @@ const common = { lookAndFeel: { language: 'Language', languageDescription: 'Choose the Firelink interface language.', + sidebarPosition: 'Sidebar position', + sidebarPositionDescription: 'Choose where the sidebar appears. Automatic follows the interface direction.', + sidebarPositionAutomatic: 'Automatic (recommended)', + sidebarPositionLeft: 'Left', + sidebarPositionRight: 'Right', languageSystem: 'System default', languageEnglish: 'English', languageChinese: 'Simplified Chinese', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 3e8dd30..a373493 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -8,7 +8,7 @@ const fa = { unfinished: 'ناتمام', }, categories: { - musics: 'موسیقی‌ها', + musics: 'موسیقی', movies: 'فیلم‌ها', compressed: 'فشرده', documents: 'اسناد', @@ -19,8 +19,8 @@ const fa = { folders: 'پوشه‌ها', queues: 'صف‌ها', tools: 'ابزارها', - scheduler: 'زمان‌بند', - speedLimiter: 'محدودکننده سرعت', + scheduler: 'زمان‌بندی', + speedLimiter: 'محدودکنندهٔ سرعت', logs: 'گزارش‌ها', settings: 'تنظیمات', }, @@ -63,8 +63,8 @@ const fa = { window: { controls: 'کنترل‌های پنجره', close: 'بستن', - minimize: 'کوچک کردن', - maximize: 'بزرگ کردن', + minimize: 'کمینه کردن', + maximize: 'بیشینه کردن', }, downloads: { actions: { @@ -86,7 +86,7 @@ const fa = { downloading: 'در حال دانلود', processing: 'در حال پردازش', paused: 'متوقف شده', - completed: 'تکمیل شده', + completed: 'تکمیل‌شده', failed: 'ناموفق', retrying: 'در حال تلاش مجدد', }, @@ -96,9 +96,9 @@ const fa = { }, }, speedLimiter: { - title: 'محدودکننده سرعت', + title: 'محدودکنندهٔ سرعت', unlimited: 'نامحدود', - saveLimit: 'ذخیره محدودیت', + saveLimit: 'ذخیره محدودیت سرعت', globalSpeedLimit: 'محدودیت سرعت کلی', description: 'در مورد انتقال‌های جدید و فعال aria2 و دانلودهای رسانه جدید yt-dlp اعمال می‌شود. محدودیت‌های خاص هر دانلود برای آن انتقال‌ها به قوت خود باقی می‌ماند.', quickPresets: 'پیش‌تنظیم‌های سریع', @@ -215,7 +215,7 @@ const fa = { speedCap: 'سقف سرعت', category: 'دسته', lastTry: 'آخرین تلاش', - dateAdded: 'تاریخ اضافه شدن', + dateAdded: 'تاریخ افزودن', destination: 'مقصد', lastError: 'آخرین خطا', saved: ' (ذخیره شده)', @@ -260,9 +260,9 @@ const fa = { title: 'نیاز به دسترسی ذخیره‌سازی اعتبارنامه', stores: { portable: 'پوشه داده‌های قابل‌حمل Firelink', - windows: 'Windows Credential Manager', - linux: 'مخزن اعتبارنامه‌های Linux شما', - macos: 'macOS Keychain', + windows: 'مدیر اعتبارنامه Windows', + linux: 'مخزن اعتبارنامه Linux شما', + macos: 'Keychain macOS', system: "مخزن اعتبارنامه‌های این سیستم", siteCredentials: "مخزن اعتبارنامه‌های سیستم", }, @@ -307,7 +307,7 @@ const fa = { updateAvailable: 'Firelink {{version}} در دسترس است.', viewRelease: 'مشاهده انتشار', sleepPreventionFailed: 'جلوگیری از خواب سیستم به‌روزرسانی نشد: {{detail}}', - schedulerNoQueues: 'زمان‌بند هیچ صف معتبری انتخاب نکرده است. تنظیمات زمان‌بند را به‌روزرسانی کنید.', + schedulerNoQueues: 'زمان‌بند هیچ صف معتبری انتخاب نکرده است. تنظیمات زمان‌بندی را به‌روزرسانی کنید.', schedulerPauseOneFailed: 'زمان‌بند نتوانست ۱ دانلود را متوقف کند.', schedulerPauseManyFailed: 'زمان‌بند نتوانست {{count}} دانلود را متوقف کند.', scheduledIncomplete: 'همه دانلودهای زمان‌بندی‌شده تکمیل نشدند. اقدام سیستم پس از صف رد شد.', @@ -335,7 +335,7 @@ const fa = { status: 'وضعیت', speed: 'سرعت', eta: 'زمان تخمینی', - dateAdded: 'تاریخ اضافه شدن', + dateAdded: 'تاریخ افزودن', }, addDownload: 'افزودن دانلود', resumeAll: 'ادامه همه', @@ -351,8 +351,8 @@ const fa = { nonResumableOne: '۱ دانلود از ادامه پشتیبانی نمی‌کند. اگر آن را متوقف کنید، بعداً باید دوباره شروع کنید. آیا مطمئن هستید که می‌خواهید متوقف کنید؟', nonResumableMany: '{{count}} دانلود از ادامه پشتیبانی نمی‌کنند. اگر آنها را متوقف کنید، بعداً باید دوباره شروع کنید. آیا مطمئن هستید که می‌خواهید متوقف کنید؟', interactionError: '{{message}}: {{detail}}', - openFileFailed: 'فایل دانلود شده باز نشد', - revealFileFailed: 'نمایش دانلود در Finder ناموفق بود', + openFileFailed: 'باز کردن فایل دانلودشده ناموفق بود', + revealFileFailed: 'نمایش فایل دانلودشده در پوشه ناموفق بود', pauseFailed: 'توقف دانلود ناموفق بود', resumeFailed: 'ادامه {{fileName}} ناموفق بود', moveManyFailed: 'انتقال دانلودها به صف ناموفق بود', @@ -373,9 +373,9 @@ const fa = { resume: 'ادامه', redownload: 'دانلود مجدد', copyFilePath: 'کپی مسیر فایل', - properties: 'خصوصیات', + properties: 'ویژگی‌ها', backendRejectedStart: 'درخواست شروع/ادامه توسط موتور دانلود رد شد.', - transferActive: 'نمی‌توانید خصوصیات را در حالی که انتقال فعال است تغییر دهید. ابتدا آن را متوقف کنید.', + transferActive: 'نمی‌توانید ویژگی‌ها را هنگام فعال بودن انتقال تغییر دهید. ابتدا آن را متوقف کنید.', redownloadNotFound: 'دانلود مجدد ناموفق: دانلود پیدا نشد.', redownloadActive: 'نمی‌توانید یک دانلود {{status}} را دوباره دانلود کنید. ابتدا آن را متوقف کنید یا منتظر بمانید تا تمام شود.', originalUrlMissing: 'دانلود مجدد ناموفق: URL اصلی وجود ندارد.', @@ -433,14 +433,14 @@ const fa = { availableMediaStreams: 'جریان‌های رسانه موجود', metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.', saveLocation: 'محل ذخیره', - browse: 'مرور', + browse: 'انتخاب', categoryFolders: 'فایل‌ها در پوشه‌های دسته سازماندهی خواهند شد.', sharedFolder: 'تمام دانلودهای انتخاب‌شده از این پوشه استفاده خواهند کرد.', transferSettings: 'تنظیمات انتقال', connectionsPerFile: 'اتصالات در هر فایل', connectionsPerFileAria: 'اتصالات در هر فایل', limitSpeedPerFile: 'محدودیت سرعت در هر فایل', - speedLimitPerFileAria: 'محدودیت سرعت در هر فایل', + speedLimitPerFileAria: 'محدودیت سرعت برای هر فایل', authorization: 'احراز هویت', useAuthorization: 'استفاده از احراز هویت', username: 'نام کاربری', @@ -448,7 +448,7 @@ const fa = { advancedTransfer: 'انتقال پیشرفته', verifyChecksum: 'تأیید چک‌سام', checksumAlgorithm: 'الگوریتم چک‌سام', - expectedDigest: 'هش موردانتظار', + expectedDigest: 'هش مورد انتظار', headers: 'هدرها', requestHeaders: 'هدرهای درخواست', cookies: 'کوکی‌ها', @@ -495,11 +495,11 @@ const fa = { thisOs: 'این سیستم‌عامل', checking: 'در حال بررسی...', ready: 'آماده', - errorMissing: 'خطا / ناموجود', + errorMissing: 'خطا / یافت نشد', hideTechnicalDetails: 'پنهان کردن جزئیات فنی', showTechnicalDetails: 'نمایش جزئیات فنی', binary: 'باینری', - expected: 'موردانتظار', + expected: 'مقدار مورد انتظار', error: 'خطا', tip: 'نکته', stderr: 'stderr', @@ -532,6 +532,11 @@ const fa = { lookAndFeel: { language: 'زبان', languageDescription: 'زبان رابط Firelink را انتخاب کنید.', + sidebarPosition: 'جایگاه نوار کناری', + sidebarPositionDescription: 'مشخص کنید نوار کناری کجا نمایش داده شود. حالت خودکار از جهت رابط کاربری پیروی می‌کند.', + sidebarPositionAutomatic: 'خودکار (پیشنهادشده)', + sidebarPositionLeft: 'چپ', + sidebarPositionRight: 'راست', languageSystem: 'زبان سیستم', languageEnglish: 'انگلیسی', languageChinese: 'چینی ساده‌شده', @@ -539,32 +544,32 @@ const fa = { languagePersian: 'فارسی', languageUkrainian: 'اوکراینی', languageRussian: 'روسی', - appTheme: 'پوسته برنامه', - theme: 'پوسته', - ariaLabel: 'پوسته برنامه', + appTheme: 'ظاهر برنامه', + theme: 'ظاهر', + ariaLabel: 'ظاهر برنامه', system: 'سیستم', light: 'روشن', dark: 'تاریک', dracula: 'Dracula', nord: 'Nord', - systemAppearance: 'سیستم از ظاهر روشن یا تاریک فعلی {{platform}} پیروی می‌کند.', + systemAppearance: 'برنامه از حالت روشن یا تاریک فعلی {{platform}} پیروی می‌کند.', display: 'نمایش', fontSize: 'اندازه قلم', - fontSizeDescription: 'لیست دانلودها و کنترل‌های فشرده را تغییر مقیاس می‌دهد.', + fontSizeDescription: 'اندازه فهرست دانلود و کنترل‌های فشرده را تغییر می‌دهد.', small: 'کوچک', standard: 'استاندارد', large: 'بزرگ', - listDensity: 'تراکم لیست', + listDensity: 'تراکم فهرست', listDensityDescription: 'ارتفاع سطر، فاصله‌گذاری و مقیاس نوار پیشرفت را تغییر می‌دهد.', compact: 'فشرده', - comfortable: 'راحت', + comfortable: 'معمولی', relaxed: 'بسیار راحت', osIntegration: 'یکپارچه‌سازی سیستم‌عامل', dockBadge: 'نمایش نشان روی آیکون Dock', dockBadgeDescription: 'تعداد دانلودهای فعال را روی آیکون Firelink در Dock نشان می‌دهد.', menuBarIcon: 'نمایش آیکون نوار منو', statusIndicatorIcon: 'نمایش آیکون نشانگر وضعیت', - systemTrayIcon: 'نمایش آیکون System Tray', + systemTrayIcon: 'نمایش آیکون سینی سیستم', macosTrayDescription: 'دسترسی سریع از نوار منوی macOS فراهم می‌کند.', windowsTrayDescription: 'دسترسی سریع از ناحیه اعلان‌های Windows فراهم می‌کند.', linuxTrayDescription: 'در صورت وجود، دسترسی سریع از سینی دسکتاپ یا ناحیه وضعیت فراهم می‌کند.', @@ -602,15 +607,15 @@ const fa = { firefoxWindows: 'Firefox (Windows)', firefoxMacos: 'Firefox (macOS)', safariMacos: 'Safari (macOS)', - windowsDesktop: 'Windows دسکتاپ', - macosDesktop: 'macOS دسکتاپ', + windowsDesktop: 'دسکتاپ Windows', + macosDesktop: 'دسکتاپ macOS', }, locations: { - baseDownloadFolder: 'پوشه پایه دانلود', + baseDownloadFolder: 'پوشه اصلی دانلود', baseDownloadFolderDescription: 'پوشه‌های دسته خودکار در داخل این پوشه ایجاد می‌شوند.', - browse: 'مرور', - baseFolderCreateFailed: 'پوشه پایه ذخیره شد، اما ایجاد پوشه‌های دسته ناموفق بود: {{detail}}', - baseFolderUpdated: 'پوشه پایه دانلود به‌روزرسانی شد', + browse: 'انتخاب', + baseFolderCreateFailed: 'پوشه اصلی ذخیره شد، اما ایجاد پوشه‌های دسته ناموفق بود: {{detail}}', + baseFolderUpdated: 'پوشه اصلی دانلود به‌روزرسانی شد', askWhereToSave: 'هنگام افزودن دانلود بپرس کجا ذخیره شود', rememberLastUsedDownloadDirectory: 'پوشه دانلود اخیراً استفاده‌شده را به خاطر بسپار', rememberLastUsedDownloadDirectoryDescription: 'از آخرین پوشه انتخاب‌شده در پنجره افزودن برای دانلود بعدی تا زمان راه‌اندازی مجدد برنامه استفاده کنید.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index a01f774..f14ddd0 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -532,6 +532,11 @@ const he = { lookAndFeel: { language: 'שפה', languageDescription: 'בחר את שפת הממשק של Firelink.', + sidebarPosition: 'מיקום סרגל הצד', + sidebarPositionDescription: 'בחר היכן יופיע סרגל הצד. מצב אוטומטי עוקב אחר כיוון הממשק.', + sidebarPositionAutomatic: 'אוטומטי (מומלץ)', + sidebarPositionLeft: 'שמאל', + sidebarPositionRight: 'ימין', languageSystem: 'שפת המערכת', languageEnglish: 'אנגלית', languageChinese: 'סינית מפושטת', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 22d679c..72220a8 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -532,6 +532,11 @@ const ru = { lookAndFeel: { language: 'Язык', languageDescription: 'Выберите язык интерфейса Firelink.', + sidebarPosition: 'Положение боковой панели', + sidebarPositionDescription: 'Выберите, где отображать боковую панель. Автоматический режим учитывает направление интерфейса.', + sidebarPositionAutomatic: 'Автоматически (рекомендуется)', + sidebarPositionLeft: 'Слева', + sidebarPositionRight: 'Справа', languageSystem: 'Язык системы', languageEnglish: 'Английский', languageChinese: 'Упрощённый китайский', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index db8c0cd..67850e5 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -532,6 +532,11 @@ const uk = { lookAndFeel: { language: 'Мова', languageDescription: 'Виберіть мову інтерфейсу Firelink.', + sidebarPosition: 'Положення бічної панелі', + sidebarPositionDescription: 'Виберіть, де відображати бічну панель. Автоматичний режим враховує напрямок інтерфейсу.', + sidebarPositionAutomatic: 'Автоматично (рекомендовано)', + sidebarPositionLeft: 'Ліворуч', + sidebarPositionRight: 'Праворуч', languageSystem: 'Мова системи', languageEnglish: 'Англійська', languageChinese: 'Спрощена китайська', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index a87da82..ad37807 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -532,6 +532,11 @@ const zhCN = { lookAndFeel: { language: '语言', languageDescription: '选择 Firelink 的界面语言。', + sidebarPosition: '侧边栏位置', + sidebarPositionDescription: '选择侧边栏显示的位置。“自动”会跟随界面文字方向。', + sidebarPositionAutomatic: '自动(推荐)', + sidebarPositionLeft: '左侧', + sidebarPositionRight: '右侧', languageSystem: '系统默认', languageEnglish: '英语', languageChinese: '简体中文', diff --git a/src/index.css b/src/index.css index 36d611f..7242e0a 100644 --- a/src/index.css +++ b/src/index.css @@ -1002,6 +1002,7 @@ html[data-list-density="relaxed"] { } .app-shell { + direction: ltr; background: hsl(var(--main-bg)); } @@ -1011,6 +1012,16 @@ html[data-list-density="relaxed"] { background: transparent; } + .app-sidebar-shell--left { + order: 0; + } + + .app-sidebar-shell--right { + order: 2; + padding-inline-start: 0; + padding-inline-end: 9px; + } + .sidebar-resize-handle { position: absolute; top: 18px; @@ -1033,6 +1044,16 @@ html[data-list-density="relaxed"] { transition: background-color 100ms ease; } + .app-sidebar-shell--right .sidebar-resize-handle { + inset-inline-start: -4px; + inset-inline-end: auto; + } + + .app-sidebar-shell--right .sidebar-resize-handle::after { + inset-inline-start: 3px; + inset-inline-end: auto; + } + .sidebar-resize-handle:hover::after, body.is-resizing .sidebar-resize-handle::after { background: hsl(var(--accent-color) / 0.55); @@ -1059,19 +1080,36 @@ html[data-list-density="relaxed"] { .app-workspace { + order: 1; + direction: ltr; background: hsl(var(--workspace-bg)); } + html[dir="rtl"] .app-sidebar-panel, + html[dir="rtl"] .app-workspace { + direction: rtl; + } + .app-sidebar-reveal-button { position: absolute; top: 12px; - inset-inline-start: 88px; + left: 88px; + right: auto; z-index: 80; -webkit-app-region: no-drag; } .app-workspace--custom-window-controls .app-sidebar-reveal-button { - inset-inline-start: 124px; + left: 124px; + } + + .app-workspace--sidebar-right .app-sidebar-reveal-button { + left: auto; + right: 88px; + } + + .app-workspace--sidebar-right.app-workspace--custom-window-controls .app-sidebar-reveal-button { + right: 124px; } .app-statusbar { @@ -1584,6 +1622,7 @@ html[data-list-density="relaxed"] { position: fixed; top: 15px; left: 22px; + right: auto; z-index: 60; display: inline-flex; align-items: center; @@ -1592,6 +1631,11 @@ html[data-list-density="relaxed"] { pointer-events: auto; } +.app-shell--sidebar-right .window-controls { + left: auto; + right: 22px; +} + .window-control { width: 14px; height: 14px; @@ -1643,11 +1687,21 @@ html[data-list-density="relaxed"] { } .app-workspace--sidebar-collapsed .main-titlebar { - padding-inline-start: 124px; + padding-left: 124px; + padding-right: 18px; } .app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar { - padding-inline-start: 160px; + padding-left: 160px; +} + +.app-workspace--sidebar-right.app-workspace--sidebar-collapsed .main-titlebar { + padding-left: 18px; + padding-right: 124px; +} + +.app-workspace--sidebar-right.app-workspace--sidebar-collapsed.app-workspace--custom-window-controls .main-titlebar { + padding-right: 160px; } .main-titlebar-title { @@ -1824,6 +1878,8 @@ html[data-list-density="relaxed"] { .download-table-header > div:first-child { padding-inline-start: 0; + direction: ltr; + text-align: left; } .download-table-header > div:last-child { @@ -1949,6 +2005,8 @@ html[data-list-density="relaxed"] .download-ghost-row { } .download-file-cell { + direction: ltr; + text-align: left; display: flex; align-items: center; gap: var(--download-cell-gap); @@ -1957,6 +2015,8 @@ html[data-list-density="relaxed"] .download-ghost-row { } .download-file-name { + direction: ltr; + text-align: left; display: block; flex: 1 1 auto; min-width: 0; @@ -2344,7 +2404,7 @@ html[dir="rtl"] .sidebar-inner .mr-3 { margin-left: 0.75rem; } -html[dir="rtl"] .app-sidebar-panel { +.app-sidebar-shell--right .app-sidebar-panel { box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.045), -2px 0 10px hsl(0 0% 0% / 0.20); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 9cad3c0..110a8aa 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -70,6 +70,7 @@ const notifySettingsPersistenceError = () => { const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const; const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const; const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const; +const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const; const PROXY_MODE_VALUES = ['none', 'system', 'custom'] as const; const MEDIA_COOKIE_SOURCE_VALUES = [ 'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale' @@ -164,6 +165,8 @@ export type { Theme }; +export type SidebarPosition = 'auto' | 'left' | 'right'; + export interface SettingsState { theme: Theme; language: AppLocalePreference; @@ -180,6 +183,7 @@ export interface SettingsState { speedLimitPresetValues: number[]; logsEnabled: boolean; isSidebarVisible: boolean; + sidebarPosition: SidebarPosition; activeView: ActiveView; activeSettingsTab: SettingsTab; scheduler: SchedulerSettings; @@ -225,6 +229,7 @@ export interface SettingsState { setGlobalSpeedLimit: (limit: string) => Promise; setSpeedLimitPresetValues: (values: number[]) => void; setLogsEnabled: (enabled: boolean) => void; + setSidebarPosition: (position: SidebarPosition) => void; setActiveView: (view: ActiveView) => void; setActiveSettingsTab: (tab: SettingsTab) => void; setScheduler: (settings: SchedulerSettings) => void; @@ -288,6 +293,7 @@ export const useSettingsStore = create()( activeView: 'downloads', activeSettingsTab: 'downloads', isSidebarVisible: true, + sidebarPosition: 'auto', scheduler: { enabled: false, startTime: '00:00', @@ -362,6 +368,7 @@ export const useSettingsStore = create()( }, setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }), setLogsEnabled: (logsEnabled) => set({ logsEnabled }), + setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }), setActiveView: (view) => set({ activeView: view }), setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }), setScheduler: (scheduler) => set({ scheduler }), @@ -500,7 +507,7 @@ export const useSettingsStore = create()( { name: 'firelink-settings', storage: createJSONStorage(() => tauriStorage), - version: 4, + version: 5, migrate: (persistedState) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState as SettingsState; @@ -548,6 +555,7 @@ export const useSettingsStore = create()( speedLimitPresetValues: state.speedLimitPresetValues, logsEnabled: state.logsEnabled, isSidebarVisible: state.isSidebarVisible, + sidebarPosition: state.sidebarPosition, activeSettingsTab: state.activeSettingsTab, scheduler: state.scheduler, schedulerRunning: state.schedulerRunning, @@ -604,6 +612,9 @@ export const useSettingsStore = create()( listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity) ? persisted.listRowDensity : currentState.listRowDensity, + sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition) + ? persisted.sidebarPosition + : currentState.sidebarPosition, proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode) ? persisted.proxyMode : currentState.proxyMode, diff --git a/src/utils/platform.test.ts b/src/utils/platform.test.ts new file mode 100644 index 0000000..6ecae66 --- /dev/null +++ b/src/utils/platform.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { shouldUseCustomWindowControls } from './platform'; + +describe('shouldUseCustomWindowControls', () => { + it('keeps custom controls present while Windows/Linux detection is unresolved', () => { + expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(true); + expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true); + }); + + it('does not render custom controls for macOS', () => { + expect(shouldUseCustomWindowControls('unknown', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(false); + expect(shouldUseCustomWindowControls('macos', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)')).toBe(false); + }); + + it('only opts into the supported desktop platforms', () => { + expect(shouldUseCustomWindowControls('windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(true); + expect(shouldUseCustomWindowControls('linux', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe(true); + expect(shouldUseCustomWindowControls('android', 'Mozilla/5.0 (Linux; Android 14)')).toBe(false); + }); +}); diff --git a/src/utils/platform.ts b/src/utils/platform.ts index 9fe0806..84d4cd7 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -9,6 +9,9 @@ const fallback: PlatformInfo = { portable: false }; +export const shouldUseCustomWindowControls = (os: string, userAgent: string): boolean => + !userAgent.includes('Mac') && (os === 'windows' || os === 'linux' || os === 'unknown'); + let cached: PlatformInfo | null = null; let pending: Promise | null = null;