mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
feat(i18n): add localization infrastructure and English catalog
Refs #17
This commit is contained in:
Generated
+94
-1
@@ -18,9 +18,11 @@
|
|||||||
"@tauri-apps/plugin-log": "^2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
"lucide-react": "^1.24.0",
|
"lucide-react": "^1.24.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-i18next": "^17.0.10",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -39,6 +41,15 @@
|
|||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
@@ -1791,6 +1802,43 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||||
|
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2190,6 +2238,33 @@
|
|||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||||
|
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.2",
|
||||||
|
"html-parse-stringify": "^3.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.2.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||||
@@ -2332,7 +2407,7 @@
|
|||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc"
|
"tsc": "bin/tsc"
|
||||||
@@ -2394,6 +2469,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||||
@@ -2561,6 +2645,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
@@ -47,9 +47,11 @@
|
|||||||
"@tauri-apps/plugin-log": "^2.9.0",
|
"@tauri-apps/plugin-log": "^2.9.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
"lucide-react": "^1.24.0",
|
"lucide-react": "^1.24.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-i18next": "^17.0.10",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+50
-35
@@ -32,6 +32,8 @@ import { getVersion } from '@tauri-apps/api/app';
|
|||||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||||
import { PanelLeft } from 'lucide-react';
|
import { PanelLeft } from 'lucide-react';
|
||||||
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
||||||
|
import { syncDocumentLocale } from './i18n';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
let automaticUpdateCheckStarted = false;
|
let automaticUpdateCheckStarted = false;
|
||||||
const processingScheduleKeys = new Set<string>();
|
const processingScheduleKeys = new Set<string>();
|
||||||
@@ -99,11 +101,22 @@ const playCompletionChime = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { i18n, t } = useTranslation();
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||||
const [coreReady, setCoreReady] = useState(false);
|
const [coreReady, setCoreReady] = useState(false);
|
||||||
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
|
const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLanguageChanged = (language?: string) => {
|
||||||
|
syncDocumentLocale(language ?? i18n.language);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLanguageChanged();
|
||||||
|
i18n.on('languageChanged', handleLanguageChanged);
|
||||||
|
return () => i18n.off('languageChanged', handleLanguageChanged);
|
||||||
|
}, [i18n]);
|
||||||
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
||||||
@@ -151,7 +164,7 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Could not save settings. Check storage permissions and try again.',
|
message: t($ => $.app.settingsSaveFailed),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -181,7 +194,7 @@ function App() {
|
|||||||
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
||||||
clearPendingPostActionTimer();
|
clearPendingPostActionTimer();
|
||||||
|
|
||||||
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||||
let timerId: number | null = null;
|
let timerId: number | null = null;
|
||||||
let toastId: string | null = null;
|
let toastId: string | null = null;
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
@@ -199,13 +212,13 @@ function App() {
|
|||||||
onDismiss: clearPendingPostActionTimer,
|
onDismiss: clearPendingPostActionTimer,
|
||||||
message: (
|
message: (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span>{actionLabel} in 10 seconds.</span>
|
<span>{t($ => $.app.systemActionCountdown, { action: actionLabel })}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-button px-2 py-1"
|
className="app-button px-2 py-1"
|
||||||
onClick={cancel}
|
onClick={cancel}
|
||||||
>
|
>
|
||||||
Cancel
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -226,7 +239,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
if (activeTransfers) {
|
if (activeTransfers) {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'System action cancelled because another download is active or queued.',
|
message: t($ => $.app.systemActionCancelled),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -235,7 +248,7 @@ function App() {
|
|||||||
invoke('perform_system_action', { action }).catch(error => {
|
invoke('perform_system_action', { action }).catch(error => {
|
||||||
console.error('Scheduled post action failed:', error);
|
console.error('Scheduled post action failed:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Scheduled system action failed: ${String(error)}`,
|
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -313,12 +326,12 @@ function App() {
|
|||||||
if (!settings.showNotifications) return;
|
if (!settings.showNotifications) return;
|
||||||
|
|
||||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id);
|
||||||
const fileName = item?.fileName || 'A file';
|
const fileName = item?.fileName || t($ => $.app.unknownFile);
|
||||||
if (event.payload.status === 'completed') {
|
if (event.payload.status === 'completed') {
|
||||||
try {
|
try {
|
||||||
sendNotification({
|
sendNotification({
|
||||||
title: 'Download Complete',
|
title: t($ => $.app.downloadCompleteTitle),
|
||||||
body: `${fileName} has finished downloading.`
|
body: t($ => $.app.downloadCompleteBody, { fileName })
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Completion notification failed:', error);
|
console.error('Completion notification failed:', error);
|
||||||
@@ -326,8 +339,8 @@ function App() {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
sendNotification({
|
sendNotification({
|
||||||
title: 'Download Failed',
|
title: t($ => $.app.downloadFailedTitle),
|
||||||
body: `${fileName} failed to download.`,
|
body: t($ => $.app.downloadFailedBody, { fileName }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failure notification failed:', error);
|
console.error('Failure notification failed:', error);
|
||||||
@@ -371,7 +384,7 @@ function App() {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
console.error('Failed to initialize Firelink state:', error);
|
console.error('Failed to initialize Firelink state:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
message: t($ => $.app.initializeDownloadsFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -424,7 +437,7 @@ function App() {
|
|||||||
isActionable: true,
|
isActionable: true,
|
||||||
message: (
|
message: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p>Browser extension disconnected because its pairing token changed.</p>
|
<p>{t($ => $.app.extensionDisconnected)}</p>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -438,14 +451,14 @@ function App() {
|
|||||||
acknowledgePairingTokenChange();
|
acknowledgePairingTokenChange();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not copy pairing token: ${String(error)}`,
|
message: t($ => $.app.copyTokenFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy token
|
{t($ => $.app.copyToken)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -457,7 +470,7 @@ function App() {
|
|||||||
acknowledgePairingTokenChange();
|
acknowledgePairingTokenChange();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Integrations
|
{t($ => $.app.integrations)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,7 +480,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to hydrate extension pairing token:', error);
|
console.error('Failed to hydrate extension pairing token:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`,
|
message: t($ => $.app.credentialPersistenceFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -521,7 +534,7 @@ function App() {
|
|||||||
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
||||||
console.error('Failed to resume saved downloads after startup:', error);
|
console.error('Failed to resume saved downloads after startup:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not resume saved downloads: ${String(error)}`,
|
message: t($ => $.app.resumeDownloadsFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -545,14 +558,14 @@ function App() {
|
|||||||
.then(result => {
|
.then(result => {
|
||||||
if (result.type !== 'UpdateAvailable') return;
|
if (result.type !== 'UpdateAvailable') return;
|
||||||
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
||||||
throw new Error('The update check returned an untrusted release URL.');
|
throw new Error(t($ => $.settings.common.updateUntrustedReleaseUrl));
|
||||||
}
|
}
|
||||||
addToast({
|
addToast({
|
||||||
variant: 'info',
|
variant: 'info',
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
message: (
|
message: (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span>Firelink {result.update.version} is available.</span>
|
<span>{t($ => $.app.updateAvailable, { version: result.update.version })}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-button px-2 py-1"
|
className="app-button px-2 py-1"
|
||||||
@@ -560,7 +573,7 @@ function App() {
|
|||||||
void openUrl(result.update.release_url);
|
void openUrl(result.update.release_url);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
View release
|
{t($ => $.app.viewRelease)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -595,7 +608,7 @@ function App() {
|
|||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error('Failed to update sleep prevention:', error);
|
console.error('Failed to update sleep prevention:', error);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not update sleep prevention: ${String(error)}`,
|
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -634,7 +647,7 @@ function App() {
|
|||||||
state.setSchedulerActiveDownloadIds([]);
|
state.setSchedulerActiveDownloadIds([]);
|
||||||
state.setSchedulerRunning(false);
|
state.setSchedulerRunning(false);
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Scheduler has no valid queues selected. Update Scheduler settings.',
|
message: t($ => $.app.schedulerNoQueues),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -668,7 +681,9 @@ function App() {
|
|||||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||||
if (failedPauses > 0) {
|
if (failedPauses > 0) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`,
|
message: failedPauses === 1
|
||||||
|
? t($ => $.app.schedulerPauseOneFailed)
|
||||||
|
: t($ => $.app.schedulerPauseManyFailed, { count: failedPauses }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -702,14 +717,14 @@ function App() {
|
|||||||
|
|
||||||
if (completionState !== 'completed') {
|
if (completionState !== 'completed') {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
|
message: t($ => $.app.scheduledIncomplete),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||||
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Scheduled system action skipped because another download is active or queued.',
|
message: t($ => $.app.scheduledActionSkippedActive),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -736,7 +751,7 @@ function App() {
|
|||||||
const permission = await requestPermission();
|
const permission = await requestPermission();
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
addToast({
|
addToast({
|
||||||
message: 'System notifications are disabled for Firelink.',
|
message: t($ => $.app.notificationsDisabled),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -744,7 +759,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not configure notifications: ${String(error)}`,
|
message: t($ => $.app.notificationsFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -920,7 +935,7 @@ function App() {
|
|||||||
<div
|
<div
|
||||||
className="sidebar-resize-handle"
|
className="sidebar-resize-handle"
|
||||||
onPointerDown={startSidebarResize}
|
onPointerDown={startSidebarResize}
|
||||||
title="Resize Sidebar"
|
title={t($ => $.actions.resizeSidebar)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -934,8 +949,8 @@ function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
||||||
title="Show Sidebar"
|
title={t($ => $.actions.showSidebar)}
|
||||||
aria-label="Show Sidebar"
|
aria-label={t($ => $.actions.showSidebar)}
|
||||||
>
|
>
|
||||||
<PanelLeft size={16} strokeWidth={2} />
|
<PanelLeft size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
@@ -950,11 +965,11 @@ function App() {
|
|||||||
|
|
||||||
{/* Status Bar */}
|
{/* Status Bar */}
|
||||||
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
||||||
<span>Ready</span>
|
<span>{t($ => $.status.ready)}</span>
|
||||||
<div className="flex gap-3 tabular-nums">
|
<div className="flex gap-3 tabular-nums">
|
||||||
<span>{activeDownloadCount} active</span>
|
<span>{t($ => $.status.active, { count: activeDownloadCount })}</span>
|
||||||
<span>{queuedCount} queued</span>
|
<span>{t($ => $.status.queued, { count: queuedCount })}</span>
|
||||||
<span>{doneCount} done</span>
|
<span>{t($ => $.status.done, { count: doneCount })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import {
|
|||||||
import { getPlatformInfo } from '../utils/platform';
|
import { getPlatformInfo } from '../utils/platform';
|
||||||
import { isTransferLocked } from '../utils/downloadActions';
|
import { isTransferLocked } from '../utils/downloadActions';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
appendRequestUrlsAfterVersion,
|
appendRequestUrlsAfterVersion,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
metadataSummaryMessage,
|
metadataSummaryState,
|
||||||
playlistFilePrefix,
|
playlistFilePrefix,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
refreshFailedMetadataRows,
|
refreshFailedMetadataRows,
|
||||||
@@ -36,7 +37,6 @@ import {
|
|||||||
} from '../utils/addDownloadMetadata';
|
} from '../utils/addDownloadMetadata';
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return 'Unknown size';
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
@@ -112,6 +112,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
|
|||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
export const AddDownloadsModal = () => {
|
export const AddDownloadsModal = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const {
|
const {
|
||||||
isAddModalOpen,
|
isAddModalOpen,
|
||||||
@@ -214,7 +215,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const hasPendingInput = Boolean(
|
const hasPendingInput = Boolean(
|
||||||
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
|
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
|
||||||
);
|
);
|
||||||
if (hasPendingInput && !window.confirm('Discard this download setup?')) return;
|
if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return;
|
||||||
toggleAddModal(false);
|
toggleAddModal(false);
|
||||||
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
|
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
|
||||||
|
|
||||||
@@ -468,7 +469,7 @@ export const AddDownloadsModal = () => {
|
|||||||
});
|
});
|
||||||
if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return;
|
if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return;
|
||||||
if (!playlistData.entries.length) {
|
if (!playlistData.entries.length) {
|
||||||
throw new Error('Playlist contains no downloadable entries');
|
throw new Error(t($ => $.addDownloads.playlistNoEntries));
|
||||||
}
|
}
|
||||||
setPlaylistExpansions(current => ({
|
setPlaylistExpansions(current => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -492,7 +493,7 @@ export const AddDownloadsModal = () => {
|
|||||||
bytes,
|
bytes,
|
||||||
isApproximate,
|
isApproximate,
|
||||||
formatLabel: f.format_label || f.ext.toUpperCase(),
|
formatLabel: f.format_label || f.ext.toUpperCase(),
|
||||||
detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : 'Unknown size',
|
detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : t($ => $.addDownloads.unknownSize),
|
||||||
selector: f.format_id,
|
selector: f.format_id,
|
||||||
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
|
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
|
||||||
};
|
};
|
||||||
@@ -517,7 +518,7 @@ export const AddDownloadsModal = () => {
|
|||||||
})
|
})
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Invalid media metadata or no formats found");
|
throw new Error(t($ => $.addDownloads.invalidMediaMetadata));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let keychainPassword = null;
|
let keychainPassword = null;
|
||||||
@@ -657,7 +658,7 @@ export const AddDownloadsModal = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
|
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
|
||||||
addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isSubmittingRef.current = true;
|
isSubmittingRef.current = true;
|
||||||
@@ -723,12 +724,12 @@ export const AddDownloadsModal = () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (isUrlDupe) {
|
if (isUrlDupe) {
|
||||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
|
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) }, resolution: 'rename' });
|
||||||
} else if (hasBatchConflict) {
|
} else if (hasBatchConflict) {
|
||||||
newConflicts.push({
|
newConflicts.push({
|
||||||
id: i.toString(),
|
id: i.toString(),
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
reason: { type: 'file', msg: 'Another selected download uses this destination' },
|
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
|
||||||
resolution: 'rename',
|
resolution: 'rename',
|
||||||
replaceAllowed: false
|
replaceAllowed: false
|
||||||
});
|
});
|
||||||
@@ -767,8 +768,8 @@ export const AddDownloadsModal = () => {
|
|||||||
reason: {
|
reason: {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
msg: fileExistsInStore
|
msg: fileExistsInStore
|
||||||
? 'Existing Firelink download uses this destination'
|
? t($ => $.addDownloads.existingDownloadDestination)
|
||||||
: 'File exists on disk; rename or skip to avoid deleting unrelated data'
|
: t($ => $.addDownloads.fileExistsOnDisk)
|
||||||
},
|
},
|
||||||
resolution: 'rename',
|
resolution: 'rename',
|
||||||
replaceAllowed: fileExistsInStore
|
replaceAllowed: fileExistsInStore
|
||||||
@@ -881,7 +882,7 @@ export const AddDownloadsModal = () => {
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
if (exists) {
|
if (exists) {
|
||||||
throw new Error(`Could not find an available name for ${finalFile}.`);
|
throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile }));
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsToAdd[idx] = { ...item, file: newName };
|
itemsToAdd[idx] = { ...item, file: newName };
|
||||||
@@ -917,11 +918,11 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem && isTransferLocked(existingItem.status)) {
|
if (existingItem && isTransferLocked(existingItem.status)) {
|
||||||
throw new Error(`Pause ${existingItem.fileName} before replacing it.`);
|
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingItem) {
|
if (!existingItem) {
|
||||||
throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`);
|
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||||
}
|
}
|
||||||
// Let the backend decide whether resumable sidecars still
|
// Let the backend decide whether resumable sidecars still
|
||||||
// exist after stopping the old transfer. This avoids a race
|
// exist after stopping the old transfer. This avoids a race
|
||||||
@@ -972,7 +973,7 @@ export const AddDownloadsModal = () => {
|
|||||||
sizeBytes: item.sizeBytes
|
sizeBytes: item.sizeBytes
|
||||||
}, action);
|
}, action);
|
||||||
if (!added) {
|
if (!added) {
|
||||||
throw new Error('Backend rejected download start.');
|
throw new Error(t($ => $.addDownloads.backendRejectedStart));
|
||||||
}
|
}
|
||||||
addedCount += 1;
|
addedCount += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -983,13 +984,15 @@ export const AddDownloadsModal = () => {
|
|||||||
toggleAddModal(false);
|
toggleAddModal(false);
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `${addedCount} added, ${failures.length} failed. ${failures[0]}`,
|
message: t($ => $.addDownloads.addedWithFailures, { added: addedCount, failed: failures.length, detail: failures[0] }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
} else if (addedCount > 0) {
|
} else if (addedCount > 0) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `${addedCount} download${addedCount === 1 ? '' : 's'} added`,
|
message: addedCount === 1
|
||||||
|
? t($ => $.addDownloads.addedOne)
|
||||||
|
: t($ => $.addDownloads.addedMany, { count: addedCount }),
|
||||||
variant: 'success'
|
variant: 'success'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1073,21 @@ export const AddDownloadsModal = () => {
|
|||||||
);
|
);
|
||||||
const playlistSummaries = Object.entries(playlistExpansions)
|
const playlistSummaries = Object.entries(playlistExpansions)
|
||||||
.filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl));
|
.filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl));
|
||||||
|
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 '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) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1090,7 +1108,7 @@ export const AddDownloadsModal = () => {
|
|||||||
)
|
)
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not resolve duplicate downloads: ${String(error)}`,
|
message: t($ => $.addDownloads.duplicateResolveFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -1124,12 +1142,12 @@ export const AddDownloadsModal = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="add-download-section-title flex items-center gap-2">
|
<div className="add-download-section-title flex items-center gap-2">
|
||||||
<Link size={16} className="text-blue-500" />
|
<Link size={16} className="text-blue-500" />
|
||||||
Download Links
|
{t($ => $.addDownloads.downloadLinks)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none"
|
className="add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none"
|
||||||
placeholder={"Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from Youtube, X, TikTok, Instagram, Reddit, etc."}
|
placeholder={t($ => $.addDownloads.pastePlaceholder)}
|
||||||
value={urls}
|
value={urls}
|
||||||
onChange={(e) => setUrls(e.target.value)}
|
onChange={(e) => setUrls(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -1137,15 +1155,24 @@ export const AddDownloadsModal = () => {
|
|||||||
const total = playlist.entry_count || playlist.entries.length;
|
const total = playlist.entry_count || playlist.entries.length;
|
||||||
return (
|
return (
|
||||||
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
|
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
|
||||||
Playlist “{playlist.title}”: {playlist.entries.length}{total > playlist.entries.length ? ` of ${total}` : ''} entries loaded
|
{t($ => $.addDownloads.playlistSummary, {
|
||||||
{playlist.truncated ? ' (safe entry limit reached)' : ''}
|
title: playlist.title,
|
||||||
{playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : ''}.
|
loaded: playlist.entries.length,
|
||||||
|
total: total > playlist.entries.length ? ` of ${total}` : '',
|
||||||
|
truncated: playlist.truncated ? t($ => $.addDownloads.safeEntryLimit) : '',
|
||||||
|
skipped: playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : '',
|
||||||
|
})}.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex justify-between items-center px-1">
|
<div className="flex justify-between items-center px-1">
|
||||||
<span className="text-[11px] text-text-muted font-medium">
|
<span className="text-[11px] text-text-muted font-medium">
|
||||||
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry, {blockedMetadataCount} blocked
|
{t($ => $.addDownloads.selectedSummary, {
|
||||||
|
ready: selectedItems.filter(item => item.status === 'ready').length,
|
||||||
|
fallback: fallbackMetadataCount,
|
||||||
|
mediaRetry: failedMediaMetadataCount,
|
||||||
|
blocked: blockedMetadataCount,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1153,7 +1180,7 @@ export const AddDownloadsModal = () => {
|
|||||||
disabled={failedMetadataCount === 0}
|
disabled={failedMetadataCount === 0}
|
||||||
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
|
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} /> Refresh Metadata
|
<RefreshCw size={12} /> {t($ => $.addDownloads.refreshMetadata)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1161,33 +1188,33 @@ export const AddDownloadsModal = () => {
|
|||||||
disabled={parsedItems.length === 0}
|
disabled={parsedItems.length === 0}
|
||||||
className="add-download-link-button ml-3 text-[11px] font-medium"
|
className="add-download-link-button ml-3 text-[11px] font-medium"
|
||||||
>
|
>
|
||||||
{allRowsSelected ? 'Clear selection' : 'Select all'}
|
{allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<SummaryBox title="Files" value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
|
<SummaryBox title={t($ => $.addDownloads.files)} value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
|
||||||
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
|
<SummaryBox title={t($ => $.addDownloads.required)} value={requiredStr === 'Unknown' ? t($ => $.addDownloads.unknown) : requiredStr} icon={Database} color="text-orange-500" />
|
||||||
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
|
<SummaryBox title={t($ => $.addDownloads.free)} value={freeSpace === 'Unknown' ? t($ => $.addDownloads.unknown) : freeSpace} icon={HardDrive} color="text-green-500" />
|
||||||
<SummaryBox title="Unknown" value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
<SummaryBox title={t($ => $.addDownloads.unknown)} value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
|
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||||
<div className="add-download-section-title flex items-center gap-2">
|
<div className="add-download-section-title flex items-center gap-2">
|
||||||
<ArrowRight size={16} className="text-blue-500" />
|
<ArrowRight size={16} className="text-blue-500" />
|
||||||
Preview
|
{t($ => $.addDownloads.preview)}
|
||||||
</div>
|
</div>
|
||||||
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
|
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
|
||||||
<div className="add-download-preview-header px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
<div className="add-download-preview-header px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
<div className="flex-[2]">File</div>
|
<div className="flex-[2]">{t($ => $.addDownloads.file)}</div>
|
||||||
<div className="flex-1">Size</div>
|
<div className="flex-1">{t($ => $.addDownloads.size)}</div>
|
||||||
<div className="flex-[1.5]">Status</div>
|
<div className="flex-[1.5]">{t($ => $.addDownloads.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
|
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label={t($ => $.addDownloads.downloadPreview)}>
|
||||||
{parsedItems.length === 0 ? (
|
{parsedItems.length === 0 ? (
|
||||||
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
|
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
|
||||||
No links added yet.
|
{t($ => $.addDownloads.noLinks)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
parsedItems.map((item, i) => (
|
parsedItems.map((item, i) => (
|
||||||
@@ -1215,22 +1242,22 @@ export const AddDownloadsModal = () => {
|
|||||||
checked={item.selected !== false}
|
checked={item.selected !== false}
|
||||||
onChange={() => toggleRowSelection(i)}
|
onChange={() => toggleRowSelection(i)}
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
aria-label={`Select ${item.file}`}
|
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
|
||||||
className="mr-2 shrink-0 accent-purple-500"
|
className="mr-2 shrink-0 accent-purple-500"
|
||||||
/>
|
/>
|
||||||
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
|
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
|
||||||
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
|
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
|
||||||
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
|
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
|
||||||
{item.status === 'loading' ? (
|
{item.status === 'loading' ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? 'Fetching playlist...' : 'Fetching...'}
|
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? t($ => $.addDownloads.fetchingPlaylist) : t($ => $.addDownloads.fetching)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
item.status === 'metadata-error'
|
item.status === 'metadata-error'
|
||||||
? item.metadataBlockedReason === 'unsafe-url' ? 'Unsafe URL' : item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback'
|
? item.metadataBlockedReason === 'unsafe-url' ? t($ => $.addDownloads.unsafeUrl) : item.isPlaylist ? t($ => $.addDownloads.playlistFailed) : item.isMedia ? t($ => $.addDownloads.metadataFailed) : t($ => $.addDownloads.fallback)
|
||||||
: item.status === 'invalid'
|
: item.status === 'invalid'
|
||||||
? 'Invalid'
|
? t($ => $.addDownloads.invalid)
|
||||||
: 'Ready'
|
: t($ => $.addDownloads.ready)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1255,10 +1282,10 @@ export const AddDownloadsModal = () => {
|
|||||||
<Video size={48} />
|
<Video size={48} />
|
||||||
</div>
|
</div>
|
||||||
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
|
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
|
||||||
<Video size={16} className="text-purple-500" /> Media Format
|
<Video size={16} className="text-purple-500" /> {t($ => $.addDownloads.mediaFormat)}
|
||||||
{parsedItems[selectedItemIndex].playlistSourceUrl && (
|
{parsedItems[selectedItemIndex].playlistSourceUrl && (
|
||||||
<span className="text-[10px] font-normal text-text-muted">
|
<span className="text-[10px] font-normal text-text-muted">
|
||||||
Playlist item {parsedItems[selectedItemIndex].playlistIndex || '?'}
|
{t($ => $.addDownloads.playlistItem, { index: parsedItems[selectedItemIndex].playlistIndex || '?' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1266,13 +1293,13 @@ export const AddDownloadsModal = () => {
|
|||||||
{parsedItems[selectedItemIndex].status === 'loading' ? (
|
{parsedItems[selectedItemIndex].status === 'loading' ? (
|
||||||
<div className="flex flex-col items-center justify-center py-6 gap-3 relative z-10">
|
<div className="flex flex-col items-center justify-center py-6 gap-3 relative z-10">
|
||||||
<RefreshCw size={24} className="animate-spin text-purple-500" />
|
<RefreshCw size={24} className="animate-spin text-purple-500" />
|
||||||
<span className="text-xs text-text-muted font-medium animate-pulse">Fetching media streams...</span>
|
<span className="text-xs text-text-muted font-medium animate-pulse">{t($ => $.addDownloads.fetchingMediaStreams)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : parsedItems[selectedItemIndex].formats ? (
|
) : parsedItems[selectedItemIndex].formats ? (
|
||||||
<div className="space-y-3 relative z-10">
|
<div className="space-y-3 relative z-10">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">Available Streams</label>
|
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">{t($ => $.addDownloads.availableStreams)}</label>
|
||||||
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1" role="radiogroup" aria-label="Available media streams">
|
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1" role="radiogroup" aria-label={t($ => $.addDownloads.availableMediaStreams)}>
|
||||||
{parsedItems[selectedItemIndex].formats!.map((f, idx) => {
|
{parsedItems[selectedItemIndex].formats!.map((f, idx) => {
|
||||||
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
|
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
|
||||||
const Icon = f.type === 'Audio' ? Music : Film;
|
const Icon = f.type === 'Audio' ? Music : Film;
|
||||||
@@ -1309,7 +1336,7 @@ export const AddDownloadsModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-4 relative z-10">
|
<div className="flex flex-col items-center justify-center py-4 relative z-10">
|
||||||
<span className="text-xs text-red-400 font-medium">Metadata unavailable. Refresh metadata before adding this media.</span>
|
<span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1318,7 +1345,7 @@ export const AddDownloadsModal = () => {
|
|||||||
{/* Save Location */}
|
{/* Save Location */}
|
||||||
<section className="add-download-section">
|
<section className="add-download-section">
|
||||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||||
<FolderPlus size={16} className="text-blue-500" /> Save Location
|
<FolderPlus size={16} className="text-blue-500" /> {t($ => $.addDownloads.saveLocation)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1326,23 +1353,23 @@ export const AddDownloadsModal = () => {
|
|||||||
readOnly
|
readOnly
|
||||||
value={saveLocation}
|
value={saveLocation}
|
||||||
className="add-download-control flex-1 px-3 py-1.5 text-xs text-text-muted font-mono"
|
className="add-download-control flex-1 px-3 py-1.5 text-xs text-text-muted font-mono"
|
||||||
aria-label="Save location"
|
aria-label={t($ => $.addDownloads.saveLocation)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleBrowse}
|
onClick={handleBrowse}
|
||||||
className="add-download-button add-download-button-secondary px-3 text-xs font-medium"
|
className="add-download-button add-download-button-secondary px-3 text-xs font-medium"
|
||||||
>
|
>
|
||||||
Browse
|
{t($ => $.addDownloads.browse)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{parsedItems.length > 1 && !isSaveLocationManual && (
|
{parsedItems.length > 1 && !isSaveLocationManual && (
|
||||||
<p className="mt-2 text-[11px] text-text-muted">
|
<p className="mt-2 text-[11px] text-text-muted">
|
||||||
Files will be organized into category folders.
|
{t($ => $.addDownloads.categoryFolders)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isSaveLocationManual && (
|
{isSaveLocationManual && (
|
||||||
<p className="mt-2 text-[11px] text-text-muted">
|
<p className="mt-2 text-[11px] text-text-muted">
|
||||||
All selected downloads will use this folder.
|
{t($ => $.addDownloads.sharedFolder)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1350,13 +1377,13 @@ export const AddDownloadsModal = () => {
|
|||||||
{/* Transfer Settings */}
|
{/* Transfer Settings */}
|
||||||
<section className="add-download-section">
|
<section className="add-download-section">
|
||||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||||
<Settings size={16} className="text-blue-500" /> Transfer Settings
|
<Settings size={16} className="text-blue-500" /> {t($ => $.addDownloads.transferSettings)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
|
<label className="text-xs text-text-secondary font-medium">{t($ => $.addDownloads.connectionsPerFile)}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label="Connections per file" />
|
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label={t($ => $.addDownloads.connectionsPerFileAria)} />
|
||||||
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
|
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1364,11 +1391,11 @@ export const AddDownloadsModal = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
||||||
<input type="checkbox" checked={speedLimitEnabled} onChange={e=>setSpeedLimitEnabled(e.target.checked)} className="add-download-checkbox" />
|
<input type="checkbox" checked={speedLimitEnabled} onChange={e=>setSpeedLimitEnabled(e.target.checked)} className="add-download-checkbox" />
|
||||||
Limit speed per file
|
{t($ => $.addDownloads.limitSpeedPerFile)}
|
||||||
</label>
|
</label>
|
||||||
{speedLimitEnabled && (
|
{speedLimitEnabled && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="add-download-control w-16 px-2 py-1 text-xs font-mono" aria-label="Speed limit per file" />
|
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="add-download-control w-16 px-2 py-1 text-xs font-mono" aria-label={t($ => $.addDownloads.speedLimitPerFileAria)} />
|
||||||
<span className="text-[10px] text-text-muted">KiB/s</span>
|
<span className="text-[10px] text-text-muted">KiB/s</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1379,17 +1406,17 @@ export const AddDownloadsModal = () => {
|
|||||||
{/* Authorization */}
|
{/* Authorization */}
|
||||||
<section className="add-download-section">
|
<section className="add-download-section">
|
||||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||||
<Shield size={16} className="text-blue-500" /> Authorization
|
<Shield size={16} className="text-blue-500" /> {t($ => $.addDownloads.authorization)}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer mb-3">
|
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer mb-3">
|
||||||
<input type="checkbox" checked={useAuth} onChange={e=>setUseAuth(e.target.checked)} className="add-download-checkbox" />
|
<input type="checkbox" checked={useAuth} onChange={e=>setUseAuth(e.target.checked)} className="add-download-checkbox" />
|
||||||
Use authorization
|
{t($ => $.addDownloads.useAuthorization)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{useAuth && (
|
{useAuth && (
|
||||||
<div className="space-y-2.5 pl-5 border-l-2 border-border-modal/50">
|
<div className="space-y-2.5 pl-5 border-l-2 border-border-modal/50">
|
||||||
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder="Username" className="add-download-control w-full px-3 py-1.5 text-xs" />
|
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder={t($ => $.addDownloads.username)} className="add-download-control w-full px-3 py-1.5 text-xs" />
|
||||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder="Password" className="add-download-control w-full px-3 py-1.5 text-xs" />
|
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder={t($ => $.addDownloads.password)} className="add-download-control w-full px-3 py-1.5 text-xs" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1402,27 +1429,27 @@ export const AddDownloadsModal = () => {
|
|||||||
aria-expanded={advancedExpanded}
|
aria-expanded={advancedExpanded}
|
||||||
>
|
>
|
||||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
Advanced Transfer
|
{t($ => $.addDownloads.advancedTransfer)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{advancedExpanded && (
|
{advancedExpanded && (
|
||||||
<div className="mt-4 space-y-4 pl-6">
|
<div className="mt-4 space-y-4 pl-6">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
||||||
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="add-download-checkbox" />
|
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="add-download-checkbox" />
|
||||||
Verify Checksum
|
{t($ => $.addDownloads.verifyChecksum)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{checksumEnabled && (
|
{checksumEnabled && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="add-download-control add-download-select w-24 px-2 text-xs" aria-label="Checksum algorithm">
|
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="add-download-control add-download-select w-24 px-2 text-xs" aria-label={t($ => $.addDownloads.checksumAlgorithm)}>
|
||||||
<option>MD5</option><option>SHA-1</option><option>SHA-256</option>
|
<option>MD5</option><option>SHA-1</option><option>SHA-256</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} placeholder="Expected digest" className="add-download-control flex-1 px-3 py-1.5 text-xs font-mono" />
|
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} placeholder={t($ => $.addDownloads.expectedDigest)} className="add-download-control flex-1 px-3 py-1.5 text-xs font-mono" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Headers</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.headers)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={headers}
|
value={headers}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@@ -1430,11 +1457,11 @@ export const AddDownloadsModal = () => {
|
|||||||
setHeaders(e.target.value);
|
setHeaders(e.target.value);
|
||||||
}}
|
}}
|
||||||
className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none"
|
className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none"
|
||||||
aria-label="Request headers"
|
aria-label={t($ => $.addDownloads.requestHeaders)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Cookies</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.cookies)}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={cookies}
|
value={cookies}
|
||||||
@@ -1442,14 +1469,14 @@ export const AddDownloadsModal = () => {
|
|||||||
cookiesManuallyEditedRef.current = true;
|
cookiesManuallyEditedRef.current = true;
|
||||||
setCookies(e.target.value);
|
setCookies(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="name=value; other=value"
|
placeholder={t($ => $.addDownloads.cookiePlaceholder)}
|
||||||
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
||||||
aria-label="Cookies"
|
aria-label={t($ => $.addDownloads.cookies)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.mirrors)}</label>
|
||||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none" aria-label="Mirrors" />
|
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none" aria-label={t($ => $.addDownloads.mirrors)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1462,11 +1489,11 @@ export const AddDownloadsModal = () => {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="add-download-footer p-4 flex items-center shrink-0">
|
<div className="add-download-footer p-4 flex items-center shrink-0">
|
||||||
<div className="text-[11px] text-text-muted font-medium flex-1">
|
<div className="text-[11px] text-text-muted font-medium flex-1">
|
||||||
{metadataSummaryMessage(parsedItems)}
|
{metadataSummary}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-secondary px-4 text-xs">
|
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-secondary px-4 text-xs">
|
||||||
Cancel
|
{t($ => $.addDownloads.cancel)}
|
||||||
</button>
|
</button>
|
||||||
<div ref={actionMenuRef} className="relative flex gap-2.5">
|
<div ref={actionMenuRef} className="relative flex gap-2.5">
|
||||||
<button
|
<button
|
||||||
@@ -1474,7 +1501,7 @@ export const AddDownloadsModal = () => {
|
|||||||
disabled={!canSubmit || isSubmitting}
|
disabled={!canSubmit || isSubmitting}
|
||||||
className="add-download-button add-download-button-primary px-5 text-xs"
|
className="add-download-button add-download-button-primary px-5 text-xs"
|
||||||
>
|
>
|
||||||
<Play size={12} fill="currentColor" /> Start Downloads
|
<Play size={12} fill="currentColor" /> {t($ => $.addDownloads.startDownloads)}
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -1482,11 +1509,11 @@ export const AddDownloadsModal = () => {
|
|||||||
onClick={() => setIsQueueMenuOpen(open => !open)}
|
onClick={() => setIsQueueMenuOpen(open => !open)}
|
||||||
disabled={!canSubmit || isSubmitting}
|
disabled={!canSubmit || isSubmitting}
|
||||||
className="add-download-button add-download-button-secondary px-4 text-xs"
|
className="add-download-button add-download-button-secondary px-4 text-xs"
|
||||||
aria-label="Add to queue"
|
aria-label={t($ => $.addDownloads.addToQueue)}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={isQueueMenuOpen}
|
aria-expanded={isQueueMenuOpen}
|
||||||
>
|
>
|
||||||
Add to queue <ChevronDown size={14} className="ml-1" />
|
{t($ => $.addDownloads.addToQueue)} <ChevronDown size={14} className="ml-1" />
|
||||||
</button>
|
</button>
|
||||||
{isQueueMenuOpen && (
|
{isQueueMenuOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useDownloadStore } from '../store/useDownloadStore';
|
import { useDownloadStore } from '../store/useDownloadStore';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export const DeleteConfirmationModal: React.FC = () => {
|
export const DeleteConfirmationModal: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
|
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
@@ -50,7 +52,11 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
setErrorMessage(`${succeeded} removed, ${failures.length} failed: ${failures[0]}`);
|
setErrorMessage(t($ => $.dialogs.removeDownload.errorSummary, {
|
||||||
|
succeeded,
|
||||||
|
failed: failures.length,
|
||||||
|
detail: failures[0],
|
||||||
|
}));
|
||||||
setIsRemoving(false);
|
setIsRemoving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,6 +66,7 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
|
|
||||||
const handleRemoveFromList = () => removeMany(false);
|
const handleRemoveFromList = () => removeMany(false);
|
||||||
const handleDeleteFile = () => removeMany(true);
|
const handleDeleteFile = () => removeMany(true);
|
||||||
|
const itemCount = deleteModalState.downloadIds?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -78,11 +85,13 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
|
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
|
||||||
<AlertTriangle size={20} className="text-red-400" />
|
<AlertTriangle size={20} className="text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-text-primary m-0">Remove Download</h2>
|
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.dialogs.removeDownload.title)}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
|
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
|
||||||
{`Are you sure you want to remove ${deleteModalState.downloadIds?.length && deleteModalState.downloadIds.length > 1 ? 'these ' + deleteModalState.downloadIds.length + ' items' : 'this item'} from the list? You can also choose to delete the underlying file from your hard drive.`}
|
{itemCount > 1
|
||||||
|
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
|
||||||
|
: t($ => $.dialogs.removeDownload.confirmationSingle)}
|
||||||
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
|
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,21 +101,21 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
disabled={isRemoving}
|
disabled={isRemoving}
|
||||||
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveFromList}
|
onClick={handleRemoveFromList}
|
||||||
disabled={isRemoving}
|
disabled={isRemoving}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Remove
|
{t($ => $.dialogs.removeDownload.remove)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteFile}
|
onClick={handleDeleteFile}
|
||||||
disabled={isRemoving}
|
disabled={isRemoving}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Delete file
|
{t($ => $.dialogs.removeDownload.deleteFile)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React from 'react';
|
|||||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||||
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
downloadProgressColorClass,
|
downloadProgressColorClass,
|
||||||
formatDownloadTotal,
|
formatDownloadTotal,
|
||||||
@@ -38,6 +39,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onMoveInQueue,
|
onMoveInQueue,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||||
|
|
||||||
const displayFraction = download.status === 'downloading'
|
const displayFraction = download.status === 'downloading'
|
||||||
@@ -47,12 +49,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const displaySpeed = download.status === 'downloading'
|
const displaySpeed = download.status === 'downloading'
|
||||||
? liveProgress?.speed ?? download.speed
|
? liveProgress?.speed ?? download.speed
|
||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? 'Processing...'
|
? t($ => $.downloads.values.processing)
|
||||||
: '-';
|
: '-';
|
||||||
const displayEta = download.status === 'downloading'
|
const displayEta = download.status === 'downloading'
|
||||||
? liveProgress?.eta ?? download.eta
|
? liveProgress?.eta ?? download.eta
|
||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? 'Muxing...'
|
? t($ => $.downloads.values.muxing)
|
||||||
: '-';
|
: '-';
|
||||||
const sizeDisplay = resolveDownloadSizeDisplay({
|
const sizeDisplay = resolveDownloadSizeDisplay({
|
||||||
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
|
||||||
@@ -62,9 +64,22 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
});
|
});
|
||||||
const hasDownloadedAmount = download.status !== 'completed' &&
|
const hasDownloadedAmount = download.status !== 'completed' &&
|
||||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||||
const completedSizeLabel = download.status === 'completed'
|
const completedSizeLabel = (() => {
|
||||||
? formatDownloadTotal(sizeDisplay)
|
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||||
: sizeDisplay.fallback;
|
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||||
|
})();
|
||||||
|
const downloadStatusLabel = t($ => $.downloads.status[download.status]);
|
||||||
|
const downloadedSizeLabel = sizeDisplay.totalIsEstimate
|
||||||
|
? t($ => $.downloads.size.downloadedOfApproximate, {
|
||||||
|
downloaded: sizeDisplay.downloaded ?? '',
|
||||||
|
total: sizeDisplay.total ?? '',
|
||||||
|
unit: sizeDisplay.unit ?? '',
|
||||||
|
})
|
||||||
|
: t($ => $.downloads.size.downloadedOf, {
|
||||||
|
downloaded: sizeDisplay.downloaded ?? '',
|
||||||
|
total: sizeDisplay.total ?? '',
|
||||||
|
unit: sizeDisplay.unit ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -88,10 +103,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
<div
|
<div
|
||||||
className="download-cell-truncate download-size-cell tabular-nums"
|
className="download-cell-truncate download-size-cell tabular-nums"
|
||||||
title={hasDownloadedAmount
|
title={hasDownloadedAmount
|
||||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
? downloadedSizeLabel
|
||||||
: completedSizeLabel}
|
: completedSizeLabel}
|
||||||
aria-label={hasDownloadedAmount
|
aria-label={hasDownloadedAmount
|
||||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
? downloadedSizeLabel
|
||||||
: completedSizeLabel}
|
: completedSizeLabel}
|
||||||
>
|
>
|
||||||
<div className="download-size-content">
|
<div className="download-size-content">
|
||||||
@@ -111,7 +126,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
|
|
||||||
<div className="download-status-cell">
|
<div className="download-status-cell">
|
||||||
{download.status === 'completed' ? (
|
{download.status === 'completed' ? (
|
||||||
<span className="download-status download-status-completed" title="Completed">Completed</span>
|
<span className="download-status download-status-completed" title={downloadStatusLabel}>{downloadStatusLabel}</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="download-progress-track">
|
<div className="download-progress-track">
|
||||||
@@ -130,12 +145,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
||||||
? download.lastError
|
? download.lastError
|
||||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||||
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
|
? `${downloadStatusLabel} #${queueIndex + 1}`
|
||||||
: download.status === 'downloading'
|
: download.status === 'downloading'
|
||||||
? displayPercent
|
? displayPercent
|
||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? 'Processing'
|
? downloadStatusLabel
|
||||||
: download.status.charAt(0).toUpperCase() + download.status.slice(1)
|
: downloadStatusLabel
|
||||||
}
|
}
|
||||||
className={`download-status flex items-center gap-1.5 ${
|
className={`download-status flex items-center gap-1.5 ${
|
||||||
download.status === 'paused' ? 'download-status-paused' :
|
download.status === 'paused' ? 'download-status-paused' :
|
||||||
@@ -150,15 +165,15 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
<>
|
<>
|
||||||
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
|
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
|
{downloadStatusLabel} #{queueIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : download.status === 'downloading' ? (
|
) : download.status === 'downloading' ? (
|
||||||
displayPercent
|
displayPercent
|
||||||
) : download.status === 'processing' ? (
|
) : download.status === 'processing' ? (
|
||||||
'Processing'
|
downloadStatusLabel
|
||||||
) : (
|
) : (
|
||||||
download.status.charAt(0).toUpperCase() + download.status.slice(1)
|
downloadStatusLabel
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -202,7 +217,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onClick={() => onMoveInQueue(download.id, 'up')}
|
onClick={() => onMoveInQueue(download.id, 'up')}
|
||||||
disabled={queueIndex === 0}
|
disabled={queueIndex === 0}
|
||||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||||
title="Move Up"
|
title={t($ => $.downloads.actions.moveUp)}
|
||||||
>
|
>
|
||||||
<ArrowUp size={14} />
|
<ArrowUp size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -210,19 +225,19 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
onClick={() => onMoveInQueue(download.id, 'down')}
|
onClick={() => onMoveInQueue(download.id, 'down')}
|
||||||
disabled={queueIndex === queueLength - 1}
|
disabled={queueIndex === queueLength - 1}
|
||||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||||
title="Move Down"
|
title={t($ => $.downloads.actions.moveDown)}
|
||||||
>
|
>
|
||||||
<ArrowDown size={14} />
|
<ArrowDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canPauseDownload(download.status) && (
|
{canPauseDownload(download.status) && (
|
||||||
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
|
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
|
||||||
<Pause size={14} fill="currentColor" />
|
<Pause size={14} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canStartDownload(download.status) && (
|
{canStartDownload(download.status) && (
|
||||||
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={startActionLabel(download.status)}>
|
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)}>
|
||||||
<Play size={14} fill="currentColor" />
|
<Play size={14} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -232,7 +247,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||||
}}
|
}}
|
||||||
className="app-icon-button h-7 w-7"
|
className="app-icon-button h-7 w-7"
|
||||||
title="Options"
|
title={t($ => $.downloads.actions.options)}
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
canPauseDownload,
|
canPauseDownload,
|
||||||
canRedownload,
|
canRedownload,
|
||||||
canStartDownload,
|
canStartDownload
|
||||||
startActionLabel
|
|
||||||
} from '../utils/downloadActions';
|
} from '../utils/downloadActions';
|
||||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
sortDownloads,
|
sortDownloads,
|
||||||
type DownloadSortColumn,
|
type DownloadSortColumn,
|
||||||
@@ -33,6 +33,7 @@ const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
|
|||||||
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
|
||||||
|
|
||||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const isMac = navigator.userAgent.includes('Mac');
|
const isMac = navigator.userAgent.includes('Mac');
|
||||||
@@ -174,7 +175,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
await invoke('open_downloaded_file', { path: fullPath });
|
await invoke('open_downloaded_file', { path: fullPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open file:", error);
|
console.error("Failed to open file:", error);
|
||||||
showInteractionError('Could not open downloaded file', error);
|
showInteractionError(t($ => $.downloadTable.openFileFailed), error);
|
||||||
}
|
}
|
||||||
}, [getDownloadPath, openProperties, showInteractionError]);
|
}, [getDownloadPath, openProperties, showInteractionError]);
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
await invoke('reveal_in_file_manager', { path: pathToReveal });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to show in Finder:", error);
|
console.error("Failed to show in Finder:", error);
|
||||||
showInteractionError('Could not show download in Finder', error);
|
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,13 +352,20 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
if (filter.startsWith('queue:')) {
|
if (filter.startsWith('queue:')) {
|
||||||
const qid = filter.replace('queue:', '');
|
const qid = filter.replace('queue:', '');
|
||||||
const queue = useDownloadStore.getState().queues.find(q => q.id === qid);
|
const queue = useDownloadStore.getState().queues.find(q => q.id === qid);
|
||||||
return queue ? queue.name : 'Unknown Queue';
|
return queue ? queue.name : t($ => $.downloadTable.unknownQueue);
|
||||||
}
|
}
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'all': return 'All Downloads';
|
case 'all': return t($ => $.downloadTable.allDownloads);
|
||||||
case 'active': return 'Active';
|
case 'active': return t($ => $.downloadTable.active);
|
||||||
case 'completed': return 'Completed';
|
case 'completed': return t($ => $.downloadTable.completed);
|
||||||
case 'unfinished': return 'Unfinished';
|
case 'unfinished': return t($ => $.downloadTable.unfinished);
|
||||||
|
case 'Musics': return t($ => $.navigation.categories.musics);
|
||||||
|
case 'Movies': return t($ => $.navigation.categories.movies);
|
||||||
|
case 'Compressed': return t($ => $.navigation.categories.compressed);
|
||||||
|
case 'Documents': return t($ => $.navigation.categories.documents);
|
||||||
|
case 'Pictures': return t($ => $.navigation.categories.pictures);
|
||||||
|
case 'Applications': return t($ => $.navigation.categories.applications);
|
||||||
|
case 'Other': return t($ => $.navigation.categories.other);
|
||||||
default: return filter;
|
default: return filter;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -365,7 +373,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const handlePause = useCallback(async (id: string, skipConfirm = false) => {
|
const handlePause = useCallback(async (id: string, skipConfirm = false) => {
|
||||||
const download = useDownloadStore.getState().downloads.find(d => d.id === id);
|
const download = useDownloadStore.getState().downloads.find(d => d.id === id);
|
||||||
if (!skipConfirm && download && download.resumable === false) {
|
if (!skipConfirm && download && download.resumable === false) {
|
||||||
const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?");
|
const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne));
|
||||||
if (!confirmPause) {
|
if (!confirmPause) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -375,7 +383,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
await useDownloadStore.getState().pauseDownload(id);
|
await useDownloadStore.getState().pauseDownload(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to pause:", e);
|
console.error("Failed to pause:", e);
|
||||||
showInteractionError('Could not pause download', e);
|
showInteractionError(t($ => $.downloadTable.pauseFailed), e);
|
||||||
}
|
}
|
||||||
}, [showInteractionError]);
|
}, [showInteractionError]);
|
||||||
|
|
||||||
@@ -383,11 +391,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
try {
|
try {
|
||||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
||||||
if (!resumed) {
|
if (!resumed) {
|
||||||
throw new Error('The backend rejected the start/resume request.');
|
throw new Error(t($ => $.downloadTable.backendRejectedStart));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to resume:", error);
|
console.error("Failed to resume:", error);
|
||||||
showInteractionError(`Could not resume ${item.fileName}`, error);
|
showInteractionError(t($ => $.downloadTable.resumeFailed, { fileName: item.fileName }), error);
|
||||||
}
|
}
|
||||||
}, [showInteractionError]);
|
}, [showInteractionError]);
|
||||||
|
|
||||||
@@ -480,7 +488,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
onClick={() => void handleAddDownload()}
|
onClick={() => void handleAddDownload()}
|
||||||
disabled={isReadingClipboard}
|
disabled={isReadingClipboard}
|
||||||
aria-busy={isReadingClipboard}
|
aria-busy={isReadingClipboard}
|
||||||
title="Add Download"
|
title={t($ => $.downloadTable.addDownload)}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -491,7 +499,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
|
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
|
||||||
}}
|
}}
|
||||||
title="Resume All"
|
title={t($ => $.downloadTable.resumeAll)}
|
||||||
>
|
>
|
||||||
<Play size={15} fill="currentColor" />
|
<Play size={15} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
@@ -505,8 +513,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
if (nonResumableCount > 0) {
|
if (nonResumableCount > 0) {
|
||||||
const confirmPause = window.confirm(
|
const confirmPause = window.confirm(
|
||||||
nonResumableCount === 1
|
nonResumableCount === 1
|
||||||
? "1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?"
|
? t($ => $.downloadTable.nonResumableOne)
|
||||||
: `${nonResumableCount} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?`
|
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
|
||||||
);
|
);
|
||||||
if (!confirmPause) {
|
if (!confirmPause) {
|
||||||
return;
|
return;
|
||||||
@@ -515,7 +523,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
// Skip the individual check by passing a flag to handlePause, or just invoking directly.
|
// Skip the individual check by passing a flag to handlePause, or just invoking directly.
|
||||||
toPause.forEach(d => handlePause(d.id, true));
|
toPause.forEach(d => handlePause(d.id, true));
|
||||||
}}
|
}}
|
||||||
title="Pause All"
|
title={t($ => $.downloadTable.pauseAll)}
|
||||||
>
|
>
|
||||||
<Pause size={15} fill="currentColor" />
|
<Pause size={15} fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
@@ -532,15 +540,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
<div className="downloads-table flex-1 flex flex-col">
|
<div className="downloads-table flex-1 flex flex-col">
|
||||||
<div className="download-table-scroll">
|
<div className="download-table-scroll">
|
||||||
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
|
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
|
||||||
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
|
{[
|
||||||
|
{ key: 'File Name' as const, label: t($ => $.downloadTable.headers.fileName) },
|
||||||
|
{ key: 'Size' as const, label: t($ => $.downloadTable.headers.size) },
|
||||||
|
{ key: 'Status' as const, label: t($ => $.downloadTable.headers.status) },
|
||||||
|
{ key: 'Speed' as const, label: t($ => $.downloadTable.headers.speed) },
|
||||||
|
{ key: 'ETA' as const, label: t($ => $.downloadTable.headers.eta) },
|
||||||
|
{ key: 'Date Added' as const, label: t($ => $.downloadTable.headers.dateAdded) },
|
||||||
|
].map(({ key, label }, index) => (
|
||||||
<div
|
<div
|
||||||
key={label}
|
key={key}
|
||||||
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
|
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
|
||||||
onClick={() => handleSort(label as DownloadSortColumn)}
|
onClick={() => handleSort(key)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 w-full h-full select-none">
|
<div className="flex items-center gap-1 w-full h-full select-none">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && (
|
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === key && (
|
||||||
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
|
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
|
||||||
? <ChevronUp size={14} />
|
? <ChevronUp size={14} />
|
||||||
: <ChevronDown size={14} />
|
: <ChevronDown size={14} />
|
||||||
@@ -560,16 +575,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
<div className="downloads-empty-state">
|
<div className="downloads-empty-state">
|
||||||
<ArrowDownCircle aria-hidden="true" />
|
<ArrowDownCircle aria-hidden="true" />
|
||||||
<div className="downloads-empty-title">
|
<div className="downloads-empty-title">
|
||||||
{isQueueFilter ? 'Queue is empty' : filter === 'completed' ? 'No Completed Downloads' : 'No Downloads'}
|
{isQueueFilter ? t($ => $.downloadTable.queueEmpty) : filter === 'completed' ? t($ => $.downloadTable.noCompletedDownloads) : t($ => $.downloadTable.noDownloads)}
|
||||||
</div>
|
</div>
|
||||||
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
|
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
|
||||||
{isQueueFilter ? (
|
{isQueueFilter ? (
|
||||||
'Add downloads to this queue from an item menu or the Add window.'
|
t($ => $.downloadTable.queueEmptyDescription)
|
||||||
) : filter === 'completed' ? (
|
) : filter === 'completed' ? (
|
||||||
'Completed downloads will appear here.'
|
t($ => $.downloadTable.completedDescription)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
{t($ => $.downloadTable.clickToAdd)} <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> {t($ => $.downloadTable.addButtonOr)}
|
||||||
<span className="flex items-center mx-1.5">
|
<span className="flex items-center mx-1.5">
|
||||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||||
@@ -579,7 +594,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
to add downloads
|
{t($ => $.downloadTable.toAddDownloads)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,7 +657,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Start/Resume
|
{t($ => $.downloadTable.startResume)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -654,8 +669,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
if (nonResumableCount > 0) {
|
if (nonResumableCount > 0) {
|
||||||
const confirmPause = window.confirm(
|
const confirmPause = window.confirm(
|
||||||
nonResumableCount === 1
|
nonResumableCount === 1
|
||||||
? "1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?"
|
? t($ => $.downloadTable.nonResumableOne)
|
||||||
: `${nonResumableCount} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?`
|
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
|
||||||
);
|
);
|
||||||
if (!confirmPause) {
|
if (!confirmPause) {
|
||||||
return;
|
return;
|
||||||
@@ -665,7 +680,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Pause
|
{t($ => $.downloadTable.pause)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -676,7 +691,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
{itemsToQueue.length > 0 && (
|
{itemsToQueue.length > 0 && (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||||
Add to Queue
|
{t($ => $.downloadTable.addToQueue)}
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||||
@@ -684,7 +699,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
<button key={q.id} onClick={() => {
|
<button key={q.id} onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
|
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
|
||||||
showInteractionError('Could not move downloads to queue', error);
|
showInteractionError(t($ => $.downloadTable.moveManyFailed), error);
|
||||||
});
|
});
|
||||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||||
{q.name}
|
{q.name}
|
||||||
@@ -704,12 +719,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
navigator.clipboard.writeText(urls).catch(error => {
|
navigator.clipboard.writeText(urls).catch(error => {
|
||||||
showInteractionError('Could not copy addresses', error);
|
showInteractionError(t($ => $.downloadTable.copyAddressesFailed), error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Copy Address
|
{t($ => $.downloadTable.copyAddress)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||||
@@ -721,7 +736,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
|
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
Remove
|
{t($ => $.downloadTable.remove)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -736,7 +751,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Open
|
{t($ => $.downloadTable.open)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -747,7 +762,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Show in Folder
|
{t($ => $.downloadTable.showInFolder)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||||
@@ -760,7 +775,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Pause
|
{t($ => $.downloadTable.pause)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -772,7 +787,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
{startActionLabel(contextItem.status)}
|
{contextItem.status === 'paused' ? t($ => $.downloadTable.resume) : t($ => $.downloadTable.start)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -783,19 +798,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
try {
|
try {
|
||||||
await redownload(contextItem.id);
|
await redownload(contextItem.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showInteractionError('Redownload failed', error);
|
showInteractionError(t($ => $.downloadTable.redownloadFailed), error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Redownload
|
{t($ => $.downloadTable.redownload)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contextItem.status !== 'completed' && (
|
{contextItem.status !== 'completed' && (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
|
||||||
Add to Queue
|
{t($ => $.downloadTable.addToQueue)}
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
|
||||||
@@ -803,7 +818,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
<button key={q.id} onClick={() => {
|
<button key={q.id} onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void assignToQueue([contextItem.id], q.id).catch(error => {
|
void assignToQueue([contextItem.id], q.id).catch(error => {
|
||||||
showInteractionError('Could not move download to queue', error);
|
showInteractionError(t($ => $.downloadTable.moveOneFailed), error);
|
||||||
});
|
});
|
||||||
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
|
||||||
{q.name}
|
{q.name}
|
||||||
@@ -819,12 +834,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
navigator.clipboard.writeText(contextItem.url).catch(error => {
|
navigator.clipboard.writeText(contextItem.url).catch(error => {
|
||||||
showInteractionError('Could not copy address', error);
|
showInteractionError(t($ => $.downloadTable.copyAddressFailed), error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Copy Address
|
{t($ => $.downloadTable.copyAddress)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{contextItem.status === 'completed' && (
|
{contextItem.status === 'completed' && (
|
||||||
@@ -833,18 +848,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
const fullPath = await getDownloadPath(contextItem);
|
const fullPath = await getDownloadPath(contextItem);
|
||||||
if (!fullPath) {
|
if (!fullPath) {
|
||||||
showInteractionError('Could not copy file path', 'File name is missing');
|
showInteractionError(t($ => $.downloadTable.copyPathFailed), t($ => $.downloadTable.missingFileName));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(fullPath);
|
await navigator.clipboard.writeText(fullPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showInteractionError('Could not copy file path', error);
|
showInteractionError(t($ => $.downloadTable.copyPathFailed), error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Copy File Path
|
{t($ => $.downloadTable.copyFilePath)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -857,7 +872,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
|
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
Remove
|
{t($ => $.downloadTable.remove)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||||
@@ -869,7 +884,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||||
>
|
>
|
||||||
Properties
|
{t($ => $.downloadTable.properties)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
||||||
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,8 +45,8 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
|||||||
>
|
>
|
||||||
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
|
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
|
||||||
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
|
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
|
<h2 className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</h2>
|
||||||
<p className="text-xs text-text-muted">Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.</p>
|
<p className="text-xs text-text-muted">{t($ => $.dialogs.duplicateDownloads.description)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
|
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
|
||||||
@@ -59,9 +61,9 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
|||||||
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
|
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
|
||||||
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
<option value="rename">Rename</option>
|
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
|
||||||
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">Replace</option>}
|
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
|
||||||
<option value="skip">Skip</option>
|
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -69,13 +71,13 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
|||||||
|
|
||||||
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
|
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
|
||||||
<button onClick={onCancel} className="app-button px-4 text-xs">
|
<button onClick={onCancel} className="app-button px-4 text-xs">
|
||||||
Cancel
|
{t($ => $.actions.cancel)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
|
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
|
||||||
className="app-button app-button-primary px-5 text-xs"
|
className="app-button app-button-primary px-5 text-xs"
|
||||||
>
|
>
|
||||||
Continue
|
{t($ => $.actions.continue)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -38,10 +39,10 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1 className="text-xl font-bold">Something went wrong.</h1>
|
<h1 className="text-xl font-bold">{i18n.t($ => $.dialogs.errorBoundary.title)}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-text-secondary mb-6">
|
<div className="text-sm text-text-secondary mb-6">
|
||||||
A critical error occurred in the React component tree. The error details below can help identify the root cause.
|
{i18n.t($ => $.dialogs.errorBoundary.description)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-black/50 p-4 rounded text-xs font-mono text-red-300 overflow-x-auto mb-4">
|
<div className="bg-black/50 p-4 rounded text-xs font-mono text-red-300 overflow-x-auto mb-4">
|
||||||
{this.state.error && this.state.error.toString()}
|
{this.state.error && this.state.error.toString()}
|
||||||
@@ -52,7 +53,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
className="app-button px-4 py-2 bg-red-600/20 text-red-400 border border-red-600/50 hover:bg-red-600/30 transition-colors rounded"
|
className="app-button px-4 py-2 bg-red-600/20 text-red-400 border border-red-600/50 hover:bg-red-600/30 transition-colors rounded"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
Reload App
|
{i18n.t($ => $.dialogs.errorBoundary.reload)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePlatformInfo } from '../utils/platform';
|
|||||||
import { getKeychainConsentVersion } from '../utils/keychainStartup';
|
import { getKeychainConsentVersion } from '../utils/keychainStartup';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ type KeychainPermissionModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
|
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
||||||
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
||||||
const platform = usePlatformInfo();
|
const platform = usePlatformInfo();
|
||||||
@@ -36,22 +38,22 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
const isMac = platform.os === 'macos';
|
const isMac = platform.os === 'macos';
|
||||||
const pairingStoreName =
|
const pairingStoreName =
|
||||||
platform.portable
|
platform.portable
|
||||||
? 'the portable Firelink data folder'
|
? t($ => $.keychain.stores.portable)
|
||||||
: platform.os === 'windows'
|
: platform.os === 'windows'
|
||||||
? 'Windows Credential Manager'
|
? t($ => $.keychain.stores.windows)
|
||||||
: platform.os === 'linux'
|
: platform.os === 'linux'
|
||||||
? 'your Linux credential store'
|
? t($ => $.keychain.stores.linux)
|
||||||
: platform.os === 'macos'
|
: platform.os === 'macos'
|
||||||
? 'macOS Keychain'
|
? t($ => $.keychain.stores.macos)
|
||||||
: "this system's credential store";
|
: t($ => $.keychain.stores.system);
|
||||||
const siteCredentialStoreName = platform.portable
|
const siteCredentialStoreName = platform.portable
|
||||||
? "the system's credential store"
|
? t($ => $.keychain.stores.siteCredentials)
|
||||||
: pairingStoreName;
|
: pairingStoreName;
|
||||||
const grantLabel = platform.portable
|
const grantLabel = platform.portable
|
||||||
? 'Continue'
|
? t($ => $.keychain.grantLabelPortable)
|
||||||
: isMac
|
: isMac
|
||||||
? 'Grant Access'
|
? t($ => $.keychain.grantLabelMacos)
|
||||||
: 'Enable Secure Storage';
|
: t($ => $.keychain.grantLabelDefault);
|
||||||
|
|
||||||
const handleGrant = async () => {
|
const handleGrant = async () => {
|
||||||
setIsGranting(true);
|
setIsGranting(true);
|
||||||
@@ -87,13 +89,13 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
grantRequest,
|
grantRequest,
|
||||||
new Promise<never>((_, reject) => {
|
new Promise<never>((_, reject) => {
|
||||||
timeoutId = window.setTimeout(
|
timeoutId = window.setTimeout(
|
||||||
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
|
() => reject(new Error(t($ => $.keychain.timeout))),
|
||||||
KEYCHAIN_GRANT_TIMEOUT_MS
|
KEYCHAIN_GRANT_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
if (!(await applyPersistentGrant(result))) {
|
if (!(await applyPersistentGrant(result))) {
|
||||||
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
|
setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.toString());
|
setError(e.toString());
|
||||||
@@ -124,28 +126,27 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
|
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
|
||||||
<KeyRound size={20} className="text-blue-500" />
|
<KeyRound size={20} className="text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
|
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.keychain.title)}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
|
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts,
|
{t($ => $.keychain.description, { pairingStore: pairingStoreName, siteCredentialStore: siteCredentialStoreName })}
|
||||||
Firelink stores its pairing token in {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{platform.portable
|
{platform.portable
|
||||||
? 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.'
|
? t($ => $.keychain.portableExplanation)
|
||||||
: isMac
|
: isMac
|
||||||
? 'macOS may show a Keychain prompt after you grant access.'
|
? t($ => $.keychain.macosExplanation)
|
||||||
: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.'}
|
: t($ => $.keychain.defaultExplanation)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong>Note:</strong>{' '}
|
<strong>{t($ => $.keychain.note)}</strong>{' '}
|
||||||
{platform.portable
|
{platform.portable
|
||||||
? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.'
|
? t($ => $.keychain.portableNote)
|
||||||
: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}
|
: t($ => $.keychain.defaultNote)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -156,11 +157,11 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
|
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
|
||||||
<strong>Hint:</strong>{' '}
|
<strong>{t($ => $.keychain.hint)}</strong>{' '}
|
||||||
{platform.portable
|
{platform.portable
|
||||||
? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.'
|
? t($ => $.keychain.portableHint)
|
||||||
: 'If you select Later, the extension will only work for this session.'}
|
: t($ => $.keychain.defaultHint)}{' '}
|
||||||
You can enable storage anytime from <strong>Settings > Integrations</strong>.
|
{t($ => $.keychain.enableFromSettings)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,14 +171,14 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
|||||||
disabled={isGranting}
|
disabled={isGranting}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Later
|
{t($ => $.keychain.later)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleGrant}
|
onClick={handleGrant}
|
||||||
disabled={isGranting}
|
disabled={isGranting}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isGranting ? 'Enabling...' : grantLabel}
|
{isGranting ? t($ => $.keychain.enabling) : grantLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+28
-26
@@ -7,6 +7,7 @@ import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'luc
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
MAX_LOG_LINES,
|
MAX_LOG_LINES,
|
||||||
appendBoundedLogEntries,
|
appendBoundedLogEntries,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from '../utils/logEntries';
|
} from '../utils/logEntries';
|
||||||
|
|
||||||
export default function LogsView() {
|
export default function LogsView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
||||||
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
|
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
|
||||||
@@ -169,10 +171,10 @@ export default function LogsView() {
|
|||||||
if (contextMenu?.text) {
|
if (contextMenu?.text) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(contextMenu.text);
|
await navigator.clipboard.writeText(contextMenu.text);
|
||||||
addToast({ message: 'Copied to clipboard', variant: 'success' });
|
addToast({ message: t($ => $.logs.copied), variant: 'success' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clipboard write error:', err);
|
console.error('Clipboard write error:', err);
|
||||||
addToast({ message: 'Failed to copy to clipboard', variant: 'error' });
|
addToast({ message: t($ => $.logs.copyFailed), variant: 'error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -182,14 +184,14 @@ export default function LogsView() {
|
|||||||
try {
|
try {
|
||||||
const path = await save({
|
const path = await save({
|
||||||
defaultPath: 'Firelink-Support-Logs.log',
|
defaultPath: 'Firelink-Support-Logs.log',
|
||||||
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
filters: [{ name: t($ => $.logs.logFiles), extensions: ['log'] }],
|
||||||
});
|
});
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
await invoke('export_logs', { destination: path });
|
await invoke('export_logs', { destination: path });
|
||||||
addToast({ message: 'Support logs exported', variant: 'success' });
|
addToast({ message: t($ => $.logs.exported), variant: 'success' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Export failed:', e);
|
console.error('Export failed:', e);
|
||||||
addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.logs.exportFailed, { detail: String(e) }), variant: 'error', isActionable: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,10 +210,10 @@ export default function LogsView() {
|
|||||||
liveFrameRef.current = null;
|
liveFrameRef.current = null;
|
||||||
}
|
}
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
addToast({ message: 'Logs cleared', variant: 'info' });
|
addToast({ message: t($ => $.logs.cleared), variant: 'info' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not clear logs: ${String(error)}`,
|
message: t($ => $.logs.clearFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -231,7 +233,7 @@ export default function LogsView() {
|
|||||||
await setLogPaused(!nextEnabled);
|
await setLogPaused(!nextEnabled);
|
||||||
setLogsEnabled(nextEnabled);
|
setLogsEnabled(nextEnabled);
|
||||||
addToast({
|
addToast({
|
||||||
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
|
message: nextEnabled ? t($ => $.logs.enabled) : t($ => $.logs.disabled),
|
||||||
variant: 'success'
|
variant: 'success'
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -241,7 +243,7 @@ export default function LogsView() {
|
|||||||
await toggleOperation;
|
await toggleOperation;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not update diagnostic logging: ${String(error)}`,
|
message: t($ => $.logs.updateFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -270,12 +272,12 @@ export default function LogsView() {
|
|||||||
<div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
|
<div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
|
||||||
<div className="flex items-center gap-2 text-text-secondary">
|
<div className="flex items-center gap-2 text-text-secondary">
|
||||||
<Terminal size={16} strokeWidth={1.8} />
|
<Terminal size={16} strokeWidth={1.8} />
|
||||||
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
|
<span className="text-[13px] font-semibold text-text-primary">{t($ => $.logs.title)}</span>
|
||||||
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
|
<span className="text-[11px] text-text-muted">{t($ => $.logs.entries, { count: logs.length })}</span>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||||
logsEnabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
logsEnabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{logsEnabled ? 'Collecting' : 'Off'}
|
{logsEnabled ? t($ => $.logs.collecting) : t($ => $.logs.off)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -286,12 +288,12 @@ export default function LogsView() {
|
|||||||
onChange={e => setLevelFilter(e.target.value as LogEntry['level'] | 'All')}
|
onChange={e => setLevelFilter(e.target.value as LogEntry['level'] | 'All')}
|
||||||
className="bg-bg-input border border-border-modal rounded px-1.5 py-0.5 text-[11px] text-text-primary focus:outline-none focus:border-accent"
|
className="bg-bg-input border border-border-modal rounded px-1.5 py-0.5 text-[11px] text-text-primary focus:outline-none focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="All">All Levels</option>
|
<option value="All">{t($ => $.logs.allLevels)}</option>
|
||||||
<option value="Error">Error</option>
|
<option value="Error">{t($ => $.logs.levels.error)}</option>
|
||||||
<option value="Warn">Warn</option>
|
<option value="Warn">{t($ => $.logs.levels.warn)}</option>
|
||||||
<option value="Info">Info</option>
|
<option value="Info">{t($ => $.logs.levels.info)}</option>
|
||||||
<option value="Debug">Debug</option>
|
<option value="Debug">{t($ => $.logs.levels.debug)}</option>
|
||||||
<option value="Trace">Trace</option>
|
<option value="Trace">{t($ => $.logs.levels.trace)}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
|
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
|
||||||
@@ -299,7 +301,7 @@ export default function LogsView() {
|
|||||||
onClick={handleToggleLogging}
|
onClick={handleToggleLogging}
|
||||||
disabled={isToggling}
|
disabled={isToggling}
|
||||||
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
|
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
|
||||||
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
|
title={logsEnabled ? t($ => $.logs.pauseLogging) : t($ => $.logs.enableLogging)}
|
||||||
>
|
>
|
||||||
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
|
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -307,17 +309,17 @@ export default function LogsView() {
|
|||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
disabled={isClearing}
|
disabled={isClearing}
|
||||||
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title="Clear displayed logs"
|
title={t($ => $.logs.clearDisplayed)}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
className="app-button px-3 text-[11px] gap-1.5"
|
className="app-button px-3 text-[11px] gap-1.5"
|
||||||
title="Export logs"
|
title={t($ => $.logs.export)}
|
||||||
>
|
>
|
||||||
<FileDown size={13} />
|
<FileDown size={13} />
|
||||||
Export Logs
|
{t($ => $.logs.exportButton)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,8 +328,8 @@ export default function LogsView() {
|
|||||||
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
|
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
|
||||||
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
|
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
|
||||||
<span className="opacity-90 leading-tight">
|
<span className="opacity-90 leading-tight">
|
||||||
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
|
<strong className="font-medium text-text-primary mr-1">{t($ => $.logs.localDiagnostics)}</strong>
|
||||||
Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
|
{t($ => $.logs.diagnosticsDescription)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,7 +342,7 @@ export default function LogsView() {
|
|||||||
>
|
>
|
||||||
{logs.length === 0 && (
|
{logs.length === 0 && (
|
||||||
<div className="text-text-muted italic select-none">
|
<div className="text-text-muted italic select-none">
|
||||||
{logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'}
|
{logsEnabled ? t($ => $.logs.noEntries) : t($ => $.logs.disabledDescription)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
|
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
|
||||||
@@ -364,7 +366,7 @@ export default function LogsView() {
|
|||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
<Copy size={13} className="mr-2 text-text-secondary" />
|
<Copy size={13} className="mr-2 text-text-secondary" />
|
||||||
Copy
|
{t($ => $.logs.copy)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
resolveDownloadSizeDisplay
|
resolveDownloadSizeDisplay
|
||||||
} from '../utils/downloadProgress';
|
} from '../utils/downloadProgress';
|
||||||
import { resolveDownloadConnections } from '../utils/downloads';
|
import { resolveDownloadConnections } from '../utils/downloads';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type LoginMode = 'matching' | 'custom' | 'none';
|
type LoginMode = 'matching' | 'custom' | 'none';
|
||||||
|
|
||||||
@@ -28,6 +29,18 @@ const formatLastTry = (value?: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PropertiesModal = () => {
|
export const PropertiesModal = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const categoryLabel = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case 'Musics': return t($ => $.navigation.categories.musics);
|
||||||
|
case 'Movies': return t($ => $.navigation.categories.movies);
|
||||||
|
case 'Compressed': return t($ => $.navigation.categories.compressed);
|
||||||
|
case 'Documents': return t($ => $.navigation.categories.documents);
|
||||||
|
case 'Pictures': return t($ => $.navigation.categories.pictures);
|
||||||
|
case 'Applications': return t($ => $.navigation.categories.applications);
|
||||||
|
default: return t($ => $.navigation.categories.other);
|
||||||
|
}
|
||||||
|
};
|
||||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||||
const item = useDownloadStore(useShallow(state =>
|
const item = useDownloadStore(useShallow(state =>
|
||||||
@@ -159,11 +172,11 @@ export const PropertiesModal = () => {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
setErrorMessage("Enter a valid URL.");
|
setErrorMessage(t($ => $.properties.enterValidUrl));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!fileName.trim()) {
|
if (!fileName.trim()) {
|
||||||
setErrorMessage("File name cannot be empty.");
|
setErrorMessage(t($ => $.properties.fileNameEmpty));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,9 +224,22 @@ export const PropertiesModal = () => {
|
|||||||
});
|
});
|
||||||
const hasDownloadedAmount = item.status !== 'completed' &&
|
const hasDownloadedAmount = item.status !== 'completed' &&
|
||||||
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
|
||||||
const completedSizeLabel = item.status === 'completed'
|
const completedSizeLabel = (() => {
|
||||||
? formatDownloadTotal(sizeDisplay)
|
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||||
: sizeDisplay.fallback;
|
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
||||||
|
})();
|
||||||
|
const statusLabel = t($ => $.downloads.status[item.status]);
|
||||||
|
const sizeDescription = sizeDisplay.totalIsEstimate
|
||||||
|
? t($ => $.downloads.size.downloadedOfApproximate, {
|
||||||
|
downloaded: sizeDisplay.downloaded ?? '',
|
||||||
|
total: sizeDisplay.total ?? '',
|
||||||
|
unit: sizeDisplay.unit ?? '',
|
||||||
|
})
|
||||||
|
: t($ => $.downloads.size.downloadedOf, {
|
||||||
|
downloaded: sizeDisplay.downloaded ?? '',
|
||||||
|
total: sizeDisplay.total ?? '',
|
||||||
|
unit: sizeDisplay.unit ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
let statusColor = 'text-text-secondary';
|
let statusColor = 'text-text-secondary';
|
||||||
let StatusIcon = Info;
|
let StatusIcon = Info;
|
||||||
@@ -240,7 +266,7 @@ export const PropertiesModal = () => {
|
|||||||
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
|
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
|
||||||
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
|
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
|
||||||
<StatusIcon size={14} />
|
<StatusIcon size={14} />
|
||||||
{item.status}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,13 +275,13 @@ export const PropertiesModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.progress)}</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
||||||
<div className="flex gap-1.5 min-w-0">
|
<div className="flex gap-1.5 min-w-0">
|
||||||
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
|
<span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.size)}</span>
|
||||||
<span
|
<span
|
||||||
className="truncate"
|
className="truncate"
|
||||||
title={hasDownloadedAmount
|
title={hasDownloadedAmount
|
||||||
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
|
? sizeDescription
|
||||||
: completedSizeLabel}
|
: completedSizeLabel}
|
||||||
>
|
>
|
||||||
{hasDownloadedAmount ? (
|
{hasDownloadedAmount ? (
|
||||||
@@ -269,19 +295,19 @@ export const PropertiesModal = () => {
|
|||||||
) : completedSizeLabel}
|
) : completedSizeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.speed)}</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">{t($ => $.properties.eta)}</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? t($ => $.properties.saved) : t($ => $.properties.defaultValue)}</span></div>
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
|
||||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
|
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||||
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
|
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
|
||||||
<div className="flex gap-1.5 col-span-4 min-w-0">
|
<div className="flex gap-1.5 col-span-4 min-w-0">
|
||||||
<span className="text-text-muted font-medium w-[90px] shrink-0">Last error</span>
|
<span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.lastError)}</span>
|
||||||
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
|
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -298,53 +324,53 @@ export const PropertiesModal = () => {
|
|||||||
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
|
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
|
||||||
<span>
|
<span>
|
||||||
{item.status === 'completed'
|
{item.status === 'completed'
|
||||||
? "File identity is read-only. Transfer settings are saved for redownload."
|
? t($ => $.properties.identityReadOnly)
|
||||||
: "Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options."}
|
: t($ => $.properties.transferSettings)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Download Section */}
|
{/* Download Section */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">Download</h3>
|
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">{t($ => $.properties.download)}</h3>
|
||||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||||
<label className="text-xs text-text-muted text-right">URL</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.url)}</label>
|
||||||
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">File name</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.fileName)}</label>
|
||||||
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={identityLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Save location</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.saveLocation)}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input type="text" value={saveLocation} readOnly disabled={identityLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="text" value={saveLocation} readOnly disabled={identityLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
<button onClick={handleBrowse} disabled={identityLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
|
<button onClick={handleBrowse} disabled={identityLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
|
||||||
<FolderPlus size={14} /> Select
|
<FolderPlus size={14} /> {t($ => $.properties.select)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Connections</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.connections)}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
<span className="text-xs text-text-muted">per file</span>
|
<span className="text-xs text-text-muted">{t($ => $.properties.perFile)}</span>
|
||||||
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
|
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
||||||
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Use current default ({perServerConnections})
|
{t($ => $.properties.useCurrentDefault, { count: perServerConnections })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-start-2 text-[11px] text-text-muted">
|
<div className="col-start-2 text-[11px] text-text-muted">
|
||||||
Saved per download. The Settings default applies to new downloads.
|
{t($ => $.properties.savedPerDownload)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Speed</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.speed)}</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||||
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
|
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
|
||||||
Limit
|
{t($ => $.properties.limit)}
|
||||||
</label>
|
</label>
|
||||||
{speedLimitEnabled && (
|
{speedLimitEnabled && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -359,7 +385,7 @@ export const PropertiesModal = () => {
|
|||||||
{/* Site Login Section */}
|
{/* Site Login Section */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
||||||
{item.status === 'completed' ? 'Site Login for Redownload' : 'Site Login'}
|
{item.status === 'completed' ? t($ => $.properties.siteLoginRedownload) : t($ => $.properties.siteLogin)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
|
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
|
||||||
@@ -370,7 +396,7 @@ export const PropertiesModal = () => {
|
|||||||
disabled={transferLocked}
|
disabled={transferLocked}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
|
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
|
||||||
>
|
>
|
||||||
{mode === 'matching' ? 'Matching site login' : mode === 'custom' ? 'Custom credentials' : 'No login'}
|
{mode === 'matching' ? t($ => $.properties.matchingSiteLogin) : mode === 'custom' ? t($ => $.properties.customCredentials) : t($ => $.properties.noLogin)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -378,16 +404,16 @@ export const PropertiesModal = () => {
|
|||||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||||
{loginMode === 'matching' && (
|
{loginMode === 'matching' && (
|
||||||
<div className="col-start-2 text-xs text-text-secondary italic">
|
<div className="col-start-2 text-xs text-text-secondary italic">
|
||||||
Will use saved login if available.
|
{t($ => $.properties.useSavedLogin)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loginMode === 'custom' && (
|
{loginMode === 'custom' && (
|
||||||
<>
|
<>
|
||||||
<label className="text-xs text-text-muted text-right">Username</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.username)}</label>
|
||||||
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder="Username" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.username)} className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Password</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.password)}</label>
|
||||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder="Password" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.password)} className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -400,20 +426,20 @@ export const PropertiesModal = () => {
|
|||||||
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
|
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
{item.status === 'completed' ? 'Advanced Transfer for Redownload' : 'Advanced Transfer'}
|
{item.status === 'completed' ? t($ => $.properties.advancedTransferRedownload) : t($ => $.properties.advancedTransfer)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{advancedExpanded && (
|
{advancedExpanded && (
|
||||||
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
|
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
|
||||||
<label className="text-xs text-text-muted text-right">Checksum</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.checksum)}</label>
|
||||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||||
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
|
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
|
||||||
Verify
|
{t($ => $.properties.verify)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{checksumEnabled && (
|
{checksumEnabled && (
|
||||||
<>
|
<>
|
||||||
<label className="text-xs text-text-muted text-right">Algorithm</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.algorithm)}</label>
|
||||||
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
|
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
|
||||||
<option value="MD5">MD5</option>
|
<option value="MD5">MD5</option>
|
||||||
<option value="SHA-1">SHA-1</option>
|
<option value="SHA-1">SHA-1</option>
|
||||||
@@ -421,21 +447,21 @@ export const PropertiesModal = () => {
|
|||||||
<option value="SHA-512">SHA-512</option>
|
<option value="SHA-512">SHA-512</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Digest</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.digest)}</label>
|
||||||
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.expectedDigest)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="text-xs text-text-muted text-right">Cookies</label>
|
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
|
||||||
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||||
|
|
||||||
<div className="col-span-2 mt-2">
|
<div className="col-span-2 mt-2">
|
||||||
<label className="block text-xs text-text-muted mb-1.5">Headers</label>
|
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
|
||||||
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-xs text-text-muted mb-1.5">Mirrors</label>
|
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.mirrors)}</label>
|
||||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,7 +482,7 @@ export const PropertiesModal = () => {
|
|||||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||||
className="app-button px-4 text-xs"
|
className="app-button px-4 text-xs"
|
||||||
>
|
>
|
||||||
Cancel
|
{t($ => $.properties.cancel)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -464,7 +490,7 @@ export const PropertiesModal = () => {
|
|||||||
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
Save
|
{t($ => $.properties.save)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,22 +10,23 @@ import { isActiveDownloadStatus } from '../utils/downloads';
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { usePlatformInfo } from '../utils/platform';
|
import { usePlatformInfo } from '../utils/platform';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const days = [
|
const days = [
|
||||||
{ value: 0, label: 'Su' },
|
{ value: 0, key: 'su' },
|
||||||
{ value: 1, label: 'Mo' },
|
{ value: 1, key: 'mo' },
|
||||||
{ value: 2, label: 'Tu' },
|
{ value: 2, key: 'tu' },
|
||||||
{ value: 3, label: 'We' },
|
{ value: 3, key: 'we' },
|
||||||
{ value: 4, label: 'Th' },
|
{ value: 4, key: 'th' },
|
||||||
{ value: 5, label: 'Fr' },
|
{ value: 5, key: 'fr' },
|
||||||
{ value: 6, label: 'Sa' },
|
{ value: 6, key: 'sa' },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
|
const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
|
||||||
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
|
{ value: 'none', icon: CheckCircle2 },
|
||||||
{ value: 'sleep', label: 'Sleep', icon: Moon },
|
{ value: 'sleep', icon: Moon },
|
||||||
{ value: 'restart', label: 'Restart', icon: RotateCcw },
|
{ value: 'restart', icon: RotateCcw },
|
||||||
{ value: 'shutdown', label: 'Shut down', icon: Power },
|
{ value: 'shutdown', icon: Power },
|
||||||
];
|
];
|
||||||
|
|
||||||
const minuteOfDay = (value: string) => {
|
const minuteOfDay = (value: string) => {
|
||||||
@@ -33,8 +34,8 @@ const minuteOfDay = (value: string) => {
|
|||||||
return hour * 60 + minute;
|
return hour * 60 + minute;
|
||||||
};
|
};
|
||||||
|
|
||||||
function nextScheduledRun(settings: SchedulerSettings): string {
|
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
|
||||||
if (!settings.enabled) return 'Scheduler is disabled';
|
if (!settings.enabled) return 'disabled';
|
||||||
|
|
||||||
const [hour, minute] = settings.startTime.split(':').map(Number);
|
const [hour, minute] = settings.startTime.split(':').map(Number);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -45,20 +46,15 @@ function nextScheduledRun(settings: SchedulerSettings): string {
|
|||||||
candidate.setHours(hour, minute, 0, 0);
|
candidate.setHours(hour, minute, 0, 0);
|
||||||
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
|
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
|
||||||
if (allowedDay && candidate > now) {
|
if (allowedDay && candidate > now) {
|
||||||
return candidate.toLocaleString(undefined, {
|
return candidate;
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'No scheduled day selected';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SchedulerView() {
|
export default function SchedulerView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const savedSettings = useSettingsStore(state => state.scheduler);
|
const savedSettings = useSettingsStore(state => state.scheduler);
|
||||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||||
const setScheduler = useSettingsStore(state => state.setScheduler);
|
const setScheduler = useSettingsStore(state => state.setScheduler);
|
||||||
@@ -75,7 +71,18 @@ export default function SchedulerView() {
|
|||||||
}, [savedSettings]);
|
}, [savedSettings]);
|
||||||
|
|
||||||
|
|
||||||
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
|
const nextRun = useMemo(() => {
|
||||||
|
const scheduledRun = nextScheduledRun(draft);
|
||||||
|
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
|
||||||
|
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
|
||||||
|
return scheduledRun.toLocaleString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}, [draft, t]);
|
||||||
const hasUnsavedChanges = useMemo(
|
const hasUnsavedChanges = useMemo(
|
||||||
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
|
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
|
||||||
[draft, savedSettings]
|
[draft, savedSettings]
|
||||||
@@ -116,15 +123,15 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) {
|
if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) {
|
||||||
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.scheduler.validationDay), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.enabled && effectiveSelectedQueueIds.length === 0) {
|
if (draft.enabled && effectiveSelectedQueueIds.length === 0) {
|
||||||
addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
|
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
|
||||||
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = {
|
const normalized = {
|
||||||
@@ -134,7 +141,7 @@ export default function SchedulerView() {
|
|||||||
};
|
};
|
||||||
setScheduler(normalized);
|
setScheduler(normalized);
|
||||||
setDraft(normalized);
|
setDraft(normalized);
|
||||||
addToast({ message: 'Scheduler settings saved', variant: 'success' });
|
addToast({ message: t($ => $.scheduler.saved), variant: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const runNow = async () => {
|
const runNow = async () => {
|
||||||
@@ -155,9 +162,14 @@ export default function SchedulerView() {
|
|||||||
if (activeIds.length > 0) {
|
if (activeIds.length > 0) {
|
||||||
useSettingsStore.getState().setSchedulerRunning(true);
|
useSettingsStore.getState().setSchedulerRunning(true);
|
||||||
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
|
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
|
||||||
addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' });
|
addToast({
|
||||||
|
message: activeIds.length === 1
|
||||||
|
? t($ => $.scheduler.trackingOne)
|
||||||
|
: t($ => $.scheduler.trackingMany, { count: activeIds.length }),
|
||||||
|
variant: 'success'
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
|
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,7 +180,14 @@ export default function SchedulerView() {
|
|||||||
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
|
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
|
||||||
useSettingsStore.getState().setSchedulerRunning(false);
|
useSettingsStore.getState().setSchedulerRunning(false);
|
||||||
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
|
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
|
||||||
addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' });
|
addToast({
|
||||||
|
message: count === 0
|
||||||
|
? t($ => $.scheduler.noActiveDownloads)
|
||||||
|
: count === 1
|
||||||
|
? t($ => $.scheduler.pausedOne)
|
||||||
|
: t($ => $.scheduler.pausedMany, { count }),
|
||||||
|
variant: 'info'
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshPermissionStatus = useCallback(async (showMessage = false) => {
|
const refreshPermissionStatus = useCallback(async (showMessage = false) => {
|
||||||
@@ -178,12 +197,12 @@ export default function SchedulerView() {
|
|||||||
await invoke('check_automation_permission');
|
await invoke('check_automation_permission');
|
||||||
setAutomationPermissionGranted(true);
|
setAutomationPermissionGranted(true);
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
setPermissionMessage('Automation permission is available.');
|
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setAutomationPermissionGranted(false);
|
setAutomationPermissionGranted(false);
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.');
|
setPermissionMessage(t($ => $.scheduler.permissionMissingDetails));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isMac]);
|
}, [isMac]);
|
||||||
@@ -222,18 +241,18 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
const handlePermissionAction = async () => {
|
const handlePermissionAction = async () => {
|
||||||
if (automationPermissionGranted) {
|
if (automationPermissionGranted) {
|
||||||
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.');
|
await openAutomationSettings(t($ => $.scheduler.revokePermissionDetails));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPermissionMessage('Requesting Automation permission...');
|
setPermissionMessage(t($ => $.scheduler.requestingPermission));
|
||||||
try {
|
try {
|
||||||
await invoke('request_automation_permission');
|
await invoke('request_automation_permission');
|
||||||
setAutomationPermissionGranted(true);
|
setAutomationPermissionGranted(true);
|
||||||
setPermissionMessage('Automation permission is available.');
|
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
|
||||||
} catch {
|
} catch {
|
||||||
setAutomationPermissionGranted(false);
|
setAutomationPermissionGranted(false);
|
||||||
await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.');
|
await openAutomationSettings(t($ => $.scheduler.enablePermissionDetails));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -253,27 +272,27 @@ export default function SchedulerView() {
|
|||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${draft.enabled ? 'translate-x-4' : 'translate-x-1'}`}
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${draft.enabled ? 'translate-x-4' : 'translate-x-1'}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
Scheduler
|
{t($ => $.scheduler.title)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||||
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
|
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{schedulerRunning ? 'Running' : nextRun}
|
{schedulerRunning ? t($ => $.scheduler.running) : nextRun}
|
||||||
</span>
|
</span>
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-[11px] font-semibold text-orange-300">
|
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-[11px] font-semibold text-orange-300">
|
||||||
Unsaved changes
|
{t($ => $.scheduler.unsavedChanges)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<button onClick={runNow} className="app-button px-3 text-[11px]">
|
<button onClick={runNow} className="app-button px-3 text-[11px]">
|
||||||
<Play size={14} /> Run Now
|
<Play size={14} /> {t($ => $.scheduler.runNow)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
|
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
|
||||||
<Pause size={14} /> Pause
|
<Pause size={14} /> {t($ => $.scheduler.pause)}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
|
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
|
||||||
<Save size={14} /> Save Settings
|
<Save size={14} /> {t($ => $.scheduler.saveSettings)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,29 +301,29 @@ export default function SchedulerView() {
|
|||||||
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
|
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
|
||||||
<section className="app-card p-5">
|
<section className="app-card p-5">
|
||||||
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
<Clock3 size={17} className="text-accent" /> Timing
|
<Clock3 size={17} className="text-accent" /> {t($ => $.scheduler.timing)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-end gap-8">
|
<div className="flex flex-wrap items-end gap-8">
|
||||||
<label className="space-y-2 text-[12px] text-text-secondary">
|
<label className="space-y-2 text-[12px] text-text-secondary">
|
||||||
<span className="block">Start Time</span>
|
<span className="block">{t($ => $.scheduler.startTime)}</span>
|
||||||
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="app-control px-3 py-2 text-text-primary" />
|
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="app-control px-3 py-2 text-text-primary" />
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
|
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
|
||||||
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
|
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
|
||||||
Stop Time
|
{t($ => $.scheduler.stopTime)}
|
||||||
</label>
|
</label>
|
||||||
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
|
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-[11px] text-text-muted">
|
<p className="mt-4 text-[11px] text-text-muted">
|
||||||
If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.
|
{t($ => $.scheduler.timingDescription)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="my-5 border-t border-border-color" />
|
<div className="my-5 border-t border-border-color" />
|
||||||
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
|
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
|
||||||
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
|
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
|
||||||
Run Every Day
|
{t($ => $.scheduler.runEveryDay)}
|
||||||
</label>
|
</label>
|
||||||
{!draft.everyday && (
|
{!draft.everyday && (
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
@@ -320,7 +339,7 @@ export default function SchedulerView() {
|
|||||||
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
|
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{day.label}
|
{t($ => $.scheduler.days[day.key])}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -330,7 +349,7 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
<section className="app-card p-5">
|
<section className="app-card p-5">
|
||||||
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
<List size={17} className="text-accent" /> Queues to Schedule
|
<List size={17} className="text-accent" /> {t($ => $.scheduler.queuesToSchedule)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{queues.map(queue => {
|
{queues.map(queue => {
|
||||||
@@ -346,7 +365,7 @@ export default function SchedulerView() {
|
|||||||
/>
|
/>
|
||||||
{queue.name}
|
{queue.name}
|
||||||
{queue.isMain && (
|
{queue.isMain && (
|
||||||
<span className="text-[11px] text-text-muted">Default queue</span>
|
<span className="text-[11px] text-text-muted">{t($ => $.scheduler.defaultQueue)}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -356,9 +375,9 @@ export default function SchedulerView() {
|
|||||||
|
|
||||||
<section className="app-card p-5">
|
<section className="app-card p-5">
|
||||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
<Power size={17} className="text-accent" /> After Completion
|
<Power size={17} className="text-accent" /> {t($ => $.scheduler.afterCompletion)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
|
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.afterCompletionDescription)}</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{postActions.map(action => {
|
{postActions.map(action => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
@@ -368,13 +387,13 @@ export default function SchedulerView() {
|
|||||||
}`}>
|
}`}>
|
||||||
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
|
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
|
||||||
<Icon size={15} />
|
<Icon size={15} />
|
||||||
{action.label}
|
{t($ => $.scheduler.postActions[action.value])}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{draft.postQueueAction !== 'none' && (
|
{draft.postQueueAction !== 'none' && (
|
||||||
<p className="mt-3 text-[11px] text-orange-400">This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.</p>
|
<p className="mt-3 text-[11px] text-orange-400">{t($ => $.scheduler.actionWarning)}</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,27 +401,27 @@ export default function SchedulerView() {
|
|||||||
{isMac ? (
|
{isMac ? (
|
||||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
<section className="app-card mt-4 max-w-[760px] p-5">
|
||||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
<LockKeyhole size={17} className="text-accent" /> System Permissions
|
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemPermissions)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for System Events.</p>
|
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.macPermissionDescription)}</p>
|
||||||
<div className="mb-4 flex items-center gap-2 text-[12px]">
|
<div className="mb-4 flex items-center gap-2 text-[12px]">
|
||||||
{automationPermissionGranted ? (
|
{automationPermissionGranted ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 size={16} className="text-green-500" />
|
<CheckCircle2 size={16} className="text-green-500" />
|
||||||
<span className="font-medium text-green-500">Automation permission granted</span>
|
<span className="font-medium text-green-500">{t($ => $.scheduler.permissionGranted)}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertCircle size={16} className="text-orange-400" />
|
<AlertCircle size={16} className="text-orange-400" />
|
||||||
<span className="font-medium text-orange-400">
|
<span className="font-medium text-orange-400">
|
||||||
{automationPermissionGranted === null ? 'Checking Automation permission...' : 'Automation permission missing'}
|
{automationPermissionGranted === null ? t($ => $.scheduler.permissionChecking) : t($ => $.scheduler.permissionMissing)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={handlePermissionAction} className="app-button app-button-primary px-3 text-[11px]">
|
<button onClick={handlePermissionAction} className="app-button app-button-primary px-3 text-[11px]">
|
||||||
{automationPermissionGranted ? 'Revoke permission' : 'Grant permission'}
|
{automationPermissionGranted ? t($ => $.scheduler.revokePermission) : t($ => $.scheduler.grantPermission)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
|
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
|
||||||
@@ -410,11 +429,12 @@ export default function SchedulerView() {
|
|||||||
) : (
|
) : (
|
||||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
<section className="app-card mt-4 max-w-[760px] p-5">
|
||||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
<LockKeyhole size={17} className="text-accent" /> System Actions
|
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemActions)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-text-muted">
|
<p className="text-[12px] text-text-muted">
|
||||||
Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}.
|
{platform.os === 'windows'
|
||||||
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
|
? t($ => $.scheduler.windowsActionsDescription)
|
||||||
|
: t($ => $.scheduler.linuxActionsDescription)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+242
-205
File diff suppressed because it is too large
Load Diff
+38
-36
@@ -11,6 +11,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { isTransferActiveStatus } from '../utils/downloads';
|
import { isTransferActiveStatus } from '../utils/downloads';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
||||||
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
||||||
const [newQueueName, setNewQueueName] = useState('');
|
const [newQueueName, setNewQueueName] = useState('');
|
||||||
@@ -84,11 +86,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const queueId = selectedFilter.replace('queue:', '');
|
const queueId = selectedFilter.replace('queue:', '');
|
||||||
const q = queues.find(q => q.id === queueId);
|
const q = queues.find(q => q.id === queueId);
|
||||||
if (q && !q.isMain) {
|
if (q && !q.isMain) {
|
||||||
if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: q.name }))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void removeQueue(queueId).catch(error => {
|
void removeQueue(queueId).catch(error => {
|
||||||
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }), variant: 'error', isActionable: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,11 +146,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
if (addQueueSubmitRef.current) return;
|
if (addQueueSubmitRef.current) return;
|
||||||
const normalizedName = newQueueName.trim();
|
const normalizedName = newQueueName.trim();
|
||||||
if (!normalizedName) {
|
if (!normalizedName) {
|
||||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!addQueue(normalizedName)) {
|
if (!addQueue(normalizedName)) {
|
||||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addQueueSubmitRef.current = true;
|
addQueueSubmitRef.current = true;
|
||||||
@@ -161,11 +163,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const normalizedName = editingQueueName.trim();
|
const normalizedName = editingQueueName.trim();
|
||||||
if (!renamingQueueId) return;
|
if (!renamingQueueId) return;
|
||||||
if (!normalizedName) {
|
if (!normalizedName) {
|
||||||
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!renameQueue(renamingQueueId, normalizedName)) {
|
if (!renameQueue(renamingQueueId, normalizedName)) {
|
||||||
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renameQueueSubmitRef.current = true;
|
renameQueueSubmitRef.current = true;
|
||||||
@@ -240,18 +242,18 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="sidebar-toggle-button"
|
className="sidebar-toggle-button"
|
||||||
title="Hide Sidebar"
|
title={t($ => $.actions.hideSidebar)}
|
||||||
>
|
>
|
||||||
<PanelLeft size={14} strokeWidth={1.9} />
|
<PanelLeft size={14} strokeWidth={1.9} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
<div className="sidebar-section-label">Library</div>
|
<div className="sidebar-section-label">{t($ => $.navigation.library)}</div>
|
||||||
<NavItem icon={Inbox} label="All" filter="all" />
|
<NavItem icon={Inbox} label={t($ => $.navigation.filters.all)} filter="all" />
|
||||||
<NavItem icon={Zap} label="Active" filter="active" />
|
<NavItem icon={Zap} label={t($ => $.navigation.filters.active)} filter="active" />
|
||||||
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
|
<NavItem icon={CheckCircle2} label={t($ => $.navigation.filters.completed)} filter="completed" />
|
||||||
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
|
<NavItem icon={CircleDashed} label={t($ => $.navigation.filters.unfinished)} filter="unfinished" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
@@ -263,7 +265,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
aria-controls="sidebar-folders-list"
|
aria-controls="sidebar-folders-list"
|
||||||
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
|
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
|
||||||
>
|
>
|
||||||
<span>Folders</span>
|
<span>{t($ => $.navigation.folders)}</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
size={13}
|
size={13}
|
||||||
@@ -278,19 +280,19 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
inert={foldersCollapsed}
|
inert={foldersCollapsed}
|
||||||
>
|
>
|
||||||
<div className="sidebar-collapse-content">
|
<div className="sidebar-collapse-content">
|
||||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
<NavItem icon={Music} label={t($ => $.navigation.categories.musics)} filter="Musics" />
|
||||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
<NavItem icon={Film} label={t($ => $.navigation.categories.movies)} filter="Movies" />
|
||||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
<NavItem icon={Archive} label={t($ => $.navigation.categories.compressed)} filter="Compressed" />
|
||||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
<NavItem icon={FileText} label={t($ => $.navigation.categories.documents)} filter="Documents" />
|
||||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
<NavItem icon={ImageIcon} label={t($ => $.navigation.categories.pictures)} filter="Pictures" />
|
||||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
<NavItem icon={Box} label={t($ => $.navigation.categories.applications)} filter="Applications" />
|
||||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
<NavItem icon={FileQuestion} label={t($ => $.navigation.categories.other)} filter="Other" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
<div className="sidebar-section-label">Queues</div>
|
<div className="sidebar-section-label">{t($ => $.navigation.queues)}</div>
|
||||||
{queues.map(queue => (
|
{queues.map(queue => (
|
||||||
<QueueItem key={queue.id} queue={queue} />
|
<QueueItem key={queue.id} queue={queue} />
|
||||||
))}
|
))}
|
||||||
@@ -300,7 +302,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
<input
|
<input
|
||||||
ref={addInputRef}
|
ref={addInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Queue name"
|
placeholder={t($ => $.actions.queueName)}
|
||||||
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
||||||
value={newQueueName}
|
value={newQueueName}
|
||||||
onChange={e => setNewQueueName(e.target.value)}
|
onChange={e => setNewQueueName(e.target.value)}
|
||||||
@@ -318,16 +320,16 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
|
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
|
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
|
||||||
<span className="truncate">Add new queue</span>
|
<span className="truncate">{t($ => $.actions.addNewQueue)}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
<div className="sidebar-section-label">Tools</div>
|
<div className="sidebar-section-label">{t($ => $.navigation.tools)}</div>
|
||||||
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
|
<ToolItem icon={CalendarClock} label={t($ => $.navigation.scheduler)} view="scheduler" />
|
||||||
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
|
<ToolItem icon={Gauge} label={t($ => $.navigation.speedLimiter)} view="speedLimiter" />
|
||||||
<ToolItem icon={Bug} label="Logs" view="logs" />
|
<ToolItem icon={Bug} label={t($ => $.navigation.logs)} view="logs" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,7 +341,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors"
|
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<Settings className={`w-[18px] h-[18px] mr-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
|
<Settings className={`w-[18px] h-[18px] mr-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
|
||||||
<span>Settings</span>
|
<span>{t($ => $.navigation.settings)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,7 +362,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void startQueue(queueId).catch(error => {
|
void startQueue(queueId).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not start queue: ${String(error)}`,
|
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -368,7 +370,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Play size={14} className="mr-2 text-text-secondary" />
|
<Play size={14} className="mr-2 text-text-secondary" />
|
||||||
Start Queue
|
{t($ => $.actions.startQueue)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||||
@@ -377,7 +379,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void pauseQueue(queueId).catch(error => {
|
void pauseQueue(queueId).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not pause queue: ${String(error)}`,
|
message: t($ => $.sidebar.pauseQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -385,7 +387,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pause size={14} className="mr-2 text-text-secondary" />
|
<Pause size={14} className="mr-2 text-text-secondary" />
|
||||||
Pause Queue
|
{t($ => $.actions.pauseQueue)}
|
||||||
</button>
|
</button>
|
||||||
<div className="h-px bg-border-color my-1 mx-2" />
|
<div className="h-px bg-border-color my-1 mx-2" />
|
||||||
<button
|
<button
|
||||||
@@ -401,7 +403,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit2 size={14} className="mr-2 text-text-secondary" />
|
<Edit2 size={14} className="mr-2 text-text-secondary" />
|
||||||
Rename Queue
|
{t($ => $.actions.renameQueue)}
|
||||||
</button>
|
</button>
|
||||||
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
|
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
|
||||||
<button
|
<button
|
||||||
@@ -413,13 +415,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`Delete queue "${queue.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: queue.name }))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void removeQueue(queueId).catch(error => {
|
void removeQueue(queueId).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not delete queue: ${String(error)}`,
|
message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -427,7 +429,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} className="mr-2" />
|
<Trash2 size={14} className="mr-2" />
|
||||||
Delete Queue
|
{t($ => $.actions.deleteQueue)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
|
|||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type SpeedUnit = 'KB/s' | 'MB/s';
|
type SpeedUnit = 'KB/s' | 'MB/s';
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export function formatPresetValue(value: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SpeedLimiterView() {
|
export default function SpeedLimiterView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||||
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
|
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
|
||||||
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
|
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
|
||||||
@@ -132,12 +134,14 @@ export default function SpeedLimiterView() {
|
|||||||
setLastCustomSpeedLimitKiB(valueKiB);
|
setLastCustomSpeedLimitKiB(valueKiB);
|
||||||
setLastCustomSpeedLimitUnit(unit);
|
setLastCustomSpeedLimitUnit(unit);
|
||||||
addToast({
|
addToast({
|
||||||
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
|
message: enabled
|
||||||
|
? t($ => $.speedLimiter.globalLimitSaved, { value: numericValue, unit })
|
||||||
|
: t($ => $.speedLimiter.globalLimitDisabled),
|
||||||
variant: 'success'
|
variant: 'success'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast({
|
addToast({
|
||||||
message: `Could not save global speed limit: ${String(error)}`,
|
message: t($ => $.speedLimiter.saveFailed, { detail: String(error) }),
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
@@ -163,8 +167,8 @@ export default function SpeedLimiterView() {
|
|||||||
setValue(String(storedPresetDisplayValue));
|
setValue(String(storedPresetDisplayValue));
|
||||||
addToast({
|
addToast({
|
||||||
message: alreadyExists
|
message: alreadyExists
|
||||||
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
|
? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit })
|
||||||
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
|
: t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }),
|
||||||
variant: alreadyExists ? 'info' : 'success'
|
variant: alreadyExists ? 'info' : 'success'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -174,7 +178,7 @@ export default function SpeedLimiterView() {
|
|||||||
const nextPresets = presetValues.filter(value => value !== presetValue);
|
const nextPresets = presetValues.filter(value => value !== presetValue);
|
||||||
setSpeedLimitPresetValues(nextPresets);
|
setSpeedLimitPresetValues(nextPresets);
|
||||||
addToast({
|
addToast({
|
||||||
message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`,
|
message: t($ => $.speedLimiter.presetRemoved, { value: formatPresetValue(displayValue), unit }),
|
||||||
variant: 'info'
|
variant: 'info'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -207,25 +211,25 @@ export default function SpeedLimiterView() {
|
|||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${enabled ? 'translate-x-4' : 'translate-x-1'}`}
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${enabled ? 'translate-x-4' : 'translate-x-1'}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
Speed Limiter
|
{t($ => $.speedLimiter.title)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||||
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
|
{enabled ? `${currentDisplayValue} ${unit}` : t($ => $.speedLimiter.unlimited)}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
|
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
|
||||||
<Save size={14} /> Save Limit
|
<Save size={14} /> {t($ => $.speedLimiter.saveLimit)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
|
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
|
||||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||||
<Gauge size={18} className="text-accent" /> Global Speed Limit
|
<Gauge size={18} className="text-accent" /> {t($ => $.speedLimiter.globalSpeedLimit)}
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
|
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
|
||||||
Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
|
{t($ => $.speedLimiter.description)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
<div className="mt-6 flex items-center gap-3">
|
||||||
@@ -257,7 +261,7 @@ export default function SpeedLimiterView() {
|
|||||||
|
|
||||||
<div className="my-6 border-t border-border-color" />
|
<div className="my-6 border-t border-border-color" />
|
||||||
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
|
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
|
||||||
<Zap size={14} /> Quick Presets
|
<Zap size={14} /> {t($ => $.speedLimiter.quickPresets)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{presetValues.map(presetValue => {
|
{presetValues.map(presetValue => {
|
||||||
@@ -280,8 +284,8 @@ export default function SpeedLimiterView() {
|
|||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onClick={() => removePreset(presetValue)}
|
onClick={() => removePreset(presetValue)}
|
||||||
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
|
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
|
||||||
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
|
title={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
|
||||||
aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
|
aria-label={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -297,7 +301,7 @@ export default function SpeedLimiterView() {
|
|||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onChange={event => setCustomPresetValue(event.target.value)}
|
onChange={event => setCustomPresetValue(event.target.value)}
|
||||||
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
|
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
|
||||||
aria-label={`Custom preset in ${unit}`}
|
aria-label={t($ => $.speedLimiter.customPresetIn, { unit })}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-text-muted">{unit}</span>
|
<span className="text-[11px] text-text-muted">{unit}</span>
|
||||||
<button
|
<button
|
||||||
@@ -305,8 +309,8 @@ export default function SpeedLimiterView() {
|
|||||||
disabled={!enabled || isSaving}
|
disabled={!enabled || isSaving}
|
||||||
onClick={applyCustomPreset}
|
onClick={applyCustomPreset}
|
||||||
className="app-icon-button h-6 w-6 disabled:opacity-50"
|
className="app-icon-button h-6 w-6 disabled:opacity-50"
|
||||||
title="Add quick preset"
|
title={t($ => $.speedLimiter.addQuickPreset)}
|
||||||
aria-label="Add quick preset"
|
aria-label={t($ => $.speedLimiter.addQuickPreset)}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { Minus, Square, X } from 'lucide-react';
|
import { Minus, Square, X } from 'lucide-react';
|
||||||
import type { PointerEvent } from 'react';
|
import type { PointerEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
|
||||||
@@ -9,13 +10,15 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function WindowControls() {
|
export function WindowControls() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="window-controls" aria-label="Window controls">
|
<div className="window-controls" aria-label={t($ => $.window.controls)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="window-control close"
|
className="window-control close"
|
||||||
title="Close"
|
title={t($ => $.window.close)}
|
||||||
aria-label="Close"
|
aria-label={t($ => $.window.close)}
|
||||||
onPointerDown={stopTitlebarDrag}
|
onPointerDown={stopTitlebarDrag}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -27,8 +30,8 @@ export function WindowControls() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="window-control minimize"
|
className="window-control minimize"
|
||||||
title="Minimize"
|
title={t($ => $.window.minimize)}
|
||||||
aria-label="Minimize"
|
aria-label={t($ => $.window.minimize)}
|
||||||
onPointerDown={stopTitlebarDrag}
|
onPointerDown={stopTitlebarDrag}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -40,8 +43,8 @@ export function WindowControls() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="window-control maximize"
|
className="window-control maximize"
|
||||||
title="Maximize"
|
title={t($ => $.window.maximize)}
|
||||||
aria-label="Maximize"
|
aria-label={t($ => $.window.maximize)}
|
||||||
onPointerDown={stopTitlebarDrag}
|
onPointerDown={stopTitlebarDrag}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react';
|
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef, useLayoutEffect } from 'react';
|
||||||
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
|
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ export const useToast = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toast, removeToast, removeToastCompletely }) => {
|
const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void; removeToastCompletely: (id: string) => void }> = ({ toast, removeToast, removeToastCompletely }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const timerStartedAt = useRef<number | null>(null);
|
const timerStartedAt = useRef<number | null>(null);
|
||||||
@@ -196,7 +198,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeToast(toast.id)}
|
onClick={() => removeToast(toast.id)}
|
||||||
className="ml-2 mt-0.5 shrink-0 rounded-md p-1 text-text-secondary opacity-70 transition-all hover:bg-item-hover hover:text-text-primary hover:opacity-100 active:scale-95"
|
className="ml-2 mt-0.5 shrink-0 rounded-md p-1 text-text-secondary opacity-70 transition-all hover:bg-item-hover hover:text-text-primary hover:opacity-100 active:scale-95"
|
||||||
aria-label="Dismiss notification"
|
aria-label={t($ => $.actions.dismissNotification)}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" strokeWidth={2.5} />
|
<X className="w-4 h-4" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import i18n, { i18nReady } from './index';
|
||||||
|
|
||||||
|
describe('i18n runtime', () => {
|
||||||
|
it('falls back to bundled English while planned catalogs are untranslated', async () => {
|
||||||
|
await i18nReady;
|
||||||
|
const originalLanguage = i18n.language;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await i18n.changeLanguage('fa');
|
||||||
|
expect(i18n.t($ => $.navigation.library)).toBe('Library');
|
||||||
|
} finally {
|
||||||
|
await i18n.changeLanguage(originalLanguage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
APP_LOCALES,
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
localeDirection,
|
||||||
|
resolveAppLocale,
|
||||||
|
type AppLocale,
|
||||||
|
} from './locales';
|
||||||
|
import { defaultNS, resources } from './resources';
|
||||||
|
|
||||||
|
export { APP_LOCALES, APP_LOCALE_METADATA, DEFAULT_LOCALE, isAppLocale, localeDirection, resolveAppLocale } from './locales';
|
||||||
|
export type { AppLocale, AppLocaleDirection } from './locales';
|
||||||
|
|
||||||
|
const initialLocale = resolveAppLocale(
|
||||||
|
typeof navigator === 'undefined' ? undefined : navigator.language
|
||||||
|
);
|
||||||
|
|
||||||
|
export const i18nReady = i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
defaultNS,
|
||||||
|
fallbackLng: DEFAULT_LOCALE,
|
||||||
|
initAsync: false,
|
||||||
|
lng: initialLocale,
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
ns: [defaultNS],
|
||||||
|
resources,
|
||||||
|
returnNull: false,
|
||||||
|
supportedLngs: APP_LOCALES,
|
||||||
|
});
|
||||||
|
|
||||||
|
void i18nReady.then(() => syncDocumentLocale(initialLocale));
|
||||||
|
|
||||||
|
export const changeAppLocale = async (locale: AppLocale): Promise<void> => {
|
||||||
|
await i18n.changeLanguage(locale);
|
||||||
|
syncDocumentLocale(locale);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncDocumentLocale = (value: string | null | undefined): AppLocale => {
|
||||||
|
const locale = resolveAppLocale(value);
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
document.documentElement.dir = localeDirection(locale);
|
||||||
|
}
|
||||||
|
return locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
APP_LOCALES,
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
isAppLocale,
|
||||||
|
localeDirection,
|
||||||
|
resolveAppLocale,
|
||||||
|
} from './locales';
|
||||||
|
|
||||||
|
describe('app locale metadata', () => {
|
||||||
|
it('keeps the planned locale set stable', () => {
|
||||||
|
expect(APP_LOCALES).toEqual(['en', 'zh-CN', 'he', 'fa', 'uk']);
|
||||||
|
expect(DEFAULT_LOCALE).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes common system language tags', () => {
|
||||||
|
expect(resolveAppLocale('en-US')).toBe('en');
|
||||||
|
expect(resolveAppLocale('zh-Hans-CN')).toBe('zh-CN');
|
||||||
|
expect(resolveAppLocale('iw-IL')).toBe('he');
|
||||||
|
expect(resolveAppLocale('fa-IR')).toBe('fa');
|
||||||
|
expect(resolveAppLocale('uk-UA')).toBe('uk');
|
||||||
|
expect(resolveAppLocale('de-DE')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the planned RTL locales correctly', () => {
|
||||||
|
expect(localeDirection('en')).toBe('ltr');
|
||||||
|
expect(localeDirection('zh-CN')).toBe('ltr');
|
||||||
|
expect(localeDirection('he')).toBe('rtl');
|
||||||
|
expect(localeDirection('fa')).toBe('rtl');
|
||||||
|
expect(localeDirection('uk')).toBe('ltr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards locale values before they reach i18next', () => {
|
||||||
|
expect(isAppLocale('en')).toBe(true);
|
||||||
|
expect(isAppLocale('fa')).toBe(true);
|
||||||
|
expect(isAppLocale('de')).toBe(false);
|
||||||
|
expect(isAppLocale(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const APP_LOCALES = ['en', 'zh-CN', 'he', 'fa', 'uk'] as const;
|
||||||
|
|
||||||
|
export type AppLocale = typeof APP_LOCALES[number];
|
||||||
|
export type AppLocaleDirection = 'ltr' | 'rtl';
|
||||||
|
|
||||||
|
export const DEFAULT_LOCALE: AppLocale = 'en';
|
||||||
|
|
||||||
|
export const APP_LOCALE_METADATA: Record<AppLocale, {
|
||||||
|
direction: AppLocaleDirection;
|
||||||
|
isTranslated: boolean;
|
||||||
|
}> = {
|
||||||
|
en: { direction: 'ltr', isTranslated: true },
|
||||||
|
'zh-CN': { direction: 'ltr', isTranslated: false },
|
||||||
|
he: { direction: 'rtl', isTranslated: false },
|
||||||
|
fa: { direction: 'rtl', isTranslated: false },
|
||||||
|
uk: { direction: 'ltr', isTranslated: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAppLocale = (value: unknown): value is AppLocale =>
|
||||||
|
typeof value === 'string' && (APP_LOCALES as readonly string[]).includes(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a system or persisted BCP 47 language tag to a planned Firelink
|
||||||
|
* locale. Non-English locales intentionally fall back to English resources
|
||||||
|
* until their translations are added.
|
||||||
|
*/
|
||||||
|
export const resolveAppLocale = (value: string | null | undefined): AppLocale => {
|
||||||
|
const normalized = value?.trim().replace(/_/g, '-').toLowerCase();
|
||||||
|
if (!normalized) return DEFAULT_LOCALE;
|
||||||
|
|
||||||
|
if (normalized === 'zh-cn' || normalized.startsWith('zh-')) return 'zh-CN';
|
||||||
|
if (normalized === 'he' || normalized.startsWith('he-') || normalized === 'iw' || normalized.startsWith('iw-')) return 'he';
|
||||||
|
if (normalized === 'fa' || normalized.startsWith('fa-')) return 'fa';
|
||||||
|
if (normalized === 'uk' || normalized.startsWith('uk-')) return 'uk';
|
||||||
|
if (normalized === 'en' || normalized.startsWith('en-')) return 'en';
|
||||||
|
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const localeDirection = (locale: AppLocale): AppLocaleDirection =>
|
||||||
|
APP_LOCALE_METADATA[locale].direction;
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
const common = {
|
||||||
|
navigation: {
|
||||||
|
library: 'Library',
|
||||||
|
filters: {
|
||||||
|
all: 'All',
|
||||||
|
active: 'Active',
|
||||||
|
completed: 'Completed',
|
||||||
|
unfinished: 'Unfinished',
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
musics: 'Musics',
|
||||||
|
movies: 'Movies',
|
||||||
|
compressed: 'Compressed',
|
||||||
|
documents: 'Documents',
|
||||||
|
pictures: 'Pictures',
|
||||||
|
applications: 'Applications',
|
||||||
|
other: 'Other',
|
||||||
|
},
|
||||||
|
folders: 'Folders',
|
||||||
|
queues: 'Queues',
|
||||||
|
tools: 'Tools',
|
||||||
|
scheduler: 'Scheduler',
|
||||||
|
speedLimiter: 'Speed Limiter',
|
||||||
|
logs: 'Logs',
|
||||||
|
settings: 'Settings',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
hideSidebar: 'Hide Sidebar',
|
||||||
|
showSidebar: 'Show Sidebar',
|
||||||
|
resizeSidebar: 'Resize Sidebar',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
continue: 'Continue',
|
||||||
|
dismissNotification: 'Dismiss notification',
|
||||||
|
queueName: 'Queue name',
|
||||||
|
addNewQueue: 'Add new queue',
|
||||||
|
startQueue: 'Start Queue',
|
||||||
|
pauseQueue: 'Pause Queue',
|
||||||
|
renameQueue: 'Rename Queue',
|
||||||
|
deleteQueue: 'Delete Queue',
|
||||||
|
},
|
||||||
|
dialogs: {
|
||||||
|
errorBoundary: {
|
||||||
|
title: 'Something went wrong.',
|
||||||
|
description: 'A critical error occurred in the React component tree. The error details below can help identify the root cause.',
|
||||||
|
reload: 'Reload App',
|
||||||
|
},
|
||||||
|
duplicateDownloads: {
|
||||||
|
title: 'Duplicate Downloads Detected',
|
||||||
|
description: 'Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.',
|
||||||
|
rename: 'Rename',
|
||||||
|
replace: 'Replace',
|
||||||
|
skip: 'Skip',
|
||||||
|
},
|
||||||
|
removeDownload: {
|
||||||
|
title: 'Remove Download',
|
||||||
|
confirmationSingle: 'Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.',
|
||||||
|
confirmationMultiple: 'Are you sure you want to remove these {{count}} items from the list? You can also choose to delete the underlying files from your hard drive.',
|
||||||
|
errorSummary: '{{succeeded}} removed, {{failed}} failed: {{detail}}',
|
||||||
|
remove: 'Remove',
|
||||||
|
deleteFile: 'Delete file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
controls: 'Window controls',
|
||||||
|
close: 'Close',
|
||||||
|
minimize: 'Minimize',
|
||||||
|
maximize: 'Maximize',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
actions: {
|
||||||
|
moveUp: 'Move Up',
|
||||||
|
moveDown: 'Move Down',
|
||||||
|
pause: 'Pause',
|
||||||
|
start: 'Start',
|
||||||
|
resume: 'Resume',
|
||||||
|
options: 'Options',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
downloadedOf: '{{downloaded}} downloaded of {{total}} {{unit}}',
|
||||||
|
downloadedOfApproximate: '{{downloaded}} downloaded of approximately {{total}} {{unit}}',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
ready: 'Ready',
|
||||||
|
staged: 'In queue',
|
||||||
|
queued: 'Queued',
|
||||||
|
downloading: 'Downloading',
|
||||||
|
processing: 'Processing',
|
||||||
|
paused: 'Paused',
|
||||||
|
completed: 'Completed',
|
||||||
|
failed: 'Failed',
|
||||||
|
retrying: 'Retrying',
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
processing: 'Processing...',
|
||||||
|
muxing: 'Muxing...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
speedLimiter: {
|
||||||
|
title: 'Speed Limiter',
|
||||||
|
unlimited: 'Unlimited',
|
||||||
|
saveLimit: 'Save Limit',
|
||||||
|
globalSpeedLimit: 'Global Speed Limit',
|
||||||
|
description: 'Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.',
|
||||||
|
quickPresets: 'Quick Presets',
|
||||||
|
removePreset: 'Remove {{value}} {{unit}} preset',
|
||||||
|
customPresetIn: 'Custom preset in {{unit}}',
|
||||||
|
addQuickPreset: 'Add quick preset',
|
||||||
|
globalLimitSaved: 'Global limit saved at {{value}} {{unit}}',
|
||||||
|
globalLimitDisabled: 'Global speed limit disabled',
|
||||||
|
saveFailed: 'Could not save global speed limit: {{detail}}',
|
||||||
|
presetAlreadyExists: '{{value}} {{unit}} is already in quick presets',
|
||||||
|
presetAdded: 'Added {{value}} {{unit}} quick preset',
|
||||||
|
presetRemoved: 'Removed {{value}} {{unit}} quick preset',
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
title: 'Logs',
|
||||||
|
entries: '({{count}} entries)',
|
||||||
|
collecting: 'Collecting',
|
||||||
|
off: 'Off',
|
||||||
|
allLevels: 'All Levels',
|
||||||
|
levels: {
|
||||||
|
error: 'Error',
|
||||||
|
warn: 'Warn',
|
||||||
|
info: 'Info',
|
||||||
|
debug: 'Debug',
|
||||||
|
trace: 'Trace',
|
||||||
|
},
|
||||||
|
pauseLogging: 'Pause diagnostic logging',
|
||||||
|
enableLogging: 'Enable diagnostic logging',
|
||||||
|
clearDisplayed: 'Clear displayed logs',
|
||||||
|
export: 'Export logs',
|
||||||
|
exportButton: 'Export Logs',
|
||||||
|
logFiles: 'Log Files',
|
||||||
|
localDiagnostics: 'Local diagnostics:',
|
||||||
|
diagnosticsDescription: 'Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.',
|
||||||
|
noEntries: 'No persisted log entries are available yet.',
|
||||||
|
disabledDescription: 'Diagnostic logging is off. Existing support logs will appear here when available.',
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied to clipboard',
|
||||||
|
copyFailed: 'Failed to copy to clipboard',
|
||||||
|
exported: 'Support logs exported',
|
||||||
|
exportFailed: 'Could not export logs: {{detail}}',
|
||||||
|
cleared: 'Logs cleared',
|
||||||
|
clearFailed: 'Could not clear logs: {{detail}}',
|
||||||
|
enabled: 'Diagnostic logging enabled',
|
||||||
|
disabled: 'Diagnostic logging disabled',
|
||||||
|
updateFailed: 'Could not update diagnostic logging: {{detail}}',
|
||||||
|
},
|
||||||
|
scheduler: {
|
||||||
|
title: 'Scheduler',
|
||||||
|
running: 'Running',
|
||||||
|
disabled: 'Scheduler is disabled',
|
||||||
|
noScheduledDay: 'No scheduled day selected',
|
||||||
|
unsavedChanges: 'Unsaved changes',
|
||||||
|
runNow: 'Run Now',
|
||||||
|
pause: 'Pause',
|
||||||
|
saveSettings: 'Save Settings',
|
||||||
|
timing: 'Timing',
|
||||||
|
startTime: 'Start Time',
|
||||||
|
stopTime: 'Stop Time',
|
||||||
|
timingDescription: 'If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.',
|
||||||
|
runEveryDay: 'Run Every Day',
|
||||||
|
queuesToSchedule: 'Queues to Schedule',
|
||||||
|
defaultQueue: 'Default queue',
|
||||||
|
afterCompletion: 'After Completion',
|
||||||
|
afterCompletionDescription: 'Choose what happens after downloads started by the scheduler finish.',
|
||||||
|
actionWarning: 'This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.',
|
||||||
|
systemPermissions: 'System Permissions',
|
||||||
|
macPermissionDescription: 'Sleep, restart, and shut down require macOS Automation permission for System Events.',
|
||||||
|
permissionGranted: 'Automation permission granted',
|
||||||
|
permissionChecking: 'Checking Automation permission...',
|
||||||
|
permissionMissing: 'Automation permission missing',
|
||||||
|
revokePermission: 'Revoke permission',
|
||||||
|
grantPermission: 'Grant permission',
|
||||||
|
permissionAvailable: 'Automation permission is available.',
|
||||||
|
permissionMissingDetails: 'Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.',
|
||||||
|
revokePermissionDetails: 'macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.',
|
||||||
|
requestingPermission: 'Requesting Automation permission...',
|
||||||
|
enablePermissionDetails: 'Enable Firelink under Automation for System Events in System Settings, then return to Firelink.',
|
||||||
|
systemActions: 'System Actions',
|
||||||
|
windowsActionsDescription: 'Sleep, restart, and shut down use Windows system privileges. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
|
||||||
|
linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
|
||||||
|
validationDay: 'Select at least one day for the scheduler',
|
||||||
|
validationQueue: 'Select at least one queue for the scheduler',
|
||||||
|
validationStopTime: 'Stop time must be later than start time',
|
||||||
|
saved: 'Scheduler settings saved',
|
||||||
|
trackingOne: 'Tracking 1 scheduled download',
|
||||||
|
trackingMany: 'Tracking {{count}} scheduled downloads',
|
||||||
|
noStartableDownloads: 'No downloads in the selected queues can be started',
|
||||||
|
pausedOne: 'Paused 1 active download',
|
||||||
|
pausedMany: 'Paused {{count}} active downloads',
|
||||||
|
noActiveDownloads: 'No active downloads',
|
||||||
|
days: {
|
||||||
|
su: 'Su',
|
||||||
|
mo: 'Mo',
|
||||||
|
tu: 'Tu',
|
||||||
|
we: 'We',
|
||||||
|
th: 'Th',
|
||||||
|
fr: 'Fr',
|
||||||
|
sa: 'Sa',
|
||||||
|
},
|
||||||
|
postActions: {
|
||||||
|
none: 'Do nothing',
|
||||||
|
sleep: 'Sleep',
|
||||||
|
restart: 'Restart',
|
||||||
|
shutdown: 'Shut down',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
progress: 'Progress',
|
||||||
|
size: 'Size',
|
||||||
|
speed: 'Speed',
|
||||||
|
eta: 'ETA',
|
||||||
|
connections: 'Connections',
|
||||||
|
speedCap: 'Speed cap',
|
||||||
|
category: 'Category',
|
||||||
|
lastTry: 'Last try',
|
||||||
|
dateAdded: 'Date added',
|
||||||
|
destination: 'Destination',
|
||||||
|
lastError: 'Last error',
|
||||||
|
saved: ' (saved)',
|
||||||
|
defaultValue: ' (default)',
|
||||||
|
savedTooltip: 'Saved for this download; Settings changes apply to new downloads.',
|
||||||
|
defaultTooltip: 'Using the current default for new downloads.',
|
||||||
|
identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.',
|
||||||
|
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
|
||||||
|
download: 'Download',
|
||||||
|
url: 'URL',
|
||||||
|
fileName: 'File name',
|
||||||
|
saveLocation: 'Save location',
|
||||||
|
select: 'Select',
|
||||||
|
perFile: 'per file',
|
||||||
|
useCurrentDefault: 'Use current default ({{count}})',
|
||||||
|
savedPerDownload: 'Saved per download. The Settings default applies to new downloads.',
|
||||||
|
limit: 'Limit',
|
||||||
|
siteLogin: 'Site Login',
|
||||||
|
siteLoginRedownload: 'Site Login for Redownload',
|
||||||
|
matchingSiteLogin: 'Matching site login',
|
||||||
|
customCredentials: 'Custom credentials',
|
||||||
|
noLogin: 'No login',
|
||||||
|
useSavedLogin: 'Will use saved login if available.',
|
||||||
|
advancedTransfer: 'Advanced Transfer',
|
||||||
|
advancedTransferRedownload: 'Advanced Transfer for Redownload',
|
||||||
|
checksum: 'Checksum',
|
||||||
|
verify: 'Verify',
|
||||||
|
algorithm: 'Algorithm',
|
||||||
|
digest: 'Digest',
|
||||||
|
expectedDigest: 'Expected digest',
|
||||||
|
cookies: 'Cookies',
|
||||||
|
headers: 'Headers',
|
||||||
|
mirrors: 'Mirrors',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
enterValidUrl: 'Enter a valid URL.',
|
||||||
|
fileNameEmpty: 'File name cannot be empty.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
save: 'Save',
|
||||||
|
},
|
||||||
|
keychain: {
|
||||||
|
title: 'Credential Storage Access Needed',
|
||||||
|
stores: {
|
||||||
|
portable: 'the portable Firelink data folder',
|
||||||
|
windows: 'Windows Credential Manager',
|
||||||
|
linux: 'your Linux credential store',
|
||||||
|
macos: 'macOS Keychain',
|
||||||
|
system: "this system's credential store",
|
||||||
|
siteCredentials: "the system's credential store",
|
||||||
|
},
|
||||||
|
grantLabelPortable: 'Continue',
|
||||||
|
grantLabelMacos: 'Grant Access',
|
||||||
|
grantLabelDefault: 'Enable Secure Storage',
|
||||||
|
description: 'Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts, Firelink stores its pairing token in {{pairingStore}}. Optional site credentials are stored in {{siteCredentialStore}}.',
|
||||||
|
portableExplanation: 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.',
|
||||||
|
macosExplanation: 'macOS may show a Keychain prompt after you grant access.',
|
||||||
|
defaultExplanation: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.',
|
||||||
|
note: 'Note:',
|
||||||
|
portableNote: 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.',
|
||||||
|
defaultNote: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.',
|
||||||
|
hint: 'Hint:',
|
||||||
|
portableHint: 'The portable pairing token is already stored with this folder; you can enable it here or select Later.',
|
||||||
|
defaultHint: 'If you select Later, the extension will only work for this session.',
|
||||||
|
enableFromSettings: 'You can enable storage anytime from Settings > Integrations.',
|
||||||
|
later: 'Later',
|
||||||
|
enabling: 'Enabling...',
|
||||||
|
timeout: 'Credential storage request timed out. You can select Later and try again.',
|
||||||
|
unavailable: '{{store}} is unavailable.',
|
||||||
|
accessRequired: 'Grant credential-store access before regenerating the pairing token.',
|
||||||
|
storeUnavailable: 'Credential store access is unavailable.',
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.',
|
||||||
|
systemActionCountdown: '{{action}} in 10 seconds.',
|
||||||
|
systemActionCancelled: 'System action cancelled because another download is active or queued.',
|
||||||
|
systemActionFailed: 'Scheduled system action failed: {{detail}}',
|
||||||
|
downloadCompleteTitle: 'Download Complete',
|
||||||
|
downloadCompleteBody: '{{fileName}} has finished downloading.',
|
||||||
|
unknownFile: 'A file',
|
||||||
|
downloadFailedTitle: 'Download Failed',
|
||||||
|
downloadFailedBody: '{{fileName}} failed to download.',
|
||||||
|
initializeDownloadsFailed: 'Could not initialize saved downloads: {{detail}}',
|
||||||
|
extensionDisconnected: 'Browser extension disconnected because its pairing token changed.',
|
||||||
|
copyToken: 'Copy token',
|
||||||
|
integrations: 'Integrations',
|
||||||
|
copyTokenFailed: 'Could not copy pairing token: {{detail}}',
|
||||||
|
credentialPersistenceFailed: 'Secure credential persistence is unavailable. Browser pairing works for this session only: {{detail}}',
|
||||||
|
resumeDownloadsFailed: 'Could not resume saved downloads: {{detail}}',
|
||||||
|
updateAvailable: 'Firelink {{version}} is available.',
|
||||||
|
viewRelease: 'View release',
|
||||||
|
sleepPreventionFailed: 'Could not update sleep prevention: {{detail}}',
|
||||||
|
schedulerNoQueues: 'Scheduler has no valid queues selected. Update Scheduler settings.',
|
||||||
|
schedulerPauseOneFailed: 'Scheduler could not pause 1 download.',
|
||||||
|
schedulerPauseManyFailed: 'Scheduler could not pause {{count}} downloads.',
|
||||||
|
scheduledIncomplete: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
|
||||||
|
scheduledActionSkippedActive: 'Scheduled system action skipped because another download is active or queued.',
|
||||||
|
notificationsDisabled: 'System notifications are disabled for Firelink.',
|
||||||
|
notificationsFailed: 'Could not configure notifications: {{detail}}',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
deleteQueueConfirm: 'Delete queue "{{name}}"? Its unfinished downloads will move to Main Queue.',
|
||||||
|
deleteQueueFailed: 'Could not delete queue: {{detail}}',
|
||||||
|
queueNameEmpty: 'Queue name cannot be empty',
|
||||||
|
queueNameExists: 'A queue with this name already exists',
|
||||||
|
startQueueFailed: 'Could not start queue: {{detail}}',
|
||||||
|
pauseQueueFailed: 'Could not pause queue: {{detail}}',
|
||||||
|
},
|
||||||
|
downloadTable: {
|
||||||
|
unknownQueue: 'Unknown Queue',
|
||||||
|
allDownloads: 'All Downloads',
|
||||||
|
active: 'Active',
|
||||||
|
completed: 'Completed',
|
||||||
|
unfinished: 'Unfinished',
|
||||||
|
headers: {
|
||||||
|
fileName: 'File Name',
|
||||||
|
size: 'Size',
|
||||||
|
status: 'Status',
|
||||||
|
speed: 'Speed',
|
||||||
|
eta: 'ETA',
|
||||||
|
dateAdded: 'Date Added',
|
||||||
|
},
|
||||||
|
addDownload: 'Add Download',
|
||||||
|
resumeAll: 'Resume All',
|
||||||
|
pauseAll: 'Pause All',
|
||||||
|
queueEmpty: 'Queue is empty',
|
||||||
|
noCompletedDownloads: 'No Completed Downloads',
|
||||||
|
noDownloads: 'No Downloads',
|
||||||
|
queueEmptyDescription: 'Add downloads to this queue from an item menu or the Add window.',
|
||||||
|
completedDescription: 'Completed downloads will appear here.',
|
||||||
|
clickToAdd: 'Click',
|
||||||
|
addButtonOr: 'button or',
|
||||||
|
toAddDownloads: 'to add downloads',
|
||||||
|
nonResumableOne: '1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?',
|
||||||
|
nonResumableMany: '{{count}} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?',
|
||||||
|
interactionError: '{{message}}: {{detail}}',
|
||||||
|
openFileFailed: 'Could not open downloaded file',
|
||||||
|
revealFileFailed: 'Could not show download in Finder',
|
||||||
|
pauseFailed: 'Could not pause download',
|
||||||
|
resumeFailed: 'Could not resume {{fileName}}',
|
||||||
|
moveManyFailed: 'Could not move downloads to queue',
|
||||||
|
moveOneFailed: 'Could not move download to queue',
|
||||||
|
copyAddressesFailed: 'Could not copy addresses',
|
||||||
|
copyAddressFailed: 'Could not copy address',
|
||||||
|
copyPathFailed: 'Could not copy file path',
|
||||||
|
missingFileName: 'File name is missing',
|
||||||
|
redownloadFailed: 'Redownload failed',
|
||||||
|
startResume: 'Start/Resume',
|
||||||
|
addToQueue: 'Add to Queue',
|
||||||
|
copyAddress: 'Copy Address',
|
||||||
|
remove: 'Remove',
|
||||||
|
open: 'Open',
|
||||||
|
showInFolder: 'Show in Folder',
|
||||||
|
pause: 'Pause',
|
||||||
|
start: 'Start',
|
||||||
|
resume: 'Resume',
|
||||||
|
redownload: 'Redownload',
|
||||||
|
copyFilePath: 'Copy File Path',
|
||||||
|
properties: 'Properties',
|
||||||
|
backendRejectedStart: 'The backend rejected the start/resume request.',
|
||||||
|
transferActive: 'Cannot change properties while transfer is active. Pause it first.',
|
||||||
|
redownloadNotFound: 'Cannot redownload: download was not found.',
|
||||||
|
redownloadActive: 'Cannot redownload a {{status}} download. Pause or wait for it to finish first.',
|
||||||
|
originalUrlMissing: 'Cannot redownload: original URL is missing.',
|
||||||
|
pauseBeforeMove: 'Pause {{file}} before moving it to another queue.',
|
||||||
|
},
|
||||||
|
addDownloads: {
|
||||||
|
unknownSize: 'Unknown size',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
discardSetup: 'Discard this download setup?',
|
||||||
|
speedInvalid: 'Speed limit must be greater than zero',
|
||||||
|
duplicateResolveFailed: 'Could not resolve duplicate downloads: {{detail}}',
|
||||||
|
addedWithFailures: '{{added}} added, {{failed}} failed. {{detail}}',
|
||||||
|
addedOne: '1 download added',
|
||||||
|
addedMany: '{{count}} downloads added',
|
||||||
|
urlAlreadyQueued: 'URL already in queue',
|
||||||
|
destinationConflict: 'Another selected download uses this destination',
|
||||||
|
existingDownloadDestination: 'Existing Firelink download uses this destination',
|
||||||
|
fileExistsOnDisk: 'File exists on disk; rename or skip to avoid deleting unrelated data',
|
||||||
|
backendRejectedStart: 'Backend rejected download start.',
|
||||||
|
playlistNoEntries: 'Playlist contains no downloadable entries',
|
||||||
|
invalidMediaMetadata: 'Invalid media metadata or no formats found',
|
||||||
|
noAvailableName: 'Could not find an available name for {{file}}.',
|
||||||
|
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
|
||||||
|
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
|
||||||
|
downloadLinks: 'Download Links',
|
||||||
|
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from Youtube, X, TikTok, Instagram, Reddit, etc.',
|
||||||
|
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
||||||
|
safeEntryLimit: ' (safe entry limit reached)',
|
||||||
|
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
||||||
|
clearSelection: 'Clear selection',
|
||||||
|
selectAll: 'Select all',
|
||||||
|
refreshMetadata: 'Refresh Metadata',
|
||||||
|
files: 'Files',
|
||||||
|
required: 'Required',
|
||||||
|
free: 'Free',
|
||||||
|
preview: 'Preview',
|
||||||
|
file: 'File',
|
||||||
|
size: 'Size',
|
||||||
|
status: 'Status',
|
||||||
|
downloadPreview: 'Download preview',
|
||||||
|
noLinks: 'No links added yet.',
|
||||||
|
selectItem: 'Select {{file}}',
|
||||||
|
fetchingPlaylist: 'Fetching playlist...',
|
||||||
|
fetching: 'Fetching...',
|
||||||
|
unsafeUrl: 'Unsafe URL',
|
||||||
|
playlistFailed: 'Playlist failed',
|
||||||
|
metadataFailed: 'Metadata failed',
|
||||||
|
fallback: 'Fallback',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
ready: 'Ready',
|
||||||
|
mediaFormat: 'Media Format',
|
||||||
|
playlistItem: 'Playlist item {{index}}',
|
||||||
|
fetchingMediaStreams: 'Fetching media streams...',
|
||||||
|
availableStreams: 'Available Streams',
|
||||||
|
availableMediaStreams: 'Available media streams',
|
||||||
|
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
|
||||||
|
saveLocation: 'Save Location',
|
||||||
|
browse: 'Browse',
|
||||||
|
categoryFolders: 'Files will be organized into category folders.',
|
||||||
|
sharedFolder: 'All selected downloads will use this folder.',
|
||||||
|
transferSettings: 'Transfer Settings',
|
||||||
|
connectionsPerFile: 'Connections per File',
|
||||||
|
connectionsPerFileAria: 'Connections per file',
|
||||||
|
limitSpeedPerFile: 'Limit speed per file',
|
||||||
|
speedLimitPerFileAria: 'Speed limit per file',
|
||||||
|
authorization: 'Authorization',
|
||||||
|
useAuthorization: 'Use authorization',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
advancedTransfer: 'Advanced Transfer',
|
||||||
|
verifyChecksum: 'Verify Checksum',
|
||||||
|
checksumAlgorithm: 'Checksum algorithm',
|
||||||
|
expectedDigest: 'Expected digest',
|
||||||
|
headers: 'Headers',
|
||||||
|
requestHeaders: 'Request headers',
|
||||||
|
cookies: 'Cookies',
|
||||||
|
cookiePlaceholder: 'name=value; other=value',
|
||||||
|
mirrors: 'Mirrors',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
startDownloads: 'Start Downloads',
|
||||||
|
addToQueue: 'Add to queue',
|
||||||
|
pasteOneOrMore: 'Paste one or more links.',
|
||||||
|
selectAtLeastOne: 'Select at least one download.',
|
||||||
|
correctInvalid: 'Correct or remove {{count}} invalid URL{{plural}} before continuing.',
|
||||||
|
waitingForMetadata: 'Waiting for metadata for {{count}} download{{plural}}.',
|
||||||
|
removeUnsafe: 'Remove {{count}} unsafe URL{{plural}} before continuing.',
|
||||||
|
mediaMetadataUnavailableSummary: 'Media metadata is unavailable for {{count}} item{{plural}}. Refresh metadata before adding.',
|
||||||
|
metadataUnavailableFallback: 'Metadata is unavailable. Downloads can still be added using fallback details.',
|
||||||
|
fallbackReady: '{{ready}} download{{readyPlural}} ready; {{failed}} will use fallback filename and unknown size.',
|
||||||
|
readyToAdd: 'Ready to add {{count}} download{{plural}}.',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
tabs: {
|
||||||
|
downloads: 'Downloads',
|
||||||
|
lookAndFeel: 'Look and feel',
|
||||||
|
network: 'Network',
|
||||||
|
locations: 'Locations',
|
||||||
|
siteLogins: 'Site Logins',
|
||||||
|
power: 'Power',
|
||||||
|
engine: 'Engine',
|
||||||
|
integrations: 'Integrations',
|
||||||
|
about: 'About',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
thisOs: 'this OS',
|
||||||
|
checking: 'Checking...',
|
||||||
|
ready: 'Ready',
|
||||||
|
errorMissing: 'Error / Missing',
|
||||||
|
hideTechnicalDetails: 'Hide technical details',
|
||||||
|
showTechnicalDetails: 'Show technical details',
|
||||||
|
binary: 'Binary',
|
||||||
|
expected: 'Expected',
|
||||||
|
error: 'Error',
|
||||||
|
tip: 'Tip',
|
||||||
|
stderr: 'stderr',
|
||||||
|
daemonProcessAlive: 'Daemon process alive',
|
||||||
|
rpcReady: 'RPC ready',
|
||||||
|
rpcPort: 'RPC port',
|
||||||
|
daemonStderr: 'daemon stderr',
|
||||||
|
packaging: 'Packaging: PyInstaller onedir (_internal required)',
|
||||||
|
internalDirectoryFound: '_internal directory found',
|
||||||
|
pythonRuntimeFound: 'Python runtime found',
|
||||||
|
externalOpenFailed: 'Could not open {{label}}: {{detail}}',
|
||||||
|
updateUnexpectedResponse: 'The update check returned an unexpected response.',
|
||||||
|
updateUntrustedReleaseUrl: 'The update check returned an untrusted release URL.',
|
||||||
|
updateFailed: 'Update check failed: {{detail}}',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
defaultConnections: 'Default connections:',
|
||||||
|
defaultConnectionsDescription: 'New downloads; existing items keep their saved value',
|
||||||
|
parallelDownloads: 'Parallel downloads:',
|
||||||
|
parallelDownloadsDescription: 'Max simultaneous active files',
|
||||||
|
automaticRetries: 'Automatic retries:',
|
||||||
|
automaticRetriesDescription: 'If a connection fails',
|
||||||
|
systemNotification: 'Show system notification when download completes',
|
||||||
|
systemNotificationDescription: 'Uses your operating system notification settings',
|
||||||
|
completionChime: 'Play in-app completion chime',
|
||||||
|
completionChimeDescription: 'Optional Firelink sound, independent of system notifications',
|
||||||
|
clipboardLinks: 'Add clipboard links when Firelink becomes active',
|
||||||
|
clipboardLinksDescription: 'Opens supported copied links in the Add window. Off by default.',
|
||||||
|
},
|
||||||
|
lookAndFeel: {
|
||||||
|
appTheme: 'App Theme',
|
||||||
|
theme: 'Theme',
|
||||||
|
ariaLabel: 'App theme',
|
||||||
|
system: 'System',
|
||||||
|
light: 'Light',
|
||||||
|
dark: 'Dark',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
nord: 'Nord',
|
||||||
|
systemAppearance: 'System follows the current {{platform}} light or dark appearance.',
|
||||||
|
display: 'Display',
|
||||||
|
fontSize: 'Font size',
|
||||||
|
fontSizeDescription: 'Scales the download list and compact controls.',
|
||||||
|
small: 'Small',
|
||||||
|
standard: 'Standard',
|
||||||
|
large: 'Large',
|
||||||
|
listDensity: 'List density',
|
||||||
|
listDensityDescription: 'Changes row height, spacing, and progress bar scale.',
|
||||||
|
compact: 'Compact',
|
||||||
|
comfortable: 'Comfortable',
|
||||||
|
relaxed: 'Relaxed',
|
||||||
|
osIntegration: 'OS Integration',
|
||||||
|
dockBadge: 'Show badge on Dock icon',
|
||||||
|
dockBadgeDescription: 'Displays active download count on Firelink Dock icon.',
|
||||||
|
menuBarIcon: 'Show menu bar icon',
|
||||||
|
statusIndicatorIcon: 'Show status indicator icon',
|
||||||
|
systemTrayIcon: 'Show system tray icon',
|
||||||
|
macosTrayDescription: 'Provides quick access from the macOS menu bar.',
|
||||||
|
windowsTrayDescription: 'Provides quick access from the Windows notification area.',
|
||||||
|
linuxTrayDescription: 'Provides quick access from the desktop tray or status area when available.',
|
||||||
|
defaultTrayDescription: 'Provides quick access from the OS tray area when available.',
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
proxy: 'Proxy',
|
||||||
|
mode: 'Mode',
|
||||||
|
modeDescription: 'Controls proxy use for new download requests.',
|
||||||
|
noProxy: 'No Proxy',
|
||||||
|
systemProxy: 'System Proxy',
|
||||||
|
customProxy: 'Custom Proxy',
|
||||||
|
proxyHost: 'Proxy host',
|
||||||
|
proxyHostDescription: 'Host name, IP address, or HTTP proxy URL.',
|
||||||
|
proxyHostPlaceholder: '127.0.0.1 or http://127.0.0.1',
|
||||||
|
proxyPort: 'Proxy port',
|
||||||
|
proxyPortDescription: 'Valid range is 1 to 65535.',
|
||||||
|
noProxyDescription: 'Downloads ignore configured proxies.',
|
||||||
|
systemProxyDescription: 'Downloads use the detected {{platform}} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.',
|
||||||
|
customProxyDescription: 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.',
|
||||||
|
invalidCustomProxy: 'Enter a valid HTTP proxy host and port to enable the custom proxy.',
|
||||||
|
incompleteCustomProxy: 'Enter a proxy host and port to enable the custom proxy.',
|
||||||
|
checkingSystemProxy: 'Checking system proxy configuration…',
|
||||||
|
detectedSystemProxy: 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.',
|
||||||
|
noSystemProxy: 'No usable system proxy was detected. Downloads will use no proxy.',
|
||||||
|
systemProxyReadFailed: 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.',
|
||||||
|
identity: 'Identity',
|
||||||
|
customUserAgent: 'Custom User-Agent',
|
||||||
|
userAgentDescription: 'Applied to metadata fetches and download engines.',
|
||||||
|
userAgentPlaceholder: 'Leave blank for Firelink default',
|
||||||
|
userAgentOverrides: 'Overrides the outbound User-Agent header. Leave blank for Firelink defaults.',
|
||||||
|
chromeWindows: 'Chrome (Windows)',
|
||||||
|
chromeMacos: 'Chrome (macOS)',
|
||||||
|
edgeWindows: 'Edge (Windows)',
|
||||||
|
firefoxWindows: 'Firefox (Windows)',
|
||||||
|
firefoxMacos: 'Firefox (macOS)',
|
||||||
|
safariMacos: 'Safari (macOS)',
|
||||||
|
windowsDesktop: 'Windows desktop',
|
||||||
|
macosDesktop: 'macOS desktop',
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
baseDownloadFolder: 'Base Download Folder',
|
||||||
|
baseDownloadFolderDescription: 'Automatic category folders are created inside this folder.',
|
||||||
|
browse: 'Browse',
|
||||||
|
baseFolderCreateFailed: 'Base folder saved, but category folders could not be created: {{detail}}',
|
||||||
|
baseFolderUpdated: 'Base download folder updated',
|
||||||
|
askWhereToSave: 'Ask where to save when adding downloads',
|
||||||
|
automaticCategorySubfolders: 'Automatically save files to category subfolders',
|
||||||
|
automaticCategorySubfoldersDescription: 'When off, automatic downloads use the base folder.',
|
||||||
|
categorySubfolders: 'Category Subfolders',
|
||||||
|
relativeToBase: 'Relative to the base folder',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
categorySubfolder: '{{category}} subfolder',
|
||||||
|
customFolder: 'Custom folder',
|
||||||
|
useAutomatic: 'Use automatic',
|
||||||
|
resetDefaults: 'Reset Defaults',
|
||||||
|
resetToast: 'Reset category locations to default',
|
||||||
|
},
|
||||||
|
siteLogins: {
|
||||||
|
title: 'Site Credentials',
|
||||||
|
noSavedLogins: 'No saved logins.',
|
||||||
|
user: 'User: {{username}}',
|
||||||
|
deleteCredential: 'Delete credential',
|
||||||
|
deletedCredential: 'Deleted credential',
|
||||||
|
deleteFailed: 'Could not delete credential: {{detail}}',
|
||||||
|
addTitle: 'Add Site Credentials',
|
||||||
|
urlPattern: 'URL Pattern:',
|
||||||
|
urlPatternPlaceholder: 'e.g. *.example.com or example.com/downloads',
|
||||||
|
username: 'Username:',
|
||||||
|
password: 'Password:',
|
||||||
|
usernamePlaceholder: 'Username',
|
||||||
|
passwordPlaceholder: 'Password',
|
||||||
|
saving: 'Saving…',
|
||||||
|
addLogin: 'Add Login',
|
||||||
|
urlPatternRequired: 'URL pattern is required.',
|
||||||
|
urlPatternWhitespace: 'URL pattern cannot contain whitespace.',
|
||||||
|
usernameRequired: 'Username is required.',
|
||||||
|
passwordRequired: 'Password is required.',
|
||||||
|
accessBeforeSave: 'Grant credential-store access before saving a site login.',
|
||||||
|
saveFailed: 'Failed to save site credential securely.',
|
||||||
|
addedCredential: 'Added site credential',
|
||||||
|
},
|
||||||
|
power: {
|
||||||
|
title: 'Power Management',
|
||||||
|
preventSleep: 'Prevent system sleep while downloads are active',
|
||||||
|
preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.',
|
||||||
|
},
|
||||||
|
engine: {
|
||||||
|
title: 'Media Downloader & Engines',
|
||||||
|
description: 'Successful results are reused for this app session. Recheck runs real validation again.',
|
||||||
|
recheck: 'Recheck engines',
|
||||||
|
coreDownloader: 'Core Downloader (Aria2)',
|
||||||
|
version: 'Version:',
|
||||||
|
status: 'Status:',
|
||||||
|
mediaExtractors: 'Media Extractors',
|
||||||
|
browserCookiesSource: 'Browser Cookies Source:',
|
||||||
|
cookieDescription: 'yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.',
|
||||||
|
none: 'None',
|
||||||
|
},
|
||||||
|
integrations: {
|
||||||
|
title: 'Connect Browser Extension',
|
||||||
|
description: 'Capture downloads directly from your browser in three easy steps.',
|
||||||
|
portablePairingEnabled: 'Portable Pairing Enabled',
|
||||||
|
pairingTokenPersisted: 'Pairing Token Persisted',
|
||||||
|
portablePersistedDescription: 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.',
|
||||||
|
systemPersistedDescription: 'Your pairing token is stored in the system credential store and will persist across restarts.',
|
||||||
|
portablePairingAvailable: 'Portable Pairing Available',
|
||||||
|
credentialStorageNeeded: 'Credential Storage Needed',
|
||||||
|
portableAvailableDescription: 'Your pairing token is stored with this portable Firelink folder and will persist across restarts. Enable it here to review the portable-storage warning.',
|
||||||
|
credentialStorageDescription: "Firelink needs access to this system's credential store to securely save your pairing token across app restarts. Currently, your extension will only stay connected for this session.",
|
||||||
|
reviewPortablePairing: 'Review Portable Pairing',
|
||||||
|
grantCredentialAccess: 'Grant Credential Access',
|
||||||
|
copyToken: 'Copy Token',
|
||||||
|
tokenDescription: 'This secure token authorizes your browser extension.',
|
||||||
|
tokenCopied: 'Token copied to clipboard!',
|
||||||
|
tokenCopyFailed: 'Could not copy token: {{detail}}',
|
||||||
|
pairingTokenRegenerated: 'Pairing token regenerated',
|
||||||
|
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
|
||||||
|
getExtension: 'Get Extension',
|
||||||
|
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
|
||||||
|
firefoxAddons: 'Firefox Add-ons',
|
||||||
|
chromiumZip: 'Chromium ZIP',
|
||||||
|
pasteConnect: 'Paste & Connect',
|
||||||
|
pasteConnectDescription: "Click the Firelink icon in your browser's toolbar and paste the copied token.",
|
||||||
|
extensionServerStatus: 'Extension Server Status:',
|
||||||
|
listening: '● Listening on 127.0.0.1:{{port}}',
|
||||||
|
unavailable: '● Server unavailable',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
firelinkIcon: 'Firelink Icon',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
version: 'Version {{version}}',
|
||||||
|
description: 'Cross-platform download manager for macOS, Windows, and Linux.',
|
||||||
|
sourceCode: 'Source Code',
|
||||||
|
source: 'Source',
|
||||||
|
upToDate: 'Firelink {{version}} is up to date.',
|
||||||
|
updateAvailable: 'Firelink {{version}} is available.',
|
||||||
|
viewRelease: 'View release',
|
||||||
|
updates: 'Updates',
|
||||||
|
checkForUpdates: 'Check for Updates',
|
||||||
|
updateDescription: 'Firelink checks GitHub Releases for new versions.',
|
||||||
|
checking: 'Checking...',
|
||||||
|
checkNow: 'Check Now',
|
||||||
|
automaticUpdates: 'Automatically check for updates',
|
||||||
|
createdBy: 'Created by NimBold',
|
||||||
|
mitLicense: 'MIT License',
|
||||||
|
credits: 'Credits: aria2, yt-dlp, FFmpeg, and Deno.',
|
||||||
|
copyright: 'Copyright © 2026 NimBold. All rights reserved.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
ready: 'Ready',
|
||||||
|
active: '{{count}} active',
|
||||||
|
queued: '{{count}} queued',
|
||||||
|
done: '{{count}} done',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const defaultNS = 'common' as const;
|
||||||
|
|
||||||
|
export const resources = {
|
||||||
|
en: {
|
||||||
|
common,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import 'i18next';
|
||||||
|
import type { defaultNS, resources } from './i18n/resources';
|
||||||
|
|
||||||
|
declare module 'i18next' {
|
||||||
|
interface CustomTypeOptions {
|
||||||
|
defaultNS: typeof defaultNS;
|
||||||
|
enableSelector: 'optimize';
|
||||||
|
resources: typeof resources['en'];
|
||||||
|
returnNull: false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { i18nReady } from "./i18n";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { ToastProvider } from "./contexts/ToastContext";
|
import { ToastProvider } from "./contexts/ToastContext";
|
||||||
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
|
||||||
@@ -34,7 +35,9 @@ console.warn = (...values: unknown[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (rootElement) {
|
const renderApp = () => {
|
||||||
|
if (!rootElement) return;
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@@ -44,7 +47,12 @@ if (rootElement) {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
void i18nReady.then(renderApp).catch(error => {
|
||||||
|
console.error('Failed to initialize localization:', error);
|
||||||
|
renderApp();
|
||||||
|
});
|
||||||
|
|
||||||
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
|
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
|
||||||
// Individual components that provide custom context menus call preventDefault()
|
// Individual components that provide custom context menus call preventDefault()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
resolveCategoryDestination
|
resolveCategoryDestination
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
export type { DownloadCategory } from '../utils/downloads';
|
export type { DownloadCategory } from '../utils/downloads';
|
||||||
|
|
||||||
@@ -896,7 +897,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
||||||
throw new Error("Cannot change properties while transfer is active. Pause it first.");
|
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
|
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
|
||||||
@@ -1011,15 +1012,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
await waitForPendingStartupResume();
|
await waitForPendingStartupResume();
|
||||||
const targetItem = get().downloads.find(d => d.id === id);
|
const targetItem = get().downloads.find(d => d.id === id);
|
||||||
if (!targetItem) {
|
if (!targetItem) {
|
||||||
throw new Error('Cannot redownload: download was not found.');
|
throw new Error(i18n.t($ => $.downloadTable.redownloadNotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['completed', 'failed', 'paused'].includes(targetItem.status)) {
|
if (!['completed', 'failed', 'paused'].includes(targetItem.status)) {
|
||||||
throw new Error(`Cannot redownload a ${targetItem.status} download. Pause or wait for it to finish first.`);
|
throw new Error(i18n.t($ => $.downloadTable.redownloadActive, {
|
||||||
|
status: i18n.t($ => $.downloads.status[targetItem.status])
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = targetItem.url?.trim();
|
const url = targetItem.url?.trim();
|
||||||
if (!url) throw new Error('Cannot redownload: original URL is missing.');
|
if (!url) throw new Error(i18n.t($ => $.downloadTable.originalUrlMissing));
|
||||||
|
|
||||||
setDownloadControlIntent(id, 'resume');
|
setDownloadControlIntent(id, 'resume');
|
||||||
await invalidateAndWaitForDispatch(id);
|
await invalidateAndWaitForDispatch(id);
|
||||||
@@ -1283,7 +1286,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
const selected = get().downloads.filter(item => selectedIds.has(item.id));
|
const selected = get().downloads.filter(item => selectedIds.has(item.id));
|
||||||
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
|
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
|
||||||
if (locked) {
|
if (locked) {
|
||||||
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
|
throw new Error(i18n.t($ => $.downloadTable.pauseBeforeMove, { file: locked.fileName }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const movableSelected = selected.filter(item => item.status !== 'completed');
|
const movableSelected = selected.filter(item => item.status !== 'completed');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
normalizeDownloadLocationSettings
|
normalizeDownloadLocationSettings
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
let settingsQueue: Promise<void> = Promise.resolve();
|
let settingsQueue: Promise<void> = Promise.resolve();
|
||||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
|
||||||
@@ -425,11 +426,11 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
const current = get();
|
const current = get();
|
||||||
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
|
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
|
||||||
set({ showKeychainModal: true });
|
set({ showKeychainModal: true });
|
||||||
throw new Error('Grant credential-store access before regenerating the pairing token.');
|
throw new Error(i18n.t($ => $.keychain.accessRequired));
|
||||||
}
|
}
|
||||||
const result = await invoke('regenerate_pairing_token');
|
const result = await invoke('regenerate_pairing_token');
|
||||||
if (!result.persistent) {
|
if (!result.persistent) {
|
||||||
throw new Error(result.error || 'Credential store access is unavailable.');
|
throw new Error(result.error || i18n.t($ => $.keychain.storeUnavailable));
|
||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
extensionPairingToken: result.token,
|
extensionPairingToken: result.token,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isMediaUrl
|
isMediaUrl
|
||||||
} from './downloads';
|
} from './downloads';
|
||||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
|
||||||
|
|
||||||
@@ -361,20 +362,55 @@ export const mediaFileNameForSelectedFormat = (
|
|||||||
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
|
return canonicalizeDownloadFileName(`${baseName}.${selectedExt}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
export type MetadataSummaryState =
|
||||||
if (rows.length === 0) return 'Paste one or more links.';
|
| { type: 'empty' }
|
||||||
|
| { type: 'none-selected' }
|
||||||
|
| { type: 'invalid'; count: number }
|
||||||
|
| { type: 'loading'; count: number }
|
||||||
|
| { type: 'unsafe'; count: number }
|
||||||
|
| { type: 'media-error'; count: number }
|
||||||
|
| { type: 'all-error' }
|
||||||
|
| { type: 'fallback'; ready: number; failed: number }
|
||||||
|
| { type: 'ready'; count: number };
|
||||||
|
|
||||||
|
export const metadataSummaryState = (rows: AddDownloadDraftRow[]): MetadataSummaryState => {
|
||||||
|
if (rows.length === 0) return { type: 'empty' };
|
||||||
|
|
||||||
const selectedRows = rows.filter(row => row.selected !== false);
|
const selectedRows = rows.filter(row => row.selected !== false);
|
||||||
if (selectedRows.length === 0) return 'Select at least one download.';
|
if (selectedRows.length === 0) return { type: 'none-selected' };
|
||||||
|
|
||||||
|
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
|
||||||
|
if (invalid > 0) return { type: 'invalid', count: invalid };
|
||||||
|
|
||||||
|
const loading = selectedRows.filter(row => row.status === 'loading').length;
|
||||||
|
if (loading > 0) return { type: 'loading', count: loading };
|
||||||
|
|
||||||
|
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||||
|
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
|
||||||
|
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||||
|
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||||
|
if (blocked > 0) return { type: 'unsafe', count: blocked };
|
||||||
|
if (failedMedia > 0) return { type: 'media-error', count: failedMedia };
|
||||||
|
if (failed === selectedRows.length) return { type: 'all-error' };
|
||||||
|
if (failed > 0) return { type: 'fallback', ready, failed };
|
||||||
|
return { type: 'ready', count: ready };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
||||||
|
if (rows.length === 0) return i18n.t($ => $.addDownloads.pasteOneOrMore);
|
||||||
|
|
||||||
|
const selectedRows = rows.filter(row => row.selected !== false);
|
||||||
|
if (selectedRows.length === 0) return i18n.t($ => $.addDownloads.selectAtLeastOne);
|
||||||
|
const plural = (count: number) => count === 1 ? '' : 's';
|
||||||
|
|
||||||
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
|
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
|
||||||
if (invalid > 0) {
|
if (invalid > 0) {
|
||||||
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
|
return i18n.t($ => $.addDownloads.correctInvalid, { count: invalid, plural: plural(invalid) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = selectedRows.filter(row => row.status === 'loading').length;
|
const loading = selectedRows.filter(row => row.status === 'loading').length;
|
||||||
if (loading > 0) {
|
if (loading > 0) {
|
||||||
return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`;
|
return i18n.t($ => $.addDownloads.waitingForMetadata, { count: loading, plural: plural(loading) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
|
||||||
@@ -382,16 +418,16 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
|
|||||||
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length;
|
||||||
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
const ready = selectedRows.filter(row => row.status === 'ready').length;
|
||||||
if (blocked > 0) {
|
if (blocked > 0) {
|
||||||
return `Remove ${blocked} unsafe URL${blocked === 1 ? '' : 's'} before continuing.`;
|
return i18n.t($ => $.addDownloads.removeUnsafe, { count: blocked, plural: plural(blocked) });
|
||||||
}
|
}
|
||||||
if (failedMedia > 0) {
|
if (failedMedia > 0) {
|
||||||
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
|
return i18n.t($ => $.addDownloads.mediaMetadataUnavailableSummary, { count: failedMedia, plural: plural(failedMedia) });
|
||||||
}
|
}
|
||||||
if (failed === selectedRows.length) {
|
if (failed === selectedRows.length) {
|
||||||
return 'Metadata is unavailable. Downloads can still be added using fallback details.';
|
return i18n.t($ => $.addDownloads.metadataUnavailableFallback);
|
||||||
}
|
}
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
return `${ready} download${ready === 1 ? '' : 's'} ready; ${failed} will use fallback filename and unknown size.`;
|
return i18n.t($ => $.addDownloads.fallbackReady, { ready, readyPlural: plural(ready), failed });
|
||||||
}
|
}
|
||||||
return `Ready to add ${ready} download${ready === 1 ? '' : 's'}.`;
|
return i18n.t($ => $.addDownloads.readyToAdd, { count: ready, plural: plural(ready) });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user