- 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}
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}
)}
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}
{weak && (
- Short passwords are easy to guess — use 8+ characters (a passphrase is best).
+ {t.weakWarning}
)}
@@ -130,11 +204,11 @@ export default function FileCrypt() {
@@ -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() {
- Part size (MB)
+ {t.partSize}
>
@@ -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)}
- move(i, -1)} disabled={i === 0} title="Move up" className="border-2 border-border bg-muted p-1.5 shadow-brutal-sm press-brutal disabled:opacity-30">
+ move(i, -1)} disabled={i === 0} title={t.moveUp} className="border-2 border-border bg-muted p-1.5 shadow-brutal-sm press-brutal disabled:opacity-30">
- move(i, 1)} disabled={i === pieces.length - 1} title="Move down" className="border-2 border-border bg-muted p-1.5 shadow-brutal-sm press-brutal disabled:opacity-30">
+ move(i, 1)} disabled={i === pieces.length - 1} title={t.moveDown} className="border-2 border-border bg-muted p-1.5 shadow-brutal-sm press-brutal disabled:opacity-30">
- removePiece(p.id)} title="Remove" className="border-2 border-border bg-muted p-1.5 shadow-brutal-sm press-brutal">
+ removePiece(p.id)} title={t.remove} className="border-2 border-border bg-muted p-1.5 shadow-brutal-sm press-brutal">
@@ -185,7 +243,7 @@ export default function FileSplit() {
)}
- Join {pieces.length || ''} part{pieces.length === 1 ? '' : 's'}
+ {t.joinLabel(pieces.length)}
>
)}
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}
persistSignaling('auto')}>
- Automatic (share a link)
+ {tr.autoMethod}
persistSignaling('manual')}>
- Manual (copy-paste · no server)
+ {tr.manualMethod}
)}
- Your STUN / TURN servers (optional)
+ {tr.stunLabel}
- One per line. Leave empty to use public STUN. A TURN server makes connections work on strict networks.
+ {tr.stunHint}
)}
- Continue
+ {tr.continue}
);
}
const statusLabel: Record = {
- connecting: 'Connecting…',
- waiting: signaling === 'manual' ? 'Waiting to connect…' : 'Waiting for the other device to join…',
- connected: isSending ? 'Connected — choose a file to send.' : 'Connected — waiting for a file…',
- transferring: 'Transferring…',
- done: isSending ? 'Sent!' : 'Received!',
+ connecting: tr.stConnecting,
+ waiting: signaling === 'manual' ? tr.stWaitingManual : tr.stWaitingAuto,
+ connected: isSending ? tr.stConnectedSend : tr.stConnectedRecv,
+ transferring: tr.stTransferring,
+ done: isSending ? tr.stSent : tr.stReceived,
};
const codeBox = (value: string) => (
)}
@@ -286,32 +413,30 @@ export default function FileTransfer() {
- {queuedName ? 'Choose a different file' : 'Pick a file to send'}
+ {queuedName ? tr.chooseDifferent : tr.pickFile}
- {t.status === 'connected'
- ? 'Sent directly to the other device.'
- : 'Pick it now — it sends automatically once the other device connects.'}
+ {t.status === 'connected' ? tr.sentDirectly : tr.pickItNow}
@@ -319,10 +444,8 @@ export default function FileTransfer() {
- Files transfer directly between devices (peer-to-peer).{' '}
- {signaling === 'manual'
- ? 'Manual mode uses no server at all.'
- : 'A minimal signaling server is used only to introduce the two devices (~2 KB handshake) — your files never pass through it.'}
+ {tr.footerLine}{' '}
+ {signaling === 'manual' ? tr.footerManual : tr.footerAuto}
);
diff --git a/src/islands/files/ZipTool.tsx b/src/islands/files/ZipTool.tsx
index f36f8d9..58f9a3d 100644
--- a/src/islands/files/ZipTool.tsx
+++ b/src/islands/files/ZipTool.tsx
@@ -6,12 +6,70 @@ import { Alert } from '@/components/ui/Alert';
import { downloadService } from '@/services/download';
import { formatBytes } from '@/tools/image/canvas.lib';
import { createZip, extractZip, type ZipEntry } from '@/tools/files/zip.lib';
+import type { Lang } from '@/i18n/config';
type Mode = 'create' | 'extract';
+const TR: Record string;
+ total: (s: string) => string;
+ clear: string;
+ dropZip: string;
+ listHint: string;
+ reading: string;
+ fileCount: (n: number) => string;
+ download: string;
+}> = {
+ en: {
+ createZip: 'Create ZIP',
+ extractZip: 'Extract ZIP',
+ couldNotCreate: 'Could not create the archive.',
+ couldNotRead: 'Could not read the archive.',
+ dropFiles: 'Drop files or click to browse',
+ bundleHint: 'Bundle any files into a single .zip — all in your browser',
+ remove: 'Remove',
+ zipping: 'Zipping…',
+ createZipCount: (n) => `Create ZIP (${n} file${n === 1 ? '' : 's'})`,
+ total: (s) => `${s} total`,
+ clear: 'Clear',
+ dropZip: 'Drop a .zip or click to browse',
+ listHint: 'List its contents and download any file',
+ reading: 'reading…',
+ fileCount: (n) => `${n} file${n === 1 ? '' : 's'}`,
+ download: 'Download',
+ },
+ id: {
+ createZip: 'Buat ZIP',
+ extractZip: 'Ekstrak ZIP',
+ couldNotCreate: 'Tidak dapat membuat arsip.',
+ couldNotRead: 'Tidak dapat membaca arsip.',
+ dropFiles: 'Letakkan file atau klik untuk menjelajah',
+ bundleHint: 'Gabungkan file apa pun ke dalam satu .zip — semua di browser Anda',
+ remove: 'Hapus',
+ zipping: 'Membuat ZIP…',
+ createZipCount: (n) => `Buat ZIP (${n} file)`,
+ total: (s) => `${s} total`,
+ clear: 'Bersihkan',
+ dropZip: 'Letakkan file .zip atau klik untuk menjelajah',
+ listHint: 'Tampilkan isinya dan unduh file mana pun',
+ reading: 'membaca…',
+ fileCount: (n) => `${n} file`,
+ download: 'Unduh',
+ },
+};
+
let counter = 0;
-export default function ZipTool() {
+export default function ZipTool({ lang = 'en' }: { lang?: Lang }) {
+ const t = TR[lang] ?? TR.en;
const [mode, setMode] = useState('create');
// Create mode: files queued for zipping.
const [items, setItems] = useState<{ id: string; file: File }[]>([]);
@@ -46,7 +104,7 @@ export default function ZipTool() {
const zipped = createZip(zipEntries);
await downloadService.download(new Blob([zipped], { type: 'application/zip' }), 'archive.zip');
} catch (e) {
- setError(e instanceof Error ? e.message : 'Could not create the archive.');
+ setError(e instanceof Error ? e.message : t.couldNotCreate);
} finally {
setBusy(false);
}
@@ -62,7 +120,7 @@ export default function ZipTool() {
try {
setEntries(extractZip(new Uint8Array(await file.arrayBuffer())));
} catch (e) {
- setError(e instanceof Error ? e.message : 'Could not read the archive.');
+ setError(e instanceof Error ? e.message : t.couldNotRead);
} finally {
setBusy(false);
}
@@ -80,11 +138,11 @@ export default function ZipTool() {
setMode('create')}>
- Create ZIP
+ {t.createZip}
setMode('extract')}>
- Extract ZIP
+ {t.extractZip}
@@ -92,8 +150,8 @@ export default function ZipTool() {
<>
-
Drop files or click to browse
-
Bundle any files into a single .zip — all in your browser
+
{t.dropFiles}
+
{t.bundleHint}
@@ -103,7 +161,7 @@ export default function ZipTool() {
)}
diff --git a/src/islands/media/ScreenRecorder.tsx b/src/islands/media/ScreenRecorder.tsx
index 7e2fd95..b85d98d 100644
--- a/src/islands/media/ScreenRecorder.tsx
+++ b/src/islands/media/ScreenRecorder.tsx
@@ -14,6 +14,57 @@ import {
getRecordingStartedAt,
saveRecorderSettings,
} from '@/services/global-recording';
+import type { Lang } from '@/i18n/config';
+
+const TR: Record = {
+ en: {
+ introPart1: 'Record a tab, window, or your whole screen. The browser asks what to share, and everything is captured and encoded ',
+ introLocal: 'locally',
+ introPart2: ' — nothing is uploaded. System/tab audio is included when the browser allows it.',
+ notSupported: "Your browser doesn't support screen recording (getDisplayMedia / MediaRecorder).",
+ micLabel: 'Also record microphone',
+ startRecording: 'Start recording',
+ saving: 'Saving...',
+ stop: 'Stop',
+ processing: 'Processing...',
+ recordingLabel: 'Recording',
+ download: 'Download',
+ errCancelled: 'Screen sharing was cancelled.',
+ errCouldNotStart: 'Could not start screen recording.',
+ errStopFailed: 'Failed to stop recording.',
+ },
+ id: {
+ introPart1: 'Rekam sebuah tab, jendela, atau seluruh layar Anda. Browser akan menanyakan apa yang ingin dibagikan, dan semuanya ditangkap serta dikodekan ',
+ introLocal: 'secara lokal',
+ introPart2: ' — tidak ada yang diunggah. Audio sistem/tab disertakan bila browser mengizinkannya.',
+ notSupported: 'Browser Anda tidak mendukung perekaman layar (getDisplayMedia / MediaRecorder).',
+ micLabel: 'Rekam juga mikrofon',
+ startRecording: 'Mulai merekam',
+ saving: 'Menyimpan...',
+ stop: 'Hentikan',
+ processing: 'Memproses...',
+ recordingLabel: 'Rekaman',
+ download: 'Unduh',
+ errCancelled: 'Berbagi layar dibatalkan.',
+ errCouldNotStart: 'Tidak dapat memulai perekaman layar.',
+ errStopFailed: 'Gagal menghentikan perekaman.',
+ },
+};
async function blobToDataUrl(blob: Blob): Promise {
const buf = await blob.arrayBuffer();
@@ -39,7 +90,8 @@ function pickMime(): { mime: string; ext: string } {
return { mime: '', ext: 'webm' };
}
-export default function ScreenRecorder() {
+export default function ScreenRecorder({ lang = 'en' }: { lang?: Lang }) {
+ const t = TR[lang] ?? TR.en;
// Start as false for SSR, then check in useEffect
const [supported, setSupported] = useState(false);
const [recording, setRecording] = useState(false);
@@ -207,8 +259,8 @@ export default function ScreenRecorder() {
countdown: inTauriApp,
});
} catch (e) {
- if (e instanceof DOMException && e.name === 'NotAllowedError') setError('Screen sharing was cancelled.');
- else setError(e instanceof Error ? e.message : 'Could not start screen recording.');
+ if (e instanceof DOMException && e.name === 'NotAllowedError') setError(t.errCancelled);
+ else setError(e instanceof Error ? e.message : t.errCouldNotStart);
}
};
@@ -219,7 +271,7 @@ export default function ScreenRecorder() {
// Result + recording=false arrive via the gwt:recording-* events.
await stopManaged();
} catch (e) {
- const message = e instanceof Error ? e.message : 'Failed to stop recording.';
+ const message = e instanceof Error ? e.message : t.errStopFailed;
// A "Captured N frames" message is informational, not a hard error.
if (message.includes('Captured') && message.includes('frames')) setError(`✅ ${message}`);
else setError(message);
@@ -285,16 +337,15 @@ export default function ScreenRecorder() {
const mmss = `${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`;
if (!supported) {
- return Your browser doesn't support screen recording (getDisplayMedia / MediaRecorder).;
+ return {t.notSupported};
}
return (
- Record a tab, window, or your whole screen. The browser asks what to share, and everything is
- captured and encoded locally — nothing is uploaded.
- System/tab audio is included when the browser allows it.
+ {t.introPart1}
+ {t.introLocal}{t.introPart2}
)}
diff --git a/src/islands/media/Screenshot.tsx b/src/islands/media/Screenshot.tsx
index a2bf15d..cec955c 100644
--- a/src/islands/media/Screenshot.tsx
+++ b/src/islands/media/Screenshot.tsx
@@ -9,10 +9,101 @@ import { detectCompanion, companionCapture } from '@/services/companion';
import { captureService } from '@/services/capture';
import type { DisplayInfo } from '@/services/capture';
import { isTauri } from '@/services/platform';
+import type { Lang } from '@/i18n/config';
type Rect = { x: number; y: number; w: number; h: number };
-export default function Screenshot() {
+const TR: Record string;
+ capturingEllipsis: string;
+ enhancedCapture: string;
+ companionDetectedBold: string;
+ companionDetectedRest: string;
+ companionCta1: string;
+ companionCta2: string;
+ cropHint: string;
+ screenshotAlt: string;
+ formatLabel: string;
+ exportCrop: string;
+ exportFull: string;
+ retake: string;
+ resultLabel: string;
+ resultAlt: string;
+ download: string;
+ errCaptureCancelled: string;
+ errCouldNotCapture: string;
+ errExtCancelled: string;
+ errLoadImage: string;
+ errExtFailed: (m: string) => string;
+}> = {
+ en: {
+ ssIntro1: 'Pick a screen, window, or tab; a countdown gives you time to arrange it, then a frame is grabbed and drawn to a canvas ',
+ ssIntroLocal: 'locally',
+ ssIntro2: ". DRM-protected content captures as black — that's a browser rule, not a bug.",
+ notSupported: "Your browser doesn't support screen capture (getDisplayMedia).",
+ countdownLabel: 'Countdown (s)',
+ captureScreen: 'Capture screen',
+ capturingIn: (n) => `Capturing in ${n}…`,
+ capturingEllipsis: 'Capturing…',
+ enhancedCapture: 'Enhanced capture',
+ companionDetectedBold: 'Companion extension detected.',
+ companionDetectedRest: ' Enhanced capture skips the countdown and can be triggered by a global hotkey even while another window is focused.',
+ companionCta1: 'Want a global hotkey and cross-window capture? Install the optional ',
+ companionCta2: ' extension — the tool works fully without it.',
+ cropHint: 'Drag on the image to select a crop region, or export the whole screenshot.',
+ screenshotAlt: 'Screenshot',
+ formatLabel: 'Format',
+ exportCrop: 'Export crop',
+ exportFull: 'Export full',
+ retake: 'Retake',
+ resultLabel: 'Result',
+ resultAlt: 'Screenshot result',
+ download: 'Download',
+ errCaptureCancelled: 'Screen capture was cancelled.',
+ errCouldNotCapture: 'Could not capture the screen.',
+ errExtCancelled: 'Capture was cancelled.',
+ errLoadImage: 'Failed to load captured image',
+ errExtFailed: (m) => `Extension capture failed (${m}).`,
+ },
+ id: {
+ ssIntro1: 'Pilih sebuah layar, jendela, atau tab; hitung mundur memberi Anda waktu untuk menatanya, lalu satu frame diambil dan digambar ke kanvas ',
+ ssIntroLocal: 'secara lokal',
+ ssIntro2: '. Konten yang dilindungi DRM tertangkap sebagai hitam — itu aturan browser, bukan bug.',
+ notSupported: 'Browser Anda tidak mendukung tangkapan layar (getDisplayMedia).',
+ countdownLabel: 'Hitung mundur (d)',
+ captureScreen: 'Tangkap layar',
+ capturingIn: (n) => `Menangkap dalam ${n}…`,
+ capturingEllipsis: 'Menangkap…',
+ enhancedCapture: 'Tangkapan lanjutan',
+ companionDetectedBold: 'Ekstensi Companion terdeteksi.',
+ companionDetectedRest: ' Tangkapan lanjutan melewati hitung mundur dan dapat dipicu oleh hotkey global bahkan saat jendela lain sedang fokus.',
+ companionCta1: 'Ingin hotkey global dan tangkapan lintas jendela? Pasang ekstensi opsional ',
+ companionCta2: ' — tool ini berfungsi penuh tanpanya.',
+ cropHint: 'Seret pada gambar untuk memilih area potongan, atau ekspor seluruh tangkapan layar.',
+ screenshotAlt: 'Tangkapan layar',
+ formatLabel: 'Format',
+ exportCrop: 'Ekspor potongan',
+ exportFull: 'Ekspor penuh',
+ retake: 'Ambil ulang',
+ resultLabel: 'Hasil',
+ resultAlt: 'Hasil tangkapan layar',
+ download: 'Unduh',
+ errCaptureCancelled: 'Tangkapan layar dibatalkan.',
+ errCouldNotCapture: 'Tidak dapat menangkap layar.',
+ errExtCancelled: 'Tangkapan dibatalkan.',
+ errLoadImage: 'Gagal memuat gambar yang ditangkap',
+ errExtFailed: (m) => `Tangkapan ekstensi gagal (${m}).`,
+ },
+};
+
+export default function Screenshot({ lang = 'en' }: { lang?: Lang }) {
+ const t = TR[lang] ?? TR.en;
// Start as false for SSR, then check in useEffect
const [supported, setSupported] = useState(false);
const [delay, setDelay] = useState(3);
@@ -135,7 +226,7 @@ export default function Screenshot() {
loadDataUrl(cap.dataUrl, cap.width, cap.height);
} catch (e) {
const m = e instanceof Error ? e.message : 'capture-failed';
- setError(m === 'cancelled' ? 'Capture was cancelled.' : `Extension capture failed (${m}).`);
+ setError(m === 'cancelled' ? t.errExtCancelled : t.errExtFailed(m));
}
};
@@ -212,7 +303,7 @@ export default function Screenshot() {
});
} catch (e) {
console.error('[Screenshot] Region capture failed:', e);
- setError(e instanceof Error ? e.message : 'Could not capture the screen.');
+ setError(e instanceof Error ? e.message : t.errCouldNotCapture);
} finally {
// Always restore main window
if (isTauri()) {
@@ -253,7 +344,7 @@ export default function Screenshot() {
await new Promise((resolve, reject) => {
img.onload = () => resolve();
- img.onerror = () => reject(new Error('Failed to load captured image'));
+ img.onerror = () => reject(new Error(t.errLoadImage));
img.src = dataUrl;
});
@@ -269,8 +360,8 @@ export default function Screenshot() {
setPreviewUrl(prev => { if (prev) URL.revokeObjectURL(prev); return canvas.toDataURL('image/png'); });
} catch (e) {
console.error('[Screenshot] Capture failed:', e);
- if (e instanceof DOMException && e.name === 'NotAllowedError') setError('Screen capture was cancelled.');
- else setError(e instanceof Error ? e.message : 'Could not capture the screen.');
+ if (e instanceof DOMException && e.name === 'NotAllowedError') setError(t.errCaptureCancelled);
+ else setError(e instanceof Error ? e.message : t.errCouldNotCapture);
} finally {
setCapturing(false);
setCountdown(0);
@@ -330,7 +421,7 @@ export default function Screenshot() {
};
if (!supported) {
- return Your browser doesn't support screen capture (getDisplayMedia).;
+ return {t.notSupported};
}
return (
@@ -339,14 +430,13 @@ export default function Screenshot() {
<>
- Pick a screen, window, or tab; a countdown gives you time to arrange it, then a frame is grabbed
- and drawn to a canvas locally. DRM-protected
- content captures as black — that's a browser rule, not a bug.
+ {t.ssIntro1}
+ {t.ssIntroLocal}{t.ssIntro2}
- Companion extension detected. Enhanced capture
- skips the countdown and can be triggered by a global hotkey even while another window is focused.
+ {t.companionDetectedBold}{t.companionDetectedRest}
) : (
- Want a global hotkey and cross-window capture? Install the optional{' '}
- GoodWebTools Companion extension — the tool
- works fully without it.
+ {t.companionCta1}
+ GoodWebTools Companion{t.companionCta2}
- Runs entirely in your browser via ffmpeg.wasm — the video never leaves your device. Transcoding is
- CPU-bound; long or high-resolution clips can take a while.
+ {t.privacy}
- Runs entirely in your browser via ffmpeg.wasm — the video never leaves your device. Keep
- clips short and the width modest; long or large videos can be slow.
+ {t.browserNote}
- Transcribing on your device… ({formatClock(elapsed)})
+ {t.onDevice(formatClock(elapsed))}
{MODELS.find(m => m.value === model)?.value === 'onnx-community/whisper-small'
- ? ' — the “Better” model is much slower, especially on phones; a short clip can take a few minutes.'
- : ' this can take a moment.'}
+ ? t.slowSmall
+ : t.takesMoment}
)}
diff --git a/src/islands/network/OpticalTransfer.tsx b/src/islands/network/OpticalTransfer.tsx
index 7fa2edc..4c3e2f4 100644
--- a/src/islands/network/OpticalTransfer.tsx
+++ b/src/islands/network/OpticalTransfer.tsx
@@ -9,6 +9,7 @@ import { useCamera } from '@/hooks/useCamera';
import { renderQr, decodeQr } from '@/tools/optical/qr.lib';
import { encodeFrame, decodeFrame, fnv1a, packFile, unpackFile } from '@/tools/optical/frame.lib';
import { bytesToBlocks, blocksToBytes, LtEncoder, LtDecoder } from '@/tools/optical/fountain.lib';
+import type { Lang } from '@/i18n/config';
type Role = 'send' | 'receive' | null;
@@ -17,35 +18,95 @@ const SEND_FPS = 8;
const BIG_FILE = 256 * 1024; // warn beyond this — the optical channel is slow
const CAPTURE_W = 720; // downscale camera frames for faster decoding
+const TR: Record string;
+ bigWarn: string; pointHere: string; chooseAnother: string;
+ pointCamera: string;
+ receiving: (k: number) => string;
+ framesCaptured: (n: number) => string;
+ checksumFail: string;
+ receivedPre: string; receivedPost: (bytes: string) => string;
+ downloadFile: string; receiveAnother: string;
+}> = {
+ en: {
+ introA: 'Transfer a file between two devices with ',
+ introBold: 'just a screen and a camera',
+ introB: ' — no network, no accounts, nothing sent to any server. One device shows animated QR codes; the other reads them.',
+ send: 'Send a file',
+ receive: 'Receive a file',
+ back: '← Back',
+ dropBeam: 'Drop a file to beam',
+ dropHint: 'Best for small files (text, keys, docs, small images) · stays on your device',
+ blocks: (k) => `${k} blocks`,
+ bigWarn: '⚠️ This file is on the large side for an optical transfer — it may take several minutes. Keep both devices steady.',
+ pointHere: 'Point the other device’s camera at this code. It loops until the file is received.',
+ chooseAnother: 'Choose another file',
+ pointCamera: 'Point your camera at the other device’s animated QR code and hold steady.',
+ receiving: (k) => `Receiving — ${k} blocks`,
+ framesCaptured: (n) => `${n} frames captured`,
+ checksumFail: 'Received the file but its checksum didn’t match — try again.',
+ receivedPre: 'Received ',
+ receivedPost: (bytes) => ` (${bytes} bytes).`,
+ downloadFile: 'Download file',
+ receiveAnother: 'Receive another',
+ },
+ id: {
+ introA: 'Transfer file antara dua perangkat dengan ',
+ introBold: 'hanya layar dan kamera',
+ introB: ' — tanpa jaringan, tanpa akun, tidak ada yang dikirim ke server mana pun. Satu perangkat menampilkan kode QR beranimasi; yang satunya membacanya.',
+ send: 'Kirim file',
+ receive: 'Terima file',
+ back: '← Kembali',
+ dropBeam: 'Jatuhkan file untuk dipancarkan',
+ dropHint: 'Paling cocok untuk file kecil (teks, kunci, dokumen, gambar kecil) · tetap di perangkat Anda',
+ blocks: (k) => `${k} blok`,
+ bigWarn: '⚠️ File ini tergolong besar untuk transfer optik — mungkin butuh beberapa menit. Jaga kedua perangkat tetap stabil.',
+ pointHere: 'Arahkan kamera perangkat lain ke kode ini. Kode akan terus berulang sampai file diterima.',
+ chooseAnother: 'Pilih file lain',
+ pointCamera: 'Arahkan kamera Anda ke kode QR beranimasi perangkat lain dan tahan dengan stabil.',
+ receiving: (k) => `Menerima — ${k} blok`,
+ framesCaptured: (n) => `${n} frame tertangkap`,
+ checksumFail: 'File diterima tetapi checksum-nya tidak cocok — coba lagi.',
+ receivedPre: 'Menerima ',
+ receivedPost: (bytes) => ` (${bytes} byte).`,
+ downloadFile: 'Unduh file',
+ receiveAnother: 'Terima lagi',
+ },
+};
+
function randU16(): number {
return Math.floor((crypto.getRandomValues(new Uint16Array(1))[0]));
}
-export default function OpticalTransfer() {
+export default function OpticalTransfer({ lang = 'en' }: { lang?: Lang }) {
const [role, setRole] = useState(null);
+ const tr = TR[lang] ?? TR.en;
return (
{role === null && (
- Transfer a file between two devices with just a screen and a camera — no network, no
- accounts, nothing sent to any server. One device shows animated QR codes; the other reads them.
+ {tr.introA}{tr.introBold}{tr.introB}
- setRole('send')}> Send a file
- setRole('receive')}> Receive a file
+ setRole('send')}> {tr.send}
+ setRole('receive')}> {tr.receive}
- {signaling === 'auto' ? (
- <>To introduce your two devices, GoodWebTools uses a small signaling server to exchange
- connection details (about 2 KB). Your video, audio, and chat travel directly, peer-to-peer,
- and never pass through our server.>
- ) : (
- <>Manual mode uses no server at all. You'll copy-paste an invite code to the other person
- yourself. Video, audio and chat travel directly, peer-to-peer.>
- )}{' '}
- You'll be asked for camera and microphone access. Connections are best-effort and may fail on restrictive
- networks unless you add your own TURN server.
+ {signaling === 'auto' ? tr.introAuto : tr.introManual}{' '}
+ {tr.askAccess}
@@ -280,8 +405,8 @@ export default function VideoCall() {
- Video, audio and chat travel directly between devices (peer-to-peer).{' '}
- {signaling === 'manual' ? 'Manual mode uses no server at all.' : 'A minimal signaling server only introduces the two devices — your media never passes through it.'}
+ {tr.footerLine}{' '}
+ {signaling === 'manual' ? tr.footerManual : tr.footerAuto}
);
diff --git a/src/islands/pdf/ImagesToPdf.tsx b/src/islands/pdf/ImagesToPdf.tsx
index fb98d9a..aa029ea 100644
--- a/src/islands/pdf/ImagesToPdf.tsx
+++ b/src/islands/pdf/ImagesToPdf.tsx
@@ -6,8 +6,45 @@ import { ResultActions } from '@/components/ui/ResultActions';
import { PdfPreview } from '@/components/ui/PdfPreview';
import { Alert } from '@/components/ui/Alert';
import { imagesToPdf } from '@/tools/pdf/pdf.lib';
+import type { Lang } from '@/i18n/config';
-export default function ImagesToPdf() {
+const TR: Record = {
+ en: {
+ dropTitle: 'Drop PNG/JPG images or click to browse',
+ dropSubtitle: 'One image per page, in the order below',
+ moveUp: 'Move up',
+ moveDown: 'Move down',
+ remove: 'Remove',
+ building: 'Building…',
+ createPdf: 'Create PDF',
+ clear: 'Clear',
+ errConversion: 'Conversion failed',
+ },
+ id: {
+ dropTitle: 'Letakkan gambar PNG/JPG atau klik untuk menjelajah',
+ dropSubtitle: 'Satu gambar per halaman, dengan urutan di bawah',
+ moveUp: 'Pindah ke atas',
+ moveDown: 'Pindah ke bawah',
+ remove: 'Hapus',
+ building: 'Membuat…',
+ createPdf: 'Buat PDF',
+ clear: 'Bersihkan',
+ errConversion: 'Konversi gagal',
+ },
+};
+
+export default function ImagesToPdf({ lang = 'en' }: { lang?: Lang }) {
+ const t = TR[lang] ?? TR.en;
const [files, setFiles] = useState([]);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
@@ -43,7 +80,7 @@ export default function ImagesToPdf() {
try {
setResult(await imagesToPdf(files));
} catch (e) {
- setError(e instanceof Error ? e.message : 'Conversion failed');
+ setError(e instanceof Error ? e.message : t.errConversion);
} finally {
setBusy(false);
}
@@ -53,8 +90,8 @@ export default function ImagesToPdf() {