diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index 4b723fdf..bb314665 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { fetchApi } from "../lib/api-config" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatStorage } from "../lib/utils" -import { useT } from "../lib/i18n/provider" +import { getCountFormKey, useI18n, useT } from "../lib/i18n/provider" import { Area, AreaChart, ResponsiveContainer } from "recharts" interface TempDataPoint { @@ -173,6 +173,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => { export function SystemOverview() { const t = useT() + const { language } = useI18n() const [systemData, setSystemData] = useState(null) const [vmData, setVmData] = useState([]) const [storageData, setStorageData] = useState(null) @@ -511,7 +512,7 @@ export function SystemOverview() { / {vmStats.vms + vmStats.lxc} - {t("overview.runningCount", { count: vmStats.running })} + {t(getCountFormKey(language, "overview.runningCount", vmStats.running), { count: vmStats.running })}
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 208fd818..576bb586 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -27,7 +27,7 @@ import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" import DOMPurify from "dompurify" import { marked } from "marked" -import { useT } from "@/lib/i18n/provider" +import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" // Sent by /api/vms only for LXC rows, only when the user has enabled // `lxc_updates_available` notifications. The Monitor populates this @@ -753,6 +753,7 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) { export function VirtualMachines() { const t = useT() + const { language } = useI18n() const { data: vmData, error, @@ -1929,7 +1930,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { applySchedulePayload(s) } } catch (e: any) { - setScheduleError(e?.message || "Could not load schedule") + setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed")) } finally { setScheduleLoaded(vmid) } @@ -1953,7 +1954,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setBulkConfigured(false) setBulkTargets(["os"]) setBulkPersistedTargets(["os"]) - setBulkError(e?.message || "Could not load bulk update") + setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed")) } finally { setBulkLoaded(vmid) } @@ -2073,7 +2074,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { }) if (cronToSave.trim()) setScheduleConfigured(true) } catch (e: any) { - setScheduleError(e?.message || "Save failed") + setScheduleError(e?.message || t("vmLxc.scheduled.saveFailed")) } finally { setScheduleSaving(false) } @@ -2132,13 +2133,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setScheduleLastRunRebootPackages([]) setScheduleReleaseDelayDays(0) } catch (e: any) { - setScheduleError(e?.message || "Delete failed") + setScheduleError(e?.message || t("vmLxc.scheduled.deleteFailed")) } finally { setScheduleSaving(false) } } - // Turn a 5-field cron into a plain-English label — mirrors the + // Turn a 5-field cron into a localized label — mirrors the // backend's _humanise_cron so view mode matches the picker's // preset labels. const humanCron = (expr: string): string => { @@ -2151,15 +2152,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { if (isNaN(hn) || isNaN(mn)) return `${h}:${m}` return `${String(hn).padStart(2, "0")}:${String(mn).padStart(2, "0")}` } - if (d === "*" && mo === "*" && w === "*" && /^\d+$/.test(m) && /^\d+$/.test(h)) return `Daily at ${hhmm()}` + if (d === "*" && mo === "*" && w === "*" && /^\d+$/.test(m) && /^\d+$/.test(h)) return t("vmLxc.scheduled.humanDaily", { time: hhmm() }) if (d === "*" && mo === "*" && /^\d+$/.test(w) && /^\d+$/.test(m) && /^\d+$/.test(h)) { - const wdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] const wn = parseInt(w, 10) - const wname = (wn >= 0 && wn <= 6) ? wdays[wn] : w - return `Weekly (${wname} ${hhmm()})` + const wname = wn >= 0 && wn <= 6 ? t(`vmLxc.scheduled.weekdays.${wn}`) : w + return t("vmLxc.scheduled.humanWeekly", { day: wname, time: hhmm() }) } - if (mo === "*" && w === "*" && /^\d+$/.test(d) && /^\d+$/.test(m) && /^\d+$/.test(h)) return `Monthly (day ${parseInt(d, 10)} at ${hhmm()})` - if (h === "*" && d === "*" && mo === "*" && w === "*" && m === "0") return "Hourly" + if (mo === "*" && w === "*" && /^\d+$/.test(d) && /^\d+$/.test(m) && /^\d+$/.test(h)) return t("vmLxc.scheduled.humanMonthly", { day: parseInt(d, 10), time: hhmm() }) + if (h === "*" && d === "*" && mo === "*" && w === "*" && m === "0") return t("vmLxc.scheduled.humanHourly") return expr } @@ -2296,7 +2296,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // record and would drop any field we omitted. const full: any = await fetchApi(`/api/vms/${vmid}/apps`) const current = (full?.apps || []).find((a: any) => a.id === app.id) - if (!current) throw new Error("app not found in sidecar") + if (!current) throw new Error(t("vmLxc.errors.appNotFound")) const { id: _id, state: _state, created_at: _created, ...rest } = current const payload = { ...rest, ...patch } const updated: any = await fetchApi(`/api/vms/${vmid}/apps/${app.id}`, { @@ -2321,19 +2321,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { }) closeCustomCmdEditor() } catch (e) { - alert(`Could not save custom command: ${(e as any)?.message || e}`) + alert(t("vmLxc.errors.saveCustomCommandFailed", { message: (e as any)?.message || String(e) })) } finally { setCustomCmdSaving(false) } } const removeCustomCommand = async (vmid: number, app: LxcAppWatch) => { - if (!confirm(`Remove the custom update command for "${app.name}"?`)) return + if (!confirm(t("vmLxc.errors.removeCustomCommandConfirm", { name: app.name }))) return setCustomCmdSaving(true) try { await patchAppWatch(vmid, app, { update_command: "" }) closeCustomCmdEditor() } catch (e) { - alert(`Could not remove custom command: ${(e as any)?.message || e}`) + alert(t("vmLxc.errors.removeCustomCommandFailed", { message: (e as any)?.message || String(e) })) } finally { setCustomCmdSaving(false) } @@ -2342,7 +2342,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { try { await patchAppWatch(vmid, app, { hide_no_updater_notice: true }) } catch (e) { - alert(`Could not hide notice: ${(e as any)?.message || e}`) + alert(t("vmLxc.errors.hideNoticeFailed", { message: (e as any)?.message || String(e) })) } } @@ -2363,10 +2363,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? ["docker-images"] : ["apps"] const fallbackLabels = target === "os" - ? ["OS"] + ? [t("vmLxc.bulkUpdate.osTarget")] : target === "both" - ? ["OS", "Applications"] - : [opts?.appName || (opts?.dockerEngine ? "Docker Engine" : "Application")] + ? [t("vmLxc.bulkUpdate.osTarget"), t("vmLxc.scheduled.targetApp")] + : [opts?.appName || (opts?.dockerEngine ? "Docker Engine" : t("vmLxc.scheduled.targetApp"))] const runId = typeof window !== "undefined" && typeof window.crypto?.randomUUID === "function" ? window.crypto.randomUUID() : `manual-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` @@ -2817,7 +2817,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { / {total}
- {t("overview.runningCount", { count: running })} + {t(getCountFormKey(language, "overview.runningCount", running), { count: running })}
@@ -2987,12 +2987,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{(["all", "running", "stopped"] as const).map((key) => { const active = statusFilter === key - const label = key === "all" ? "All" : key === "running" ? "Running" : "Stopped" + const label = t(`vmLxc.statusFilter.${key}`) // Icon color: white when the tab is active (over the blue fill); // green / red on inactive tabs so the state mapping stays legible // before selection. The black-text variant was tested and dropped @@ -3031,7 +3031,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.empty")}
) : filteredVMs.length === 0 ? (
- No {statusFilter === "running" ? "running" : "stopped"} virtual machines + {t("vmLxc.statusFilter.empty", { status: t(`vmLxc.statusFilter.${statusFilter}`) })}
) : (
diff --git a/AppImage/lib/i18n/provider.tsx b/AppImage/lib/i18n/provider.tsx index f0569608..04d5b638 100644 --- a/AppImage/lib/i18n/provider.tsx +++ b/AppImage/lib/i18n/provider.tsx @@ -132,4 +132,18 @@ export function useT() { return useI18n().t } +export function getCountFormKey(language: LanguageCode, key: string, count: number): string { + if (language !== "sk") return key + + const absoluteCount = Math.abs(count) + const lastDigit = absoluteCount % 10 + const lastTwoDigits = absoluteCount % 100 + + if (lastDigit === 1 && lastTwoDigits !== 11) return `${key}One` + if (lastDigit >= 2 && lastDigit <= 4 && !(lastTwoDigits >= 12 && lastTwoDigits <= 14)) { + return `${key}Few` + } + return `${key}Many` +} + export { SUPPORTED_LANGUAGES } diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 6a923308..db9a48de 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -97,6 +97,9 @@ "free": "Free", "activeVmLxc": "Active VM & LXC", "runningCount": "{count} running", + "runningCountOne": "{count} running", + "runningCountFew": "{count} running", + "runningCountMany": "{count} running", "vmsCount": "{count} VMs", "stoppedCount": "{count} stopped", "temperature": "Temperature", @@ -913,6 +916,13 @@ "idle": "Idle", "listTitle": "Virtual Machines & Containers", "empty": "No virtual machines found", + "statusFilter": { + "ariaLabel": "Filter virtual machines and containers by status", + "all": "All", + "running": "Running", + "stopped": "Stopped", + "empty": "No virtual machines or containers with status \"{status}\"" + }, "uptime": "Uptime: {uptime}", "cpuUsage": "CPU Usage", "memory": "Memory", @@ -1092,7 +1102,12 @@ "unknown": "Unknown error", "backupStartFailed": "Failed to start backup: {message}", "controlFailed": "Failed to {action} VM {vmid}: {message}", - "saveNotesFailed": "Error saving notes. Please try again." + "saveNotesFailed": "Error saving notes. Please try again.", + "appNotFound": "This application is no longer available. Refresh the page and try again.", + "saveCustomCommandFailed": "Could not save the custom update command: {message}", + "removeCustomCommandConfirm": "Remove the custom update command for \"{name}\"?", + "removeCustomCommandFailed": "Could not remove the custom update command: {message}", + "hideNoticeFailed": "Could not hide this notice: {message}" }, "backupModal": { "title": "Backup {type} {vmid} ({name})", @@ -1197,7 +1212,15 @@ "noTargets": "No targets selected", "selectAtLeastOne": "Select at least one update target.", "deleteButton": "Delete schedule", - "deleteConfirm": "Remove the scheduled updates for this container? Apply defaults (backup + restart) are kept." + "deleteConfirm": "Remove the scheduled updates for this container? Apply defaults (backup + restart) are kept.", + "loadFailed": "Could not load the scheduled updates.", + "saveFailed": "Could not save the scheduled updates.", + "deleteFailed": "Could not remove the scheduled updates.", + "humanDaily": "Daily at {time}", + "humanWeekly": "Weekly ({day} {time})", + "humanMonthly": "Monthly (day {day} at {time})", + "humanHourly": "Hourly", + "weekdays": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] }, "cronChip": { "detected": "host cron detected", @@ -1343,7 +1366,8 @@ "deleteConfirm": "Remove this bulk update configuration?", "saveFailed": "Could not save the bulk update configuration.", "deleteFailed": "Could not remove the bulk update configuration.", - "planFailed": "Could not prepare the bulk update. Edit the configuration and review unavailable methods." + "planFailed": "Could not prepare the bulk update. Edit the configuration and review unavailable methods.", + "loadFailed": "Could not load the bulk update configuration." }, "appEditor": { "closePanel": "Close panel", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index a9ea2c3c..24febecf 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -96,7 +96,10 @@ "total": "Spolu", "free": "Voľné", "activeVmLxc": "Aktívne VM a LXC", - "runningCount": "{count} beží", + "runningCount": "{count} spustené", + "runningCountOne": "{count} spustený", + "runningCountFew": "{count} spustené", + "runningCountMany": "{count} spustených", "vmsCount": "{count} VM", "stoppedCount": "{count} vypnuté", "temperature": "Teplota", @@ -901,7 +904,7 @@ "totalCpuAllocated": "Pridelené CPU spolu", "totalMemory": "Pamäť spolu", "totalDisk": "Disk spolu", - "running": "beží", + "running": "spustené", "stopped": "vypnuté", "vms": "VM", "used": "Použité", @@ -913,6 +916,13 @@ "idle": "Voľné", "listTitle": "Virtuálne stroje a kontajnery", "empty": "Nenašli sa žiadne virtuálne stroje", + "statusFilter": { + "ariaLabel": "Filtrovať virtuálne stroje a kontajnery podľa stavu", + "all": "Všetky", + "running": "Spustené", + "stopped": "Vypnuté", + "empty": "Žiadne virtuálne stroje ani kontajnery so stavom „{status}“" + }, "uptime": "Beží: {uptime}", "cpuUsage": "Využitie CPU", "memory": "Pamäť", @@ -1092,7 +1102,12 @@ "unknown": "Neznáma chyba", "backupStartFailed": "Zálohu sa nepodarilo spustiť: {message}", "controlFailed": "Akcia {action} pre VM {vmid} zlyhala: {message}", - "saveNotesFailed": "Poznámky sa nepodarilo uložiť. Skúste to znova." + "saveNotesFailed": "Poznámky sa nepodarilo uložiť. Skúste to znova.", + "appNotFound": "Táto aplikácia už nie je dostupná. Obnovte stránku a skúste to znova.", + "saveCustomCommandFailed": "Vlastný príkaz na aktualizáciu sa nepodarilo uložiť: {message}", + "removeCustomCommandConfirm": "Odstrániť vlastný príkaz na aktualizáciu pre „{name}“?", + "removeCustomCommandFailed": "Vlastný príkaz na aktualizáciu sa nepodarilo odstrániť: {message}", + "hideNoticeFailed": "Toto upozornenie sa nepodarilo skryť: {message}" }, "backupModal": { "title": "Záloha {type} {vmid} ({name})", @@ -1197,7 +1212,15 @@ "noTargets": "Nie sú vybrané žiadne ciele", "selectAtLeastOne": "Vyberte aspoň jeden cieľ aktualizácie.", "deleteButton": "Odstrániť plán", - "deleteConfirm": "Odstrániť plánované aktualizácie tohto kontajnera? Predvolené nastavenia (záloha a reštart) zostanú zachované." + "deleteConfirm": "Odstrániť plánované aktualizácie tohto kontajnera? Predvolené nastavenia (záloha a reštart) zostanú zachované.", + "loadFailed": "Plánované aktualizácie sa nepodarilo načítať.", + "saveFailed": "Plánované aktualizácie sa nepodarilo uložiť.", + "deleteFailed": "Plánované aktualizácie sa nepodarilo odstrániť.", + "humanDaily": "Denne o {time}", + "humanWeekly": "Týždenne ({day} o {time})", + "humanMonthly": "Mesačne ({day}. deň o {time})", + "humanHourly": "Každú hodinu", + "weekdays": ["nedeľa", "pondelok", "utorok", "streda", "štvrtok", "piatok", "sobota"] }, "cronChip": { "detected": "zistený cron na serveri", @@ -1343,7 +1366,8 @@ "deleteConfirm": "Odstrániť túto konfiguráciu hromadnej aktualizácie?", "saveFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo uložiť.", "deleteFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo odstrániť.", - "planFailed": "Hromadnú aktualizáciu sa nepodarilo pripraviť. Skontrolujte nedostupné metódy." + "planFailed": "Hromadnú aktualizáciu sa nepodarilo pripraviť. Skontrolujte nedostupné metódy.", + "loadFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo načítať." }, "appEditor": { "closePanel": "Zavrieť panel", @@ -1441,16 +1465,16 @@ "upToDateBadge": "Aktuálne", "updateAvailableBadge": "Dostupná aktualizácia", "portDescriptionPlaceholder": "Popis (napr. Web UI, go2rtc, admin)", - "portPortPlaceholder": "prístav", + "portPortPlaceholder": "port", "portHttp": "http", "portHttps": "https", "portLogoLabel": "URL loga pre tento odkaz (voliteľné)", "portLogoPlaceholder": "napr. https://example.com/logo.webp", - "portCategoryPlaceholder": "Category for the Apps dashboard (optional)", - "portCategoryNone": "No category", - "portCategoryAddNew": "+ Add new category…", - "portCategoryCustomPlaceholder": "Type a category and press Enter (Esc to cancel)", - "portCustomUrlPlaceholder": "Custom URL (e.g. https://vault.example.com) — overrides IP:port", + "portCategoryPlaceholder": "Kategória pre prehľad aplikácií (voliteľné)", + "portCategoryNone": "Bez kategórie", + "portCategoryAddNew": "+ Pridať novú kategóriu…", + "portCategoryCustomPlaceholder": "Napíšte názov kategórie a stlačte Enter (Esc zruší)", + "portCustomUrlPlaceholder": "Vlastná URL (napr. https://vault.example.com) — nahradí IP:port", "removePortTooltip": "Odstrániť port", "detectMethodDpkg": "dpkg ·", "detectMethodApk": "apk ·",