From 4f54b0506ae219cef23351218d44902895c9d44c Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 2 Aug 2026 15:20:39 +0700 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20localize=20the=20remaining=2034?= =?UTF-8?q?=20tool=20UIs=20to=20Bahasa=20(wave=203=20=E2=80=94=20completes?= =?UTF-8?q?=20all=2073)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final wave of tool-UI localization: PDF (11), Maps (4), Files (4), Network (3), Media (7), Playground (2), Draw (3). Each island renders Bahasa on /id/ via the lang prop + local TR:{en,id} (LegacyLetter pattern). Every GoodWebTools tool UI is now bilingual. Logic untouched; format/brand/acronym/code terms kept; no 'alat'. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- src/islands/draw/DbDiagram.tsx | 115 ++++++++-- src/islands/draw/SignaturePad.tsx | 52 ++++- src/islands/draw/Whiteboard.tsx | 76 +++++-- src/islands/files/ArchiveExtract.tsx | 62 +++++- src/islands/files/FileCrypt.tsx | 114 ++++++++-- src/islands/files/FileSplit.tsx | 92 ++++++-- src/islands/files/FileTransfer.tsx | 227 ++++++++++++++----- src/islands/files/ZipTool.tsx | 88 ++++++-- src/islands/maps/CoordConvert.tsx | 45 +++- src/islands/maps/GeoViewer.tsx | 62 +++++- src/islands/maps/MapExplorer.tsx | 53 ++++- src/islands/maps/StaticMap.tsx | 37 +++- src/islands/media/AudioConvert.tsx | 90 ++++++-- src/islands/media/ScreenRecorder.tsx | 81 +++++-- src/islands/media/Screenshot.tsx | 142 +++++++++--- src/islands/media/VideoConvert.tsx | 126 +++++++++-- src/islands/media/VideoToAudio.tsx | 85 ++++++-- src/islands/media/VideoToGif.tsx | 79 +++++-- src/islands/media/VoiceToText.tsx | 130 ++++++++--- src/islands/network/OpticalTransfer.tsx | 110 ++++++++-- src/islands/network/VideoCall.tsx | 229 +++++++++++++++----- src/islands/pdf/ImagesToPdf.tsx | 55 ++++- src/islands/pdf/PdfCompress.tsx | 61 +++++- src/islands/pdf/PdfDelete.tsx | 70 +++++- src/islands/pdf/PdfMerge.tsx | 55 ++++- src/islands/pdf/PdfProtect.tsx | 85 ++++++-- src/islands/pdf/PdfRepair.tsx | 76 +++++-- src/islands/pdf/PdfRotate.tsx | 40 +++- src/islands/pdf/PdfSplit.tsx | 80 +++++-- src/islands/pdf/PdfToImage.tsx | 97 +++++++-- src/islands/pdf/PdfUnlock.tsx | 70 +++++- src/islands/pdf/PdfWatermark.tsx | 114 +++++++--- src/islands/playground/CodeScratchpad.tsx | 78 +++++-- src/islands/playground/SqlitePlayground.tsx | 154 ++++++++++--- 34 files changed, 2534 insertions(+), 596 deletions(-) diff --git a/src/islands/draw/DbDiagram.tsx b/src/islands/draw/DbDiagram.tsx index 862a50e..5e0e778 100644 --- a/src/islands/draw/DbDiagram.tsx +++ b/src/islands/draw/DbDiagram.tsx @@ -12,8 +12,80 @@ import { downloadService } from '@/services/download'; import { exportSql, DIALECTS, type Dialect } from '@/tools/draw/sql-export.lib'; import { exportDiagramImage, type ImageFormat } from '@/tools/draw/diagram-image.lib'; import { addRef, removeRef, type RefColumn } from '@/tools/draw/refs.lib'; +import type { Lang } from '@/i18n/config'; import '@xyflow/react/dist/style.css'; +const TR: Record = { + en: { + loading: 'Loading diagram…', + descPre: 'Write your schema in ', + descPost: ' on the left; the ER diagram updates live. Drag tables to arrange them — your layout and schema are saved in your browser.', + saveProject: 'Save project', + exportDbml: 'Export .dbml', + open: 'Open…', + saving: 'Saving…', + saved: 'Saved', + sqlDialect: 'SQL dialect', + exportSql: 'Export SQL', + image: 'Image', + scale: 'Scale', + exportImage: 'Export image', + expand: 'Expand', + showNavbar: 'Show navbar', + hideNavbarSpace: 'Hide navbar for more space', + hideNavbar: 'Hide navbar', + exit: 'Exit', + copySql: 'Copy SQL', + downloadSql: 'Download .sql', + exportFailed: 'Export failed', + }, + id: { + loading: 'Memuat diagram…', + descPre: 'Tulis skema Anda dalam ', + descPost: ' di sebelah kiri; diagram ER diperbarui secara langsung. Seret tabel untuk menatanya — tata letak dan skema Anda disimpan di browser Anda.', + saveProject: 'Simpan proyek', + exportDbml: 'Ekspor .dbml', + open: 'Buka…', + saving: 'Menyimpan…', + saved: 'Tersimpan', + sqlDialect: 'Dialek SQL', + exportSql: 'Ekspor SQL', + image: 'Gambar', + scale: 'Skala', + exportImage: 'Ekspor gambar', + expand: 'Perbesar', + showNavbar: 'Tampilkan navbar', + hideNavbarSpace: 'Sembunyikan navbar untuk ruang lebih', + hideNavbar: 'Sembunyikan navbar', + exit: 'Keluar', + copySql: 'Salin SQL', + downloadSql: 'Unduh .sql', + exportFailed: 'Ekspor gagal', + }, +}; + const SEED = `Table users { id int [pk, increment] email varchar [not null, unique] @@ -33,7 +105,8 @@ Ref: posts.user_id > users.id const nodeTypes = { table: TableNode }; const edgeTypes = { relation: RelationEdge }; -export default function DbDiagram() { +export default function DbDiagram({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; // react-flow is browser-only and heavy — load it after mount (like Whiteboard). const [RF, setRF] = useState<{ ReactFlow: ComponentType>; @@ -201,7 +274,7 @@ export default function DbDiagram() { try { setSql(exportSql(dbml, dialect)); } catch (e) { - setSqlErr(e instanceof Error ? e.message : 'Export failed'); + setSqlErr(e instanceof Error ? e.message : t.exportFailed); } }; @@ -263,7 +336,7 @@ export default function DbDiagram() { const onNodeMouseLeave = useCallback(() => setHoveredId(null), []); const diagram = useMemo(() => { - if (!RF) return
Loading diagram…
; + if (!RF) return
{t.loading}
; const { ReactFlow, Background, Controls, MiniMap } = RF; // Derive hover emphasis: connected edges + neighbour tables pop; the rest dim. @@ -315,18 +388,18 @@ export default function DbDiagram() { ); - }, [RF, nodes, edges, onNodesChange, onEdgesChange, onConnect, onEdgesDelete, hoveredId, onNodeMouseEnter, onNodeMouseLeave]); + }, [RF, nodes, edges, onNodesChange, onEdgesChange, onConnect, onEdgesDelete, hoveredId, onNodeMouseEnter, onNodeMouseLeave, t]); return (

- Write your schema in DBML on the left; the ER diagram updates live. Drag tables to arrange them — your layout and schema are saved in your browser. + {t.descPre}DBML{t.descPost}

- - - + + + {saveState === 'saving' ? ( - Saving… + {t.saving} ) : ( - Saved + {t.saved} )}
- SQL dialect + {t.sqlDialect}
- +
- Image + {t.image}
- Scale + {t.scale}
- + {!expanded && ( - + )}
@@ -397,15 +470,15 @@ export default function DbDiagram() { <>
- +
)} @@ -414,8 +487,8 @@ export default function DbDiagram() { {sql && (
- - + +
{sql}
diff --git a/src/islands/draw/SignaturePad.tsx b/src/islands/draw/SignaturePad.tsx index 88178ac..027a5f9 100644 --- a/src/islands/draw/SignaturePad.tsx +++ b/src/islands/draw/SignaturePad.tsx @@ -5,8 +5,42 @@ import { Button } from '@/components/ui/Button'; import { CopyImageButton } from '@/components/ui/CopyImageButton'; import { EditInAnnotatorButton } from '@/components/ui/EditInAnnotatorButton'; import { downloadService } from '@/services/download'; +import type { Lang } from '@/i18n/config'; -export default function SignaturePad() { +const TR: Record = { + en: { + penColor: 'Pen color', + pen: 'Pen', + width: 'Width', + transparentBg: 'Transparent background', + hint: 'Sign with a mouse, trackpad, or finger. Everything stays in your browser.', + downloadPng: 'Download PNG', + downloadSvg: 'Download SVG', + clear: 'Clear', + }, + id: { + penColor: 'Warna pena', + pen: 'Pena', + width: 'Lebar', + transparentBg: 'Latar transparan', + hint: 'Tanda tangani dengan mouse, trackpad, atau jari. Semuanya tetap di browser Anda.', + downloadPng: 'Unduh PNG', + downloadSvg: 'Unduh SVG', + clear: 'Bersihkan', + }, +}; + +export default function SignaturePad({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const canvasRef = useRef(null); const padRef = useRef(null); const [color, setColor] = useState('#0a0a0a'); @@ -78,39 +112,39 @@ export default function SignaturePad() { return (
-
-

Sign with a mouse, trackpad, or finger. Everything stays in your browser.

+

{t.hint}

diff --git a/src/islands/draw/Whiteboard.tsx b/src/islands/draw/Whiteboard.tsx index 146207a..4489559 100644 --- a/src/islands/draw/Whiteboard.tsx +++ b/src/islands/draw/Whiteboard.tsx @@ -2,17 +2,66 @@ import { useEffect, useRef, useState, type ComponentType } from 'react'; import { Maximize2, Minimize2, Check, ChevronUp, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { loadScene, saveScene, type WhiteboardScene } from '@/tools/draw/whiteboard.store'; +import type { Lang } from '@/i18n/config'; import '@excalidraw/excalidraw/index.css'; const SAVE_INTERVAL = 30; // seconds between autosaves +const TR: Record string; + saved: string; + loadError: string; + loading: string; + descPre: string; + descMid: string; + descPost: string; + expand: string; + showNavbar: string; + hideNavbarSpace: string; + hideNavbar: string; + exit: string; +}> = { + en: { + saveNow: 'Save now', + unsavedSaveNow: (n) => `Unsaved · save now (${n}s)`, + saved: 'Saved', + loadError: "Couldn't load the whiteboard. Please refresh the page.", + loading: 'Loading whiteboard…', + descPre: 'A full whiteboard for sketches, diagrams, flowcharts, and mind maps. Everything stays in your browser — export as PNG/SVG or a reusable ', + descMid: ' file from the menu. Powered by ', + descPost: ' — you can also use it at excalidraw.com.', + expand: 'Expand', + showNavbar: 'Show navbar', + hideNavbarSpace: 'Hide navbar for more space', + hideNavbar: 'Hide navbar', + exit: 'Exit', + }, + id: { + saveNow: 'Simpan sekarang', + unsavedSaveNow: (n) => `Belum tersimpan · simpan sekarang (${n}s)`, + saved: 'Tersimpan', + loadError: 'Tidak dapat memuat whiteboard. Silakan muat ulang halaman.', + loading: 'Memuat whiteboard…', + descPre: 'Whiteboard lengkap untuk sketsa, diagram, flowchart, dan mind map. Semuanya tetap di browser Anda — ekspor sebagai PNG/SVG atau file ', + descMid: ' yang dapat digunakan ulang dari menu. Didukung oleh ', + descPost: ' — Anda juga dapat menggunakannya di excalidraw.com.', + expand: 'Perbesar', + showNavbar: 'Tampilkan navbar', + hideNavbarSpace: 'Sembunyikan navbar untuk ruang lebih', + hideNavbar: 'Sembunyikan navbar', + exit: 'Keluar', + }, +}; + // Serve Excalidraw's fonts from our own origin (copied to public/excalidraw) // instead of its default esm.sh CDN — keeps the zero-external-request promise. if (typeof window !== 'undefined') { (window as unknown as { EXCALIDRAW_ASSET_PATH: string }).EXCALIDRAW_ASSET_PATH = '/excalidraw/'; } -export default function Whiteboard() { +export default function Whiteboard({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; // Excalidraw is a large, browser-only React component — load it after mount // so it never runs during SSR. const [Excalidraw, setExcalidraw] = useState> | null>(null); @@ -154,27 +203,27 @@ export default function Whiteboard() { const statusIndicator = countdown > 0 ? ( ) : ( - Saved + {t.saved} ); const canvas = failed ? (
- Couldn't load the whiteboard. Please refresh the page. + {t.loadError}
) : Excalidraw ? ( ) : (
- Loading whiteboard… + {t.loading}
); @@ -185,8 +234,7 @@ export default function Whiteboard() {

- A full whiteboard for sketches, diagrams, flowcharts, and mind maps. Everything stays in your - browser — export as PNG/SVG or a reusable .excalidraw file from the menu. Powered by{' '} + {t.descPre}.excalidraw{t.descMid} Excalidraw - {' '} - — you can also use it at excalidraw.com. + + {t.descPost}

{statusIndicator} {!expanded && ( )}
@@ -227,8 +275,8 @@ export default function Whiteboard() { {expanded && (
)} diff --git a/src/islands/files/ArchiveExtract.tsx b/src/islands/files/ArchiveExtract.tsx index 2777715..1080d38 100644 --- a/src/islands/files/ArchiveExtract.tsx +++ b/src/islands/files/ArchiveExtract.tsx @@ -4,6 +4,45 @@ import { Dropzone } from '@/components/ui/Dropzone'; import { Alert } from '@/components/ui/Alert'; import { downloadService } from '@/services/download'; import { formatBytes } from '@/tools/image/canvas.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record string; + dropArchive: string; + dropHint: string; + footer: string; + reading: (name: string) => string; + archiveWord: string; + fileCount: (n: number) => string; + download: string; +}> = { + en: { + noFiles: 'No files found in this archive.', + couldNotRead: 'Could not read this archive. It may be corrupt, password-protected, or an unsupported format.', + couldNotExtract: (name) => `Could not extract "${name}".`, + dropArchive: 'Drop an archive or click to browse', + dropHint: 'Extract RAR, 7z, TAR, GZ, ZIP and more — decoded in your browser', + footer: "Extract-only. Creating .rar/.7z isn't possible client-side (proprietary formats). The first open loads a ~1 MB decoder, then it's cached.", + reading: (name) => `Reading ${name}…`, + archiveWord: 'archive', + fileCount: (n) => `${n} file${n === 1 ? '' : 's'}`, + download: 'Download', + }, + id: { + noFiles: 'Tidak ada file yang ditemukan dalam arsip ini.', + couldNotRead: 'Tidak dapat membaca arsip ini. Mungkin rusak, dilindungi kata sandi, atau format yang tidak didukung.', + couldNotExtract: (name) => `Tidak dapat mengekstrak "${name}".`, + dropArchive: 'Letakkan arsip atau klik untuk menjelajah', + dropHint: 'Ekstrak RAR, 7z, TAR, GZ, ZIP dan lainnya — didekode di browser Anda', + footer: "Hanya ekstrak. Membuat .rar/.7z tidak mungkin di sisi klien (format proprietari). Pembukaan pertama memuat dekoder ~1 MB, lalu di-cache.", + reading: (name) => `Membaca ${name}…`, + archiveWord: 'arsip', + fileCount: (n) => `${n} file`, + download: 'Unduh', + }, +}; interface Entry { name: string; @@ -27,7 +66,8 @@ async function loadArchive() { const ACCEPT = '.rar,.7z,.zip,.tar,.gz,.tgz,.bz2,.tbz2,.xz,.txz,.zst,.cab,.iso,.cpio,.ar,.lha'; -export default function ArchiveExtract() { +export default function ArchiveExtract({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [archiveName, setArchiveName] = useState(''); const [entries, setEntries] = useState([]); const [busy, setBusy] = useState(false); @@ -53,12 +93,12 @@ export default function ArchiveExtract() { // Hide macOS archive cruft (__MACOSX/, AppleDouble ._ files). .filter(e => !e.name.includes('__MACOSX/') && !(e.name.split('/').pop() || '').startsWith('._')); setEntries(mapped); - if (mapped.length === 0) setError('No files found in this archive.'); + if (mapped.length === 0) setError(t.noFiles); } catch (e) { setError( e instanceof Error && e.message ? e.message - : 'Could not read this archive. It may be corrupt, password-protected, or an unsupported format.' + : t.couldNotRead ); } finally { setBusy(false); @@ -72,7 +112,7 @@ export default function ArchiveExtract() { const name = entry.name.split('/').pop() || extracted.name || 'file'; await downloadService.download(extracted, name); } catch { - setError(`Could not extract "${entry.name}".`); + setError(t.couldNotExtract(entry.name)); } }; @@ -80,24 +120,22 @@ export default function ArchiveExtract() {
-

Drop an archive or click to browse

+

{t.dropArchive}

- Extract RAR, 7z, TAR, GZ, ZIP and more — decoded in your browser + {t.dropHint}

- Extract-only. Creating .rar/.7z isn't possible client-side (proprietary formats). The first - open loads a ~1 MB decoder, then it's cached. + {t.footer}

- {busy &&

Reading {archiveName || 'archive'}…

} + {busy &&

{t.reading(archiveName || t.archiveWord)}

} {archiveName && !busy && !error && (

- {archiveName} — {entries.length} file - {entries.length === 1 ? '' : 's'} + {archiveName} — {t.fileCount(entries.length)}

)} @@ -111,7 +149,7 @@ export default function ArchiveExtract() { )}
-

Drop a file or click to browse

+

{t.dropFile}

- {mode === 'encrypt' - ? 'Any file — locked with AES-256, never leaves your device' - : 'Choose a .gwtenc file to unlock'} + {mode === 'encrypt' ? t.encryptHint : t.decryptHint}

@@ -98,7 +172,7 @@ export default function FileCrypt() { @@ -130,11 +204,11 @@ export default function FileCrypt() {
@@ -143,7 +217,7 @@ export default function FileCrypt() { {result && (
- {mode === 'encrypt' ? 'Encrypted' : 'Decrypted'} — {formatBytes(result.blob.size)} + {mode === 'encrypt' ? t.encrypted : t.decrypted} — {formatBytes(result.blob.size)}
@@ -151,9 +225,7 @@ export default function FileCrypt() {

- AES-256-GCM with a PBKDF2 key (250,000 iterations, SHA-256). Everything runs in your - browser — the file and password never leave your device. There is no password recovery: - lose the password and the file is unrecoverable. + {t.footer}

); diff --git a/src/islands/files/FileSplit.tsx b/src/islands/files/FileSplit.tsx index 547a5bf..adf45a6 100644 --- a/src/islands/files/FileSplit.tsx +++ b/src/islands/files/FileSplit.tsx @@ -6,9 +6,66 @@ import { Alert } from '@/components/ui/Alert'; import { downloadService } from '@/services/download'; import { formatBytes } from '@/tools/image/canvas.lib'; import { splitRanges, partName, joinedName, naturalCompare } from '@/tools/files/split.lib'; +import type { Lang } from '@/i18n/config'; type Mode = 'split' | 'join'; +const TR: Record string; + dropParts: string; + joinHint: string; + download: string; + moveUp: string; + moveDown: string; + remove: string; + joinLabel: (n: number) => string; +}> = { + en: { + split: 'Split', + join: 'Join', + partTooLarge: 'The part size is larger than the file — nothing to split.', + couldNotSplit: 'Could not split the file.', + addTwoParts: 'Add at least two parts to join.', + dropFile: 'Drop a file or click to browse', + splitHint: 'Cut a large file into fixed-size parts — all in your browser', + partSize: 'Part size (MB)', + splitInto: (n) => `Split into ${n} part${n === 1 ? '' : 's'}`, + dropParts: 'Drop the parts or click to browse', + joinHint: "They're ordered by name automatically — reorder below if needed", + download: 'Download', + moveUp: 'Move up', + moveDown: 'Move down', + remove: 'Remove', + joinLabel: (n) => `Join ${n || ''} part${n === 1 ? '' : 's'}`, + }, + id: { + split: 'Pisah', + join: 'Gabung', + partTooLarge: 'Ukuran bagian lebih besar dari file — tidak ada yang bisa dipisah.', + couldNotSplit: 'Tidak dapat memisah file.', + addTwoParts: 'Tambahkan minimal dua bagian untuk digabung.', + dropFile: 'Letakkan file atau klik untuk menjelajah', + splitHint: 'Potong file besar menjadi beberapa bagian berukuran tetap — semua di browser Anda', + partSize: 'Ukuran bagian (MB)', + splitInto: (n) => `Pisah menjadi ${n} bagian`, + dropParts: 'Letakkan bagian-bagiannya atau klik untuk menjelajah', + joinHint: 'Bagian diurutkan berdasarkan nama secara otomatis — susun ulang di bawah jika perlu', + download: 'Unduh', + moveUp: 'Naikkan', + moveDown: 'Turunkan', + remove: 'Hapus', + joinLabel: (n) => `Gabung ${n || ''} bagian`, + }, +}; + interface Part { name: string; blob: Blob; @@ -16,7 +73,8 @@ interface Part { let counter = 0; -export default function FileSplit() { +export default function FileSplit({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [mode, setMode] = useState('split'); const [error, setError] = useState(''); @@ -52,7 +110,7 @@ export default function FileSplit() { try { const ranges = splitRanges(file.size, chunkBytes); if (ranges.length <= 1) { - setError('The part size is larger than the file — nothing to split.'); + setError(t.partTooLarge); return; } setParts( @@ -62,7 +120,7 @@ export default function FileSplit() { })) ); } catch (e) { - setError(e instanceof Error ? e.message : 'Could not split the file.'); + setError(e instanceof Error ? e.message : t.couldNotSplit); } }; @@ -87,7 +145,7 @@ export default function FileSplit() { const doJoin = async () => { if (pieces.length < 2) { - setError('Add at least two parts to join.'); + setError(t.addTwoParts); return; } setError(''); @@ -100,11 +158,11 @@ export default function FileSplit() {
@@ -112,8 +170,8 @@ export default function FileSplit() { <>
-

Drop a file or click to browse

-

Cut a large file into fixed-size parts — all in your browser

+

{t.dropFile}

+

{t.splitHint}

@@ -124,7 +182,7 @@ export default function FileSplit() {

@@ -146,7 +204,7 @@ export default function FileSplit() {
  • {part.name} {formatBytes(part.blob.size)} -
  • @@ -158,8 +216,8 @@ export default function FileSplit() { <>
    -

    Drop the parts or click to browse

    -

    They're ordered by name automatically — reorder below if needed

    +

    {t.dropParts}

    +

    {t.joinHint}

    @@ -170,13 +228,13 @@ export default function FileSplit() { {i + 1} {p.file.name} {formatBytes(p.file.size)} - - - @@ -185,7 +243,7 @@ export default function FileSplit() { )} )} diff --git a/src/islands/files/FileTransfer.tsx b/src/islands/files/FileTransfer.tsx index 94deab3..8cafa68 100644 --- a/src/islands/files/FileTransfer.tsx +++ b/src/islands/files/FileTransfer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Send, Download, ShieldCheck, Settings } from 'lucide-react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; @@ -10,6 +10,7 @@ import { useFileTransfer } from '@/hooks/useFileTransfer'; import { makeRoomId, roomLink, roomIdFromHash } from '@/tools/webrtc/signal.lib'; import { formatBytes } from '@/tools/webrtc/file-transfer.lib'; import { effectiveIceServers } from '@/tools/webrtc/ice.lib'; +import type { Lang } from '@/i18n/config'; type Signaling = 'auto' | 'manual'; type ManualRole = 'send' | 'receive'; @@ -17,8 +18,141 @@ type ManualRole = 'send' | 'receive'; const ICE_KEY = 'gwt.webrtc.ice'; const SIGNALING_KEY = 'gwt.webrtc.signaling'; -export default function FileTransfer() { +const TR: Record string; sending: (name: string) => string; + transferring: string; receiving: string; downloadFile: string; + footerLine: string; footerManual: string; footerAuto: string; + stConnecting: string; stWaitingManual: string; stWaitingAuto: string; + stConnectedSend: string; stConnectedRecv: string; stTransferring: string; + stSent: string; stReceived: string; + errCreateOffer: string; errInvalidAnswer: string; errInvalidOffer: string; +}> = { + en: { + beforeConnect: 'Before you connect', + introAuto: ( + <>To introduce your two devices, GoodWebTools uses a small signaling server to + exchange connection details (about 2 KB). Your files transfer directly, + peer-to-peer, and never pass through our server. + ), + introManual: ( + <>Manual mode uses no server at all. You'll copy-paste a connection code to the + other person yourself. Files transfer directly, peer-to-peer. + ), + bestEffort: 'Connections are best-effort and may fail on restrictive networks unless you add your own TURN server.', + advancedSettings: 'Advanced connection settings', + connMethod: 'Connection method', + autoMethod: 'Automatic (share a link)', + manualMethod: 'Manual (copy-paste · no server)', + stunLabel: 'Your STUN / TURN servers (optional)', + stunHint: 'One per line. Leave empty to use public STUN. A TURN server makes connections work on strict networks.', + continue: 'Continue', + copyCode: 'Copy code', + copyLink: 'Copy link', + shareLink: 'Share this link with the other device', + pickRole: 'Manual connection — pick a role', + imSending: 'I’m sending', + imReceiving: 'I’m receiving', + step1Offer: 'Step 1 — send this offer code to the other person', + preparing: 'Preparing…', + step2Answer: 'Step 2 — paste the answer code they send back', + connect: 'Connect', + step1PasteOffer: 'Step 1 — paste the offer code from the sender', + genAnswer: 'Generate answer', + step2SendAnswer: 'Step 2 — send this answer code back to the sender', + chooseDifferent: 'Choose a different file', + pickFile: 'Pick a file to send', + sentDirectly: 'Sent directly to the other device.', + pickItNow: 'Pick it now — it sends automatically once the other device connects.', + readyToSend: (name) => `Ready to send: ${name} — waiting for the other device…`, + sending: (name) => `Sending ${name}`, + transferring: 'Transferring', + receiving: 'Receiving', + downloadFile: 'Download file', + footerLine: 'Files transfer directly between devices (peer-to-peer).', + footerManual: 'Manual mode uses no server at all.', + footerAuto: 'A minimal signaling server is used only to introduce the two devices (~2 KB handshake) — your files never pass through it.', + stConnecting: 'Connecting…', + stWaitingManual: 'Waiting to connect…', + stWaitingAuto: 'Waiting for the other device to join…', + stConnectedSend: 'Connected — choose a file to send.', + stConnectedRecv: 'Connected — waiting for a file…', + stTransferring: 'Transferring…', + stSent: 'Sent!', + stReceived: 'Received!', + errCreateOffer: 'Could not create the offer.', + errInvalidAnswer: 'Invalid answer code.', + errInvalidOffer: 'Invalid offer code.', + }, + id: { + beforeConnect: 'Sebelum Anda terhubung', + introAuto: ( + <>Untuk mengenalkan kedua perangkat Anda, GoodWebTools memakai sebuah signaling server kecil + untuk bertukar detail koneksi (sekitar 2 KB). File Anda ditransfer langsung, + peer-to-peer, dan tidak pernah melewati server kami. + ), + introManual: ( + <>Mode manual tidak memakai server sama sekali. Anda menyalin-tempel kode koneksi ke + orang lain sendiri. File ditransfer langsung, peer-to-peer. + ), + bestEffort: 'Koneksi bersifat best-effort dan bisa gagal di jaringan yang ketat kecuali Anda menambahkan server TURN sendiri.', + advancedSettings: 'Pengaturan koneksi lanjutan', + connMethod: 'Metode koneksi', + autoMethod: 'Otomatis (bagikan tautan)', + manualMethod: 'Manual (salin-tempel · tanpa server)', + stunLabel: 'Server STUN / TURN Anda (opsional)', + stunHint: 'Satu per baris. Kosongkan untuk memakai STUN publik. Server TURN membuat koneksi berfungsi di jaringan ketat.', + continue: 'Lanjutkan', + copyCode: 'Salin kode', + copyLink: 'Salin tautan', + shareLink: 'Bagikan tautan ini dengan perangkat lain', + pickRole: 'Koneksi manual — pilih peran', + imSending: 'Saya mengirim', + imReceiving: 'Saya menerima', + step1Offer: 'Langkah 1 — kirim kode offer ini ke orang lain', + preparing: 'Menyiapkan…', + step2Answer: 'Langkah 2 — tempel kode answer yang mereka kirim balik', + connect: 'Hubungkan', + step1PasteOffer: 'Langkah 1 — tempel kode offer dari pengirim', + genAnswer: 'Buat answer', + step2SendAnswer: 'Langkah 2 — kirim kode answer ini balik ke pengirim', + chooseDifferent: 'Pilih file lain', + pickFile: 'Pilih file untuk dikirim', + sentDirectly: 'Dikirim langsung ke perangkat lain.', + pickItNow: 'Pilih sekarang — file terkirim otomatis begitu perangkat lain terhubung.', + readyToSend: (name) => `Siap dikirim: ${name} — menunggu perangkat lain…`, + sending: (name) => `Mengirim ${name}`, + transferring: 'Mentransfer', + receiving: 'Menerima', + downloadFile: 'Unduh file', + footerLine: 'File ditransfer langsung antar perangkat (peer-to-peer).', + footerManual: 'Mode manual tidak memakai server sama sekali.', + footerAuto: 'Signaling server minimal hanya dipakai untuk mengenalkan kedua perangkat (handshake ~2 KB) — file Anda tidak pernah melewatinya.', + stConnecting: 'Menghubungkan…', + stWaitingManual: 'Menunggu untuk terhubung…', + stWaitingAuto: 'Menunggu perangkat lain bergabung…', + stConnectedSend: 'Terhubung — pilih file untuk dikirim.', + stConnectedRecv: 'Terhubung — menunggu file…', + stTransferring: 'Mentransfer…', + stSent: 'Terkirim!', + stReceived: 'Diterima!', + errCreateOffer: 'Tidak bisa membuat offer.', + errInvalidAnswer: 'Kode answer tidak valid.', + errInvalidOffer: 'Kode offer tidak valid.', + }, +}; + +export default function FileTransfer({ lang = 'en' }: { lang?: Lang }) { const t = useFileTransfer(); + const tr = TR[lang] ?? TR.en; const [acked, setAcked] = useState(false); const [signaling, setSignaling] = useState('auto'); const [iceText, setIceText] = useState(''); @@ -97,7 +231,7 @@ export default function FileTransfer() { try { setOfferCode(await t.manualCreateOffer(ice())); } catch (e) { - setManualErr(e instanceof Error ? e.message : 'Could not create the offer.'); + setManualErr(e instanceof Error ? e.message : tr.errCreateOffer); } finally { setManualBusy(false); } @@ -105,7 +239,7 @@ export default function FileTransfer() { const submitAnswer = async () => { setManualErr(''); try { await t.manualAcceptAnswer(pastedAnswer.trim()); } - catch (e) { setManualErr(e instanceof Error ? e.message : 'Invalid answer code.'); } + catch (e) { setManualErr(e instanceof Error ? e.message : tr.errInvalidAnswer); } }; // Manual: receiver @@ -116,7 +250,7 @@ export default function FileTransfer() { try { setAnswerCode(await t.manualAcceptOffer(pastedOffer.trim(), ice())); } catch (e) { - setManualErr(e instanceof Error ? e.message : 'Invalid offer code.'); + setManualErr(e instanceof Error ? e.message : tr.errInvalidOffer); } finally { setManualBusy(false); } @@ -130,18 +264,11 @@ export default function FileTransfer() {

    - Before you connect + {tr.beforeConnect}

    - {signaling === 'auto' ? ( - <>To introduce your two devices, GoodWebTools uses a small signaling server to - exchange connection details (about 2 KB). Your files transfer directly, - peer-to-peer, and never pass through our server. - ) : ( - <>Manual mode uses no server at all. You'll copy-paste a connection code to the - other person yourself. Files transfer directly, peer-to-peer. - )}{' '} - Connections are best-effort and may fail on restrictive networks unless you add your own TURN server. + {signaling === 'auto' ? tr.introAuto : tr.introManual}{' '} + {tr.bestEffort}

    {showAdvanced && (
    {!joining && (
    - Connection method + {tr.connMethod}
    )}