diff --git a/package.json b/package.json index fcaa3fc..b4d73b1 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,13 @@ "@tauri-apps/plugin-process": "~2.3.1", "@tauri-apps/plugin-updater": "~2.10.1", "framer-motion": "^12.36.0", + "i18next": "^26.4.0", + "i18next-browser-languagedetector": "^8.2.1", "pako": "^2.1.0", "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.12", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6f2b89..fe09479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: framer-motion: specifier: ^12.36.0 version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + i18next: + specifier: ^26.4.0 + version: 26.4.0(typescript@5.8.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 pako: specifier: ^2.1.0 version: 2.1.0 @@ -38,6 +44,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^17.0.12 + version: 17.0.12(i18next@26.4.0(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -180,6 +189,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -962,12 +975,26 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@26.4.0: + resolution: {integrity: sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1299,6 +1326,22 @@ packages: peerDependencies: react: ^19.2.4 + react-i18next@17.0.12: + resolution: {integrity: sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -1440,6 +1483,11 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -1609,6 +1657,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2285,10 +2335,20 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + html-parse-stringify@4.0.1: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.29.7 + + i18next@26.4.0(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -2792,6 +2852,17 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-i18next@17.0.12(i18next@26.4.0(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.4.0(typescript@5.8.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.8.3 + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@types/hast': 3.0.4 @@ -2996,6 +3067,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/src/components/common/AchievementToast.tsx b/src/components/common/AchievementToast.tsx index 8bc1908..b540057 100644 --- a/src/components/common/AchievementToast.tsx +++ b/src/components/common/AchievementToast.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { useAudio } from "../../context/LauncherContext"; @@ -14,10 +15,12 @@ export function AchievementToast({ message, onClose, onClick, - title = "Error!", + title, variant = "error", }: AchievementToastProps) { + const { t } = useTranslation(); const { playSfx } = useAudio(); + const displayTitle = title ?? t("common.error"); const prevMessage = useRef(message); useEffect(() => { const wasNull = !prevMessage.current; @@ -125,7 +128,7 @@ export function AchievementToast({
- {title} + {displayTitle} {message} diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index 78235a7..0b45c67 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -1,6 +1,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { motion } from "framer-motion"; import { memo } from "react"; +import { useTranslation } from "react-i18next"; const appWindow = getCurrentWindow(); @@ -13,6 +14,7 @@ export const AppHeader = memo(function AppHeader({ playPressSound, uiFade, }: AppHeaderProps) { + const { t } = useTranslation(); return ( - Emerald Legacy Launcher + {t("header.title")}
diff --git a/src/components/layout/DownloadOverlay.tsx b/src/components/layout/DownloadOverlay.tsx index 0038738..4a451e7 100644 --- a/src/components/layout/DownloadOverlay.tsx +++ b/src/components/layout/DownloadOverlay.tsx @@ -1,5 +1,6 @@ import { motion } from "framer-motion"; import { memo } from "react"; +import { useTranslation } from "react-i18next"; import { ScreenshotImage } from "../common/ScreenshotImage"; import type { Edition } from "../../types/edition"; @@ -10,6 +11,7 @@ interface DownloadOverlayProps { } export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingIds, editions }: DownloadOverlayProps) { + const { t } = useTranslation(); if (downloadingIds.length === 0) return null; return ( @@ -23,14 +25,14 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, >
- Downloads + {t("download.title")}
{downloadingIds.map((id) => { const pct = downloadProgress[id] ?? 0; const edition = editions.find((e) => e.instanceId === id || e.id === id); - const name = edition?.name || "Game Files"; + const name = edition?.name || t("download.gameFiles"); return (
{edition?.logo ? ( diff --git a/src/components/modals/ChooseInstanceModal.tsx b/src/components/modals/ChooseInstanceModal.tsx index 6c65134..8612989 100644 --- a/src/components/modals/ChooseInstanceModal.tsx +++ b/src/components/modals/ChooseInstanceModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; import { lceOnlineService } from "../../services/LceOnlineService"; @@ -30,6 +31,7 @@ export default function ChooseInstanceModal({ installs: string[]; invite: GameInvite | null; }) { + const { t } = useTranslation(); const [selectedInstance, setSelectedInstance] = useState(""); const [status, setStatus] = useState(""); const [error, setError] = useState(""); @@ -60,17 +62,17 @@ export default function ChooseInstanceModal({ playPressSound(); setIsJoining(true); setError(""); - setStatus("Launching game..."); + setStatus(t("modals.chooseInstance.launching")); try { const sessionId = invite.sessionId || ""; if (sessionId) { - setStatus("Connecting via relay..."); + setStatus(t("modals.chooseInstance.connectingRelay")); const accessToken = lceOnlineService.accessToken ?? ""; const relayPromise = TauriService.startRelayProxy( accessToken, sessionId, ); - setStatus("Launching game..."); + setStatus(t("modals.chooseInstance.launching")); await TauriService.launchGame( selectedInstance, [ @@ -163,10 +165,12 @@ export default function ChooseInstanceModal({ {!isJoining ? ( <>

- Join Game + {t("modals.chooseInstance.title")}

- Joining {hostName}'s game. Choose an instance: + {t("modals.chooseInstance.joinPrompt", { + name: hostName, + })}

{validInstances.length > 0 ? ( @@ -213,7 +217,7 @@ export default function ChooseInstanceModal({
) : (

- No installed instances available. Install a version first. + {t("modals.chooseInstance.noInstances")}

)} @@ -250,7 +254,7 @@ export default function ChooseInstanceModal({ imageRendering: "pixelated", }} > - Cancel + {t("modals.chooseInstance.cancel")} {validInstances.length > 0 && ( )}
@@ -274,7 +278,7 @@ export default function ChooseInstanceModal({ ) : ( <>

- Joining Game + {t("modals.chooseInstance.joiningTitle")}

diff --git a/src/components/modals/CustomTUModal.tsx b/src/components/modals/CustomTUModal.tsx index 271c6f0..d1bab44 100644 --- a/src/components/modals/CustomTUModal.tsx +++ b/src/components/modals/CustomTUModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, memo } from "react"; +import { useTranslation } from "react-i18next"; interface ModalButtonProps { label: string; @@ -50,6 +51,7 @@ export default function CustomTUModal({ editingEdition?: { name: string; desc: string; url: string; path?: string } | null; initialPath?: string; }) { + const { t } = useTranslation(); const [name, setName] = useState(""); const [desc, setDesc] = useState(""); const [url, setUrl] = useState(""); @@ -107,19 +109,19 @@ export default function CustomTUModal({ const handleImport = () => { if (!name) { - setError("Name is required"); + setError(t("modals.customTu.nameRequired")); return; } if (!url && !path) { - setError("URL or Path is required"); + setError(t("modals.customTu.urlOrPathRequired")); return; } if (url && !url.startsWith("http")) { - setError("Invalid URL"); + setError(t("modals.customTu.invalidUrl")); return; } setError(""); - onImport({ name, desc: desc || "Custom imported TU", url, path: path || undefined }); + onImport({ name, desc: desc || t("modals.customTu.defaultDesc"), url, path: path || undefined }); onClose(); setName(""); setDesc(""); @@ -133,13 +135,13 @@ export default function CustomTUModal({ className="relative w-[400px] p-6 flex flex-col items-center mc-options-bg" >

- {editingEdition ? "Edit Custom TU" : "Import Custom TU"} + {editingEdition ? t("modals.customTu.editTitle") : t("modals.customTu.importTitle")}

setName(e.target.value)} onFocus={() => setFocusIndex(0)} - placeholder="e.g. My Awesome Mod" + placeholder={t("modals.customTu.namePlaceholder")} className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']" style={{ imageRendering: "pixelated" }} /> @@ -155,14 +157,14 @@ export default function CustomTUModal({
setDesc(e.target.value)} onFocus={() => setFocusIndex(1)} - placeholder="A brief description..." + placeholder={t("modals.customTu.descPlaceholder")} className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']" style={{ imageRendering: "pixelated" }} /> @@ -170,14 +172,14 @@ export default function CustomTUModal({
setUrl(e.target.value)} onFocus={() => setFocusIndex(2)} - placeholder="optional if path is set" + placeholder={t("modals.customTu.urlPlaceholder")} className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']" style={{ imageRendering: "pixelated" }} /> @@ -186,7 +188,7 @@ export default function CustomTUModal({ {path && (
{ playBackSound("close_click.wav"); onClose(); }} /> { playPressSound("save_click.wav"); handleImport(); diff --git a/src/components/modals/CustomizeModal.tsx b/src/components/modals/CustomizeModal.tsx index 5febcb1..9a19055 100644 --- a/src/components/modals/CustomizeModal.tsx +++ b/src/components/modals/CustomizeModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; @@ -37,6 +38,7 @@ export default function CustomizeModal({ currentPanorama?: string; onSave: (updates: { titleImage?: string; panorama?: string }) => void; }) { + const { t } = useTranslation(); const [titleImage, setTitleImage] = useState(currentTitleImage || ""); const [panorama, setPanorama] = useState(currentPanorama || ""); const [focusIndex, setFocusIndex] = useState(0); @@ -90,7 +92,7 @@ export default function CustomizeModal({ const handlePickFile = async (field: "titleImage" | "panorama") => { try { const path = await TauriService.pickFile( - field === "titleImage" ? "Select Title Image" : "Select Panorama Background", + field === "titleImage" ? t("modals.customize.selectTitleImage") : t("modals.customize.selectPanorama"), ["png", "jpg", "jpeg", "bmp"], ); if (path) { @@ -119,7 +121,7 @@ export default function CustomizeModal({ ) => (
{value && ( )}
@@ -159,7 +161,7 @@ export default function CustomizeModal({
Preview { @@ -190,7 +192,7 @@ export default function CustomizeModal({ }} >

- Customize + {t("modals.customize.title")}

{editionName} @@ -220,7 +222,7 @@ export default function CustomizeModal({ imageRendering: "pixelated", }} > - Cancel + {t("modals.customize.cancel")}

diff --git a/src/components/modals/DownloadDlcModal.tsx b/src/components/modals/DownloadDlcModal.tsx index 4c43847..60f61d9 100644 --- a/src/components/modals/DownloadDlcModal.tsx +++ b/src/components/modals/DownloadDlcModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; interface DlcEntry { @@ -23,6 +24,7 @@ export default function DownloadDlcModal({ instanceId: string; officialDLC: string; }) { + const { t } = useTranslation(); const [dlcList, setDlcList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -185,7 +187,11 @@ export default function DownloadDlcModal({ succeeded.push(selected[i].name); } else { setError( - `Failed to download ${selected[i].name}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`, + t("modals.downloadDlc.failedToDownload", { + name: selected[i].name, + reason: + r.reason instanceof Error ? r.reason.message : String(r.reason), + }), ); } } @@ -211,7 +217,7 @@ export default function DownloadDlcModal({ }} >

- Download DLC + {t("modals.downloadDlc.title")}

{editionName} @@ -219,7 +225,7 @@ export default function DownloadDlcModal({ {loading && (

- Loading available DLCs... + {t("modals.downloadDlc.loading")}
)} @@ -231,7 +237,7 @@ export default function DownloadDlcModal({ {!loading && !error && dlcList.length === 0 && !downloading && (
- No DLC folders found in the repository. + {t("modals.downloadDlc.noneFound")}
)} @@ -239,7 +245,7 @@ export default function DownloadDlcModal({ <>
- {dlcList.length} available + {t("modals.downloadDlc.available", { count: dlcList.length })}
@@ -327,7 +333,10 @@ export default function DownloadDlcModal({
{downloading && (
- Downloaded {downloaded.length}/{selectedCount} + {t("modals.downloadDlc.downloaded", { + downloaded: downloaded.length, + selected: selectedCount, + })}
)}
@@ -343,7 +352,7 @@ export default function DownloadDlcModal({ imageRendering: "pixelated", }} > - Download ({selectedCount}) + {t("modals.downloadDlc.download", { count: selectedCount })} )} {downloading && downloaded.length === selectedCount && ( @@ -359,7 +368,7 @@ export default function DownloadDlcModal({ imageRendering: "pixelated", }} > - Done + {t("modals.downloadDlc.done")} )}
diff --git a/src/components/modals/GameLogModal.tsx b/src/components/modals/GameLogModal.tsx index 04b3f10..83efadc 100644 --- a/src/components/modals/GameLogModal.tsx +++ b/src/components/modals/GameLogModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; const MAX_DISPLAY_LINES = 5000; @@ -62,6 +63,7 @@ export default function GameLogModal({ onClose: () => void; playBackSound: () => void; }) { + const { t } = useTranslation(); const scrollRef = useRef(null); const [feedback, setFeedback] = useState(""); const lines = useMemo(() => (log ?? "").split("\n"), [log]); @@ -92,16 +94,16 @@ export default function GameLogModal({ if (!log) return; try { const path = await TauriService.saveFileDialog( - "Save Game Log", + t("modals.gameLog.saveDialog"), "game-log.txt", ["txt", "log"], ); if (!path) return; await TauriService.writeBinaryFile(path, new TextEncoder().encode(log)); - setFeedback("Saved!"); + setFeedback(t("modals.gameLog.saved")); } catch (e) { console.error(e); - setFeedback("Failed to save."); + setFeedback(t("modals.gameLog.failedToSave")); } }; @@ -109,10 +111,10 @@ export default function GameLogModal({ if (!log) return; try { await navigator.clipboard.writeText(log); - setFeedback("Copied to clipboard!"); + setFeedback(t("modals.gameLog.copied")); } catch (e) { console.error(e); - setFeedback("Failed to copy."); + setFeedback(t("modals.gameLog.failedToCopy")); } }; @@ -136,12 +138,15 @@ export default function GameLogModal({ className="flex flex-col w-[640px] max-w-[92vw] max-h-[85vh] p-5 font-['Mojangles'] mc-options-bg" >

- Game Crash Log + {t("modals.gameLog.title")}

{truncated && (

- Showing last {MAX_DISPLAY_LINES} lines of {lines.length}. + {t("modals.gameLog.showingLast", { + shown: MAX_DISPLAY_LINES, + total: lines.length, + })}

)} @@ -161,10 +166,10 @@ export default function GameLogModal({
- Save As File + {t("modals.gameLog.saveAsFile")} - Copy + {t("modals.gameLog.copy")} { @@ -173,7 +178,7 @@ export default function GameLogModal({ }} className="flex-1" > - Close + {t("modals.gameLog.close")}
{feedback && ( diff --git a/src/components/modals/ImportWorldModal.tsx b/src/components/modals/ImportWorldModal.tsx index b6d2435..4b1007f 100644 --- a/src/components/modals/ImportWorldModal.tsx +++ b/src/components/modals/ImportWorldModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; export default function ImportWorldModal({ @@ -16,6 +17,7 @@ export default function ImportWorldModal({ targetInstanceId: string; targetInstanceName: string; }) { + const { t } = useTranslation(); const [status, setStatus] = useState(""); const [error, setError] = useState(""); const [isImporting, setIsImporting] = useState(false); @@ -32,9 +34,9 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting saveData.ms file..."); + setStatus(t("modals.importWorld.selectingMsFile")); const picked = await TauriService.pickFile("Select saveData.ms", [ "*.ms", "*", @@ -44,13 +46,15 @@ export default function ImportWorldModal({ return; } const worldName = deriveWorldName(picked); - setStatus("Copying LCE save..."); + setStatus(t("modals.importWorld.copyingLceSave")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`; await TauriService.importWorld(picked, `${saveDir}/saveData.ms`); - setStatus(`World imported into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.worldImported", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -66,9 +70,9 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting Xbox 360 save (.bin)..."); + setStatus(t("modals.importWorld.selectingXboxSave")); const picked = await TauriService.pickFile( "Select Xbox 360 Minecraft save", ["*.bin", "*"], @@ -77,11 +81,13 @@ export default function ImportWorldModal({ setIsImporting(false); return; } - setStatus("Converting Xbox 360 save..."); + setStatus(t("modals.importWorld.convertingXbox")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const gameHdd = `${instancePath}/Windows64/GameHDD`; await TauriService.importLceSave(picked, gameHdd); - setStatus(`Xbox 360 save converted into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.xboxConverted", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -97,19 +103,21 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting PS3 save folder..."); + setStatus(t("modals.importWorld.selectingPs3Folder")); const picked = await TauriService.pickFolder(); if (!picked) { setIsImporting(false); return; } - setStatus("Converting PS3 save..."); + setStatus(t("modals.importWorld.convertingPs3")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const gameHdd = `${instancePath}/Windows64/GameHDD`; await TauriService.importLceSave(picked, gameHdd); - setStatus(`PS3 save converted into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.ps3Converted", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -125,20 +133,22 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting Java world folder..."); + setStatus(t("modals.importWorld.selectingJavaFolder")); const picked = await TauriService.pickFolder(); if (!picked) { setIsImporting(false); return; } const worldName = deriveWorldName(picked); - setStatus("Converting Java world to LCE..."); + setStatus(t("modals.importWorld.convertingJava")); const instancePath = await TauriService.getInstancePath(targetInstanceId); const saveDir = `${instancePath}/Windows64/GameHDD/${worldName}`; await TauriService.javaToLce(picked, `${saveDir}/saveData.ms`); - setStatus(`Java world converted into "${targetInstanceName}"!`); + setStatus( + t("modals.importWorld.javaConverted", { name: targetInstanceName }), + ); setTimeout(() => { onClose(); }, 2000); @@ -154,9 +164,9 @@ export default function ImportWorldModal({ playPressSound(); setIsImporting(true); setError(""); - setStatus("Selecting source..."); + setStatus(t("modals.importWorld.selectingSource")); try { - setStatus("Selecting saveData.ms file..."); + setStatus(t("modals.importWorld.selectingMsFile")); const picked = await TauriService.pickFile("Select saveData.ms", [ "*.ms", "*", @@ -165,15 +175,17 @@ export default function ImportWorldModal({ setIsImporting(false); return; } - setStatus("Selecting output folder for Java world..."); + setStatus(t("modals.importWorld.selectingOutputFolder")); const outputFolder = await TauriService.pickFolder(); if (!outputFolder) { setIsImporting(false); return; } - setStatus("Converting LCE save to Java world..."); + setStatus(t("modals.importWorld.convertingLceToJava")); await TauriService.lceToJava(picked, outputFolder); - setStatus(`Java world exported to "${outputFolder}"!`); + setStatus( + t("modals.importWorld.javaExported", { path: outputFolder }), + ); setTimeout(() => { onClose(); }, 2000); diff --git a/src/components/modals/OptionsModal.tsx b/src/components/modals/OptionsModal.tsx index 45adac5..8495dae 100644 --- a/src/components/modals/OptionsModal.tsx +++ b/src/components/modals/OptionsModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useRef, type RefObject } from "react"; +import { useTranslation } from "react-i18next"; import { TauriService } from "../../services/TauriService"; import { parseSchema, @@ -33,6 +34,7 @@ export default function OptionsModal({ args: string[], ) => void; }) { + const { t } = useTranslation(); const [schema, setSchema] = useState(null); const [values, setValues] = useState>({}); const [error, setError] = useState(null); @@ -56,13 +58,13 @@ export default function OptionsModal({ .then((raw) => { if (cancelled) return; if (!raw) { - setError("This instance does not provide a launch options schema."); + setError(t("modals.options.noSchema")); setLoading(false); return; } const parsed = parseSchema(raw); if (!parsed) { - setError("The launch options schema is invalid or unsupported."); + setError(t("modals.options.invalidSchema")); setLoading(false); return; } @@ -433,7 +435,7 @@ export default function OptionsModal({ >

- Options + {t("modals.options.title")}

{instanceName} @@ -443,7 +445,7 @@ export default function OptionsModal({

- Loading options... + {t("modals.options.loading")}

) : error ? ( @@ -452,7 +454,7 @@ export default function OptionsModal({ {error}

- {actionButton(cancelRef, optionOrder.length + 1, "OK", () => { + {actionButton(cancelRef, optionOrder.length + 1, t("modals.options.ok"), () => { playBackSound(); onClose(); })} @@ -477,7 +479,7 @@ export default function OptionsModal({ {sections.general.length > 0 && (

- General + {t("modals.options.general")}

{sections.general.map(renderOption)}
@@ -488,18 +490,18 @@ export default function OptionsModal({ {actionButton( resetRef, optionOrder.length, - "Reset", + t("modals.options.reset"), handleReset, true, )} - {actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => { + {actionButton(cancelRef, optionOrder.length + 1, t("modals.options.cancel"), () => { playBackSound(); onClose(); })} {actionButton( saveRef, optionOrder.length + 2, - "Save", + t("modals.options.save"), handleSave, )}
diff --git a/src/components/modals/PlaytimeModal.tsx b/src/components/modals/PlaytimeModal.tsx index 5dd3e1f..d407283 100644 --- a/src/components/modals/PlaytimeModal.tsx +++ b/src/components/modals/PlaytimeModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService, @@ -75,6 +76,7 @@ export default function PlaytimeModal({ instanceName: string; playBackSound: (s?: string) => void; }) { + const { t } = useTranslation(); const [playtime, setPlaytime] = useState(null); const [dailyData, setDailyData] = useState([]); const [loading, setLoading] = useState(false); @@ -127,7 +129,7 @@ export default function PlaytimeModal({ }} >

- Playtime + {t("modals.playtime.title")}

@@ -136,14 +138,14 @@ export default function PlaytimeModal({ {loading ? (

- Loading... + {t("modals.playtime.loading")}
) : playtime ? ( <>
- Total + {t("modals.playtime.total")} {formatTime(playtime.totalSeconds)} @@ -151,7 +153,7 @@ export default function PlaytimeModal({
- This Week + {t("modals.playtime.thisWeek")} {formatTime(playtime.weekSeconds)} @@ -159,7 +161,7 @@ export default function PlaytimeModal({
- Today + {t("modals.playtime.today")} {formatTime(playtime.daySeconds)} @@ -171,7 +173,7 @@ export default function PlaytimeModal({
- Daily Breakdown + {t("modals.playtime.dailyBreakdown")}
{[3, 7, 14].map((d) => ( @@ -197,7 +199,7 @@ export default function PlaytimeModal({ ) : (
- Failed to load playtime data. + {t("modals.playtime.failedToLoad")}
)} @@ -213,7 +215,7 @@ export default function PlaytimeModal({ imageRendering: "pixelated", }} > - Close + {t("modals.playtime.close")}
diff --git a/src/components/modals/SetUidModal.tsx b/src/components/modals/SetUidModal.tsx index 269c2fb..fb9c5ab 100644 --- a/src/components/modals/SetUidModal.tsx +++ b/src/components/modals/SetUidModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { TauriService } from "../../services/TauriService"; import type { Edition } from "../../types/edition"; @@ -20,6 +21,7 @@ export default function SetUidModal({ installedVersions: string[]; targetInstanceId: string; }) { + const { t } = useTranslation(); const [mode, setMode] = useState<"manual" | "copy">("manual"); const [uid, setUid] = useState("0xFF02F0C87E8AC1F2"); const [selectedInstance, setSelectedInstance] = useState(""); @@ -60,7 +62,7 @@ export default function SetUidModal({ let finalUid = uid; if (mode === "copy") { if (!selectedInstance) { - setError("Select an instance to copy from."); + setError(t("modals.setUid.selectToCopy")); return; } const sourcePath = await TauriService.getInstancePath(selectedInstance); @@ -68,13 +70,13 @@ export default function SetUidModal({ const sourceData = await TauriService.readBinaryFile(`${sourcePath}/uid.dat`); finalUid = new TextDecoder().decode(sourceData); } catch (e) { - setError("Source instance has no uid.dat or it could not be read."); + setError(t("modals.setUid.sourceNoUid")); return; } } if (!finalUid) { - setError("UID cannot be empty."); + setError(t("modals.setUid.uidEmpty")); return; } @@ -140,7 +142,7 @@ export default function SetUidModal({ }} >

- Set UID + {t("modals.setUid.title")}

@@ -156,7 +158,7 @@ export default function SetUidModal({ imageRendering: "pixelated", }} > - Manual + {t("modals.setUid.manual")}
@@ -178,7 +180,7 @@ export default function SetUidModal({ {mode === "manual" ? (
{ const i = validInstances.find((inst: Edition) => inst.instanceId === selectedInstance); - return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : "-- Select an Instance --"; + return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : t("modals.setUid.selectPlaceholder"); })() - : "-- Select an Instance --"} + : t("modals.setUid.selectPlaceholder")} ▼
@@ -234,7 +236,7 @@ export default function SetUidModal({ )} {validInstances.length === 0 && ( -

No other installed instances available.

+

{t("modals.setUid.noInstances")}

)}
)} @@ -262,7 +264,7 @@ export default function SetUidModal({ imageRendering: "pixelated", }} > - Cancel + {t("modals.setUid.cancel")}
diff --git a/src/components/views/ArcEditorView.tsx b/src/components/views/ArcEditorView.tsx index 2b1f7de..4a92027 100644 --- a/src/components/views/ArcEditorView.tsx +++ b/src/components/views/ArcEditorView.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; import { ArcService } from "../../services/ArcService"; @@ -6,6 +7,7 @@ import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc"; import { TauriService } from "../../services/TauriService"; export const ArcEditorView: React.FC = () => { + const { t } = useTranslation(); const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); @@ -48,7 +50,7 @@ export const ArcEditorView: React.FC = () => { const handleFileLoad = async () => { try { - const path = await TauriService.pickFile("Open ARC", ["arc"]); + const path = await TauriService.pickFile(t("arcEditor.openArc"), ["arc"]); if (!path) return; playPressSound(); const bytes = await TauriService.readBinaryFile(path); @@ -70,11 +72,11 @@ export const ArcEditorView: React.FC = () => { setLoc(null); } setSelectedEntryIdx(null); - showNotification(`Loaded ${parsed.name}`); + showNotification(t("arcEditor.loaded", { name: parsed.name })); } catch (err: unknown) { if (err !== "CANCELED") { console.error("Failed to parse ARC", err); - showNotification("Failed to parse ARC", "error"); + showNotification(t("arcEditor.failedToParse"), "error"); } } }; @@ -88,29 +90,29 @@ export const ArcEditorView: React.FC = () => { try { let targetPath = openedPath; if (!targetPath) { - targetPath = await TauriService.saveFileDialog("Save ARC", arc.name || "archive.arc", ["arc"]); + targetPath = await TauriService.saveFileDialog(t("arcEditor.saveArcDialog"), arc.name || "archive.arc", ["arc"]); } if (targetPath) { await TauriService.writeBinaryFile(targetPath, data); setOpenedPath(targetPath); - showNotification("ARC Saved Successfully"); + showNotification(t("arcEditor.arcSaved")); } } catch (err: unknown) { - if (err !== "CANCELED") showNotification("Save failed", "error"); + if (err !== "CANCELED") showNotification(t("arcEditor.saveFailed"), "error"); } }; const handleExtractEntry = async (entry: ArcEntry) => { try { const fileName = entry.filename.split("/").pop() || "asset"; - const path = await TauriService.saveFileDialog("Export Asset", fileName, []); + const path = await TauriService.saveFileDialog(t("arcEditor.exportAssetDialog"), fileName, []); if (!path) return; playPressSound(); await TauriService.writeBinaryFile(path, entry.data); - showNotification(`Extracted: ${entry.filename}`); + showNotification(t("arcEditor.extracted", { name: entry.filename })); } catch (err: unknown) { - if (err !== "CANCELED") showNotification("Extraction failed", "error"); + if (err !== "CANCELED") showNotification(t("arcEditor.extractionFailed"), "error"); } }; @@ -122,7 +124,7 @@ export const ArcEditorView: React.FC = () => { newEntries.splice(idx, 1); setArc({ ...arc, entries: newEntries }); setSelectedEntryIdx(null); - showNotification(`Deleted: ${name}`); + showNotification(t("arcEditor.deleted", { name })); }; const handleSaveLocToArc = () => { @@ -133,10 +135,10 @@ export const ArcEditorView: React.FC = () => { const newEntries = [...arc.entries]; if (locIdx >= 0) { newEntries[locIdx] = { ...newEntries[locIdx], data, size: data.length }; - showNotification("languages.loc updated in archive"); + showNotification(t("arcEditor.locUpdatedInArchive")); } else { newEntries.push({ filename: "languages.loc", ptr: 0, size: data.length, isCompressed: false, data }); - showNotification("languages.loc added to archive"); + showNotification(t("arcEditor.locAddedToArchive")); } setArc({ ...arc, entries: newEntries }); }; @@ -157,7 +159,7 @@ export const ArcEditorView: React.FC = () => { }; setArc({ ...arc, entries: [...arc.entries, newEntry] }); e.target.value = ""; - showNotification("Entry Added"); + showNotification(t("arcEditor.entryAdded")); setIsAddModalOpen(false); }; @@ -172,7 +174,7 @@ export const ArcEditorView: React.FC = () => { newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], data, size: data.length }; setArc({ ...arc, entries: newEntries }); e.target.value = ""; - showNotification("Entry Replaced"); + showNotification(t("arcEditor.entryReplaced")); setIsReplaceModalOpen(false); }; @@ -183,7 +185,7 @@ export const ArcEditorView: React.FC = () => { newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], filename: newPath, isCompressed }; setArc({ ...arc, entries: newEntries }); setIsRenameModalOpen(false); - showNotification("Entry Renamed"); + showNotification(t("arcEditor.entryRenamed")); }; const handleLocStringEdit = (langIdx: number, strIdx: number, isNew: boolean, key: string, value: string) => { @@ -199,7 +201,7 @@ export const ArcEditorView: React.FC = () => { } setLoc(newLoc); setIsLocEditModalOpen(null); - showNotification(isNew ? "String Added" : "String Updated"); + showNotification(isNew ? t("arcEditor.stringAdded") : t("arcEditor.stringUpdated")); }; const handleLocStringDelete = (langIdx: number, strIdx: number) => { @@ -208,7 +210,7 @@ export const ArcEditorView: React.FC = () => { const newLoc = { ...loc }; newLoc.languages[langIdx].strings.splice(strIdx, 1); setLoc(newLoc); - showNotification("String Deleted"); + showNotification(t("arcEditor.stringDeleted")); }; const treeData = useMemo(() => { @@ -254,7 +256,7 @@ export const ArcEditorView: React.FC = () => { {node.filename.split("/").pop()}
{node.isCompressed && ( - ZLIB + {t("arcEditor.zlib")} )}
); @@ -299,15 +301,15 @@ export const ArcEditorView: React.FC = () => { const baseFolder = await TauriService.pickFolder(); if (!baseFolder) return; playPressSound(); - showNotification("Exporting all archive entries..."); + showNotification(t("arcEditor.exportingAll")); for (const entry of arc.entries) { const fileName = entry.filename.replace(/\//g, "_"); await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, entry.data); } - showNotification("All Entries Exported"); + showNotification(t("arcEditor.allEntriesExported")); } catch (err: unknown) { - if (err !== "CANCELED") showNotification("Export failed", "error"); + if (err !== "CANCELED") showNotification(t("arcEditor.exportFailed"), "error"); } }; @@ -323,7 +325,7 @@ export const ArcEditorView: React.FC = () => { >

- ARC Editor + {t("arcEditor.title")}

@@ -359,7 +361,7 @@ export const ArcEditorView: React.FC = () => {
-

Open an ARC file to begin editing

+

{t("arcEditor.openArcFile")}

) : (
@@ -369,14 +371,14 @@ export const ArcEditorView: React.FC = () => { className={`flex items-center gap-3 px-6 py-2 mc-text-shadow ${activeTab === "arc" ? "text-[#FFFF55] opacity-100" : "text-white opacity-40"}`} > - Archive + {t("arcEditor.archive")}
@@ -387,7 +389,7 @@ export const ArcEditorView: React.FC = () => {
setSearchTerm(e.target.value)} className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" @@ -397,7 +399,7 @@ export const ArcEditorView: React.FC = () => { className="px-6 py-2 text-white mc-text-shadow text-sm" style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }} > - Add Entry + {t("arcEditor.addEntry")}
@@ -408,17 +410,17 @@ export const ArcEditorView: React.FC = () => { {selectedEntry ? (
-

Entry Details

+

{t("arcEditor.entryDetails")}

{selectedEntry.filename}

- Size + {t("arcEditor.size")} {(selectedEntry.size / 1024).toFixed(1)} KB
- Format - {selectedEntry.isCompressed ? "Compressed" : "Raw"} + {t("arcEditor.format")} + {selectedEntry.isCompressed ? t("arcEditor.compressed") : t("arcEditor.raw")}
@@ -428,21 +430,21 @@ export const ArcEditorView: React.FC = () => { className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]" style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }} > - Export File + {t("arcEditor.exportFile")}
@@ -471,7 +473,7 @@ export const ArcEditorView: React.FC = () => { ) : (
-

Select an entry to view details

+

{t("arcEditor.selectEntry")}

)}
@@ -480,16 +482,16 @@ export const ArcEditorView: React.FC = () => {
{!loc ? (
-

No languages.loc found in archive

+

{t("arcEditor.noLocFound")}

) : ( @@ -501,12 +503,12 @@ export const ArcEditorView: React.FC = () => { className="bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" > {loc.languages.map((lang, idx) => ( - + ))} setSearchTerm(e.target.value)} className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" @@ -516,23 +518,23 @@ export const ArcEditorView: React.FC = () => { className="px-6 py-2 text-white mc-text-shadow text-sm" style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }} > - Add String + {t("arcEditor.addString")}
- - - + + + @@ -548,7 +550,7 @@ export const ArcEditorView: React.FC = () => { onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: str.originalIdx, isNew: false })} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 uppercase" > - Edit + {t("arcEditor.edit")} @@ -594,17 +596,17 @@ export const ArcEditorView: React.FC = () => { {isAddModalOpen && (
-

Add File to Archive

+

{t("arcEditor.addFileToArchive")}

- +
@@ -614,7 +616,7 @@ export const ArcEditorView: React.FC = () => { {isReplaceModalOpen && selectedEntryIdx !== null && (
-

Replace File Data

+

{t("arcEditor.replaceFileData")}

{arc?.entries[selectedEntryIdx].filename}

- +
@@ -653,15 +655,16 @@ export const ArcEditorView: React.FC = () => { } function RenameModal({ initialName, initialCompressed, onClose, onConfirm }: { initialName: string, initialCompressed: boolean, onClose: () => void, onConfirm: (name: string, comp: boolean) => void }) { + const { t } = useTranslation(); const [name, setName] = useState(initialName); const [comp, setComp] = useState(initialCompressed); return (
-

Rename Entry

+

{t("arcEditor.renameEntry")}

- +
- +
@@ -690,16 +693,17 @@ function RenameModal({ initialName, initialCompressed, onClose, onConfirm }: { i } function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: number, strIdx: number, isNew: boolean }, lang: LocLanguage, onClose: () => void, onConfirm: (langIdx: number, strIdx: number, isNew: boolean, key: string, val: string) => void }) { + const { t } = useTranslation(); const [key, setKey] = useState(!data.isNew ? (lang.strings[data.strIdx].key || "") : ""); const [val, setVal] = useState(!data.isNew ? lang.strings[data.strIdx].value : ""); return (
-

{data.isNew ? "Add" : "Edit"} String

+

{data.isNew ? t("arcEditor.add") : t("arcEditor.edit")} {t("arcEditor.stringTitle")}

{!lang.isStatic ? (
- +
) : ( -
Static entry - Index: {data.isNew ? lang.strings.length : data.strIdx}
+
{t("arcEditor.staticEntry", { index: data.isNew ? lang.strings.length : data.strIdx })}
)}
- +
Key / IndexValueActions{t("arcEditor.keyOrIndex")}{t("arcEditor.value")}{t("arcEditor.actions")}