From d6a404c87e443d1eefbb5cff2bc527dfd989344c Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 2 Aug 2026 15:02:06 +0700 Subject: [PATCH] feat(i18n): localize all 21 Image tool UIs to Bahasa (wave 2) Every Image island renders Bahasa on /id/ via the lang prop + local TR:{en,id} (LegacyLetter pattern). Covers convert/view/svg, monochrome, compress/resize/crop, annotate, watermark/stamp/qr/merge, upscale, object-remove, portrait/face blur, OCR + receipt scanner, camera, background remove, metadata scrub. Also threads lang through shared OcrWorkbench/ReceiptFields/CameraCapture. Logic untouched; format/brand/acronym terms kept; no 'alat'. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- src/islands/image/BackgroundRemove.tsx | 71 +++++++++--- src/islands/image/CameraCapture.tsx | 29 ++++- src/islands/image/CameraTool.tsx | 14 ++- src/islands/image/FaceBlur.tsx | 76 ++++++++++--- src/islands/image/ImageAnnotate.tsx | 115 ++++++++++++++++--- src/islands/image/ImageCompress.tsx | 56 +++++++-- src/islands/image/ImageConvert.tsx | 65 +++++++++-- src/islands/image/ImageCrop.tsx | 56 +++++++-- src/islands/image/ImageMerge.tsx | 105 ++++++++++++----- src/islands/image/ImageOcr.tsx | 33 ++++-- src/islands/image/ImageQr.tsx | 74 +++++++++--- src/islands/image/ImageResize.tsx | 80 ++++++++++--- src/islands/image/ImageScrub.tsx | 51 +++++++-- src/islands/image/ImageStamp.tsx | 101 +++++++++++++---- src/islands/image/ImageUpscale.tsx | 73 +++++++++--- src/islands/image/ImageViewer.tsx | 91 ++++++++++++--- src/islands/image/ImageWatermark.tsx | 72 +++++++++--- src/islands/image/Monochrome.tsx | 57 +++++++--- src/islands/image/ObjectRemove.tsx | 151 ++++++++++++++++++++----- src/islands/image/OcrWorkbench.tsx | 69 ++++++++--- src/islands/image/PortraitBlur.tsx | 78 ++++++++++--- src/islands/image/ReceiptFields.tsx | 42 +++++-- src/islands/image/ReceiptScanner.tsx | 15 ++- src/islands/image/SvgViewer.tsx | 57 ++++++++-- 24 files changed, 1321 insertions(+), 310 deletions(-) diff --git a/src/islands/image/BackgroundRemove.tsx b/src/islands/image/BackgroundRemove.tsx index 0ff3957..d693ef6 100644 --- a/src/islands/image/BackgroundRemove.tsx +++ b/src/islands/image/BackgroundRemove.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { Download } from 'lucide-react'; +import type { Lang } from '@/i18n/config'; import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; @@ -10,7 +11,52 @@ import { downloadService } from '@/services/download'; import { formatBytes } from '@/tools/image/canvas.lib'; import { usePasteImage } from '@/hooks/usePasteImage'; -export default function BackgroundRemove() { +const TR: Record = { + en: { + preparing: 'Preparing…', + downloadingModel: 'Downloading AI model…', + removingBg: 'Removing background…', + removeFailed: 'Background removal failed.', + dropHere: 'Drop an image or click to browse', + dropSub: 'Removes the background with an on-device AI model · or paste (⌘V)', + privacyNote: "Runs entirely in your browser — the image never leaves your device. The first run downloads the AI model (~40 MB), then it's cached for next time.", + working: 'Working…', + result: 'Result', + transparentPng: 'transparent PNG', + altRemoved: 'Background removed', + downloadPng: 'Download PNG', + }, + id: { + preparing: 'Menyiapkan…', + downloadingModel: 'Mengunduh model AI…', + removingBg: 'Menghapus latar belakang…', + removeFailed: 'Gagal menghapus latar belakang.', + dropHere: 'Jatuhkan gambar atau klik untuk menjelajah', + dropSub: 'Menghapus latar belakang dengan model AI di perangkat · atau tempel (⌘V)', + privacyNote: 'Berjalan sepenuhnya di browser Anda — gambar tidak pernah meninggalkan perangkat Anda. Jalankan pertama kali mengunduh model AI (~40 MB), lalu disimpan di cache untuk berikutnya.', + working: 'Memproses…', + result: 'Hasil', + transparentPng: 'PNG transparan', + altRemoved: 'Latar belakang dihapus', + downloadPng: 'Unduh PNG', + }, +}; + +export default function BackgroundRemove({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [srcName, setSrcName] = useState(''); const [result, setResult] = useState(null); const [resultUrl, setResultUrl] = useState(''); @@ -29,7 +75,7 @@ export default function BackgroundRemove() { setError(''); setBusy(true); setPercent(0); - setStage('Preparing…'); + setStage(t.preparing); try { // Loaded lazily: the library pulls in onnxruntime-web + lodash (CJS), // which isn't server-render-safe, and this keeps it out of the initial bundle. @@ -41,7 +87,7 @@ export default function BackgroundRemove() { progress: (key, current, total) => { const pct = total > 0 ? Math.round((current / total) * 100) : 0; setPercent(pct); - setStage(key.startsWith('fetch') ? 'Downloading AI model…' : 'Removing background…'); + setStage(key.startsWith('fetch') ? t.downloadingModel : t.removingBg); }, }); setResult(blob); @@ -50,7 +96,7 @@ export default function BackgroundRemove() { return URL.createObjectURL(blob); }); } catch (e) { - setError(e instanceof Error ? e.message : 'Background removal failed.'); + setError(e instanceof Error ? e.message : t.removeFailed); } finally { setBusy(false); setStage(''); @@ -69,21 +115,20 @@ export default function BackgroundRemove() {
-

Drop an image or click to browse

+

{t.dropHere}

- Removes the background with an on-device AI model · or paste (⌘V) + {t.dropSub}

- Runs entirely in your browser — the image never leaves your device. The first run downloads - the AI model (~40 MB), then it's cached for next time. + {t.privacyNote}

{busy && (
- +
)} @@ -92,18 +137,18 @@ export default function BackgroundRemove() { {result && resultUrl && !busy && (
- Result + {t.result} {formatBytes(result.size)} - transparent PNG + {t.transparentPng}
{/* Checkerboard makes the transparency obvious. */}
- Background removed + {t.altRemoved}
diff --git a/src/islands/image/CameraCapture.tsx b/src/islands/image/CameraCapture.tsx index 2fcbaed..bed6b44 100644 --- a/src/islands/image/CameraCapture.tsx +++ b/src/islands/image/CameraCapture.tsx @@ -3,14 +3,31 @@ import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { useCamera } from '@/hooks/useCamera'; import { frameToFile } from '@/tools/image/camera.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { + useDeviceCamera: 'Use device camera', cancel: 'Cancel', capturing: 'Capturing…', + capture: 'Capture', switchCamera: 'Switch camera', + }, + id: { + useDeviceCamera: 'Gunakan kamera perangkat', cancel: 'Batal', capturing: 'Mengambil…', + capture: 'Ambil', switchCamera: 'Ganti kamera', + }, +}; export default function CameraCapture({ onCapture, onCancel, + lang = 'en', }: { onCapture: (file: File) => void; onCancel: () => void; + lang?: Lang; }) { + const t = TR[lang] ?? TR.en; const { videoRef, stream, error, hasMultiple, start, stop, switchCamera } = useCamera(); const fileInputRef = useRef(null); const [busy, setBusy] = useState(false); @@ -50,19 +67,19 @@ export default function CameraCapture({
{error.message}
- - + +
) : (
)} diff --git a/src/islands/image/CameraTool.tsx b/src/islands/image/CameraTool.tsx index 35d5125..3e2048f 100644 --- a/src/islands/image/CameraTool.tsx +++ b/src/islands/image/CameraTool.tsx @@ -1,9 +1,16 @@ import { useState } from 'react'; +import type { Lang } from '@/i18n/config'; import { Button } from '@/components/ui/Button'; import { ImageResult } from '@/components/ui/ImageResult'; import CameraCapture from './CameraCapture'; -export default function CameraTool() { +const TR: Record = { + en: { openCamera: 'Open camera', retake: 'Retake' }, + id: { openCamera: 'Buka kamera', retake: 'Ambil ulang' }, +}; + +export default function CameraTool({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [photo, setPhoto] = useState(null); const [capturing, setCapturing] = useState(true); @@ -13,20 +20,21 @@ export default function CameraTool() {
{capturing && ( { setPhoto(file); setCapturing(false); }} onCancel={() => setCapturing(false)} /> )} {!capturing && !photo && ( - + )} {photo && (
{/* ImageResult already renders Download / Copy image / Edit in Annotator. */} - +
)}
diff --git a/src/islands/image/FaceBlur.tsx b/src/islands/image/FaceBlur.tsx index fc61bb1..8a917ec 100644 --- a/src/islands/image/FaceBlur.tsx +++ b/src/islands/image/FaceBlur.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { Download } from 'lucide-react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Button } from '@/components/ui/Button'; @@ -9,6 +9,7 @@ import { downloadService } from '@/services/download'; import { formatBytes } from '@/tools/image/canvas.lib'; import { expandBox, type Box } from '@/tools/image/face-blur.lib'; import { usePasteImage } from '@/hooks/usePasteImage'; +import type { Lang } from '@/i18n/config'; type Effect = 'blur' | 'pixelate' | 'solid'; @@ -18,6 +19,46 @@ const EFFECTS: { key: Effect; label: string }[] = [ { key: 'solid', label: 'Solid' }, ]; +const TR: Record; + privacy: ReactNode; loadingDetector: string; detecting: string; working: string; + errProcess: string; noFaces: string; result: string; facesHidden: (n: number) => string; + altBlurred: string; downloadPng: string; +}> = { + en: { + dropTitle: 'Drop an image or click to browse', + dropDesc: 'Detects and hides faces with on-device AI · or paste (⌘V)', + effect: 'Effect', + effects: { blur: 'Blur', pixelate: 'Pixelate', solid: 'Solid' }, + privacy: <>Runs entirely in your browser — the image never leaves your device. Detection is automatic; for privacy-critical images, prefer Solid (blur/pixelate can be partly reversed). The first run downloads a small model, then it's cached., + loadingDetector: 'Loading face detector…', + detecting: 'Detecting faces…', + working: 'Working…', + errProcess: 'Could not process this image.', + noFaces: 'No faces detected in this image.', + result: 'Result', + facesHidden: (n) => `${n} face${n === 1 ? '' : 's'} hidden`, + altBlurred: 'Faces blurred', + downloadPng: 'Download PNG', + }, + id: { + dropTitle: 'Letakkan gambar atau klik untuk memilih', + dropDesc: 'Mendeteksi dan menyembunyikan wajah dengan AI di perangkat · atau tempel (⌘V)', + effect: 'Efek', + effects: { blur: 'Blur', pixelate: 'Piksel', solid: 'Blok' }, + privacy: <>Berjalan sepenuhnya di browser Anda — gambar tidak pernah meninggalkan perangkat Anda. Deteksi berjalan otomatis; untuk gambar yang sangat sensitif, pilih Blok (blur/piksel bisa sebagian dipulihkan). Penggunaan pertama mengunduh model kecil, lalu di-cache., + loadingDetector: 'Memuat pendeteksi wajah…', + detecting: 'Mendeteksi wajah…', + working: 'Memproses…', + errProcess: 'Tidak dapat memproses gambar ini.', + noFaces: 'Tidak ada wajah terdeteksi pada gambar ini.', + result: 'Hasil', + facesHidden: (n) => `${n} wajah disembunyikan`, + altBlurred: 'Wajah diburamkan', + downloadPng: 'Unduh PNG', + }, +}; + // Cache the detector across runs so the model loads once. let detectorPromise: Promise<{ detect: (img: ImageBitmap) => { detections: { boundingBox?: { originX: number; originY: number; width: number; height: number } }[] } }> | null = null; @@ -68,7 +109,8 @@ function obscure(ctx: CanvasRenderingContext2D, bmp: ImageBitmap, box: Box, effe ctx.restore(); } -export default function FaceBlur() { +export default function FaceBlur({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [effect, setEffect] = useState('blur'); const [srcName, setSrcName] = useState(''); const [result, setResult] = useState(null); @@ -88,10 +130,10 @@ export default function FaceBlur() { setError(''); setBusy(true); try { - setStage('Loading face detector…'); + setStage(t.loadingDetector); const detector = await getDetector(); const bmp = await createImageBitmap(file); - setStage('Detecting faces…'); + setStage(t.detecting); const { detections } = detector.detect(bmp); const canvas = document.createElement('canvas'); @@ -123,7 +165,7 @@ export default function FaceBlur() { return URL.createObjectURL(blob); }); } catch (e) { - setError(e instanceof Error ? e.message : 'Could not process this image.'); + setError(e instanceof Error ? e.message : t.errProcess); } finally { setBusy(false); setStage(''); @@ -153,15 +195,15 @@ export default function FaceBlur() {
-

Drop an image or click to browse

+

{t.dropTitle}

- Detects and hides faces with on-device AI · or paste (⌘V) + {t.dropDesc}

- Effect + {t.effect}
{EFFECTS.map(e => ( ))}

- Runs entirely in your browser — the image never leaves your device. Detection is automatic; - for privacy-critical images, prefer Solid (blur/pixelate can be partly - reversed). The first run downloads a small model, then it's cached. + {t.privacy}

- {busy &&

{stage || 'Working…'}

} + {busy &&

{stage || t.working}

} {error && {error}} {faceCount === 0 && !busy && !error && ( - No faces detected in this image. + {t.noFaces} )} {result && resultUrl && !busy && faceCount !== null && faceCount > 0 && (
- Result - {faceCount} face{faceCount === 1 ? '' : 's'} hidden + {t.result} + {t.facesHidden(faceCount)} {formatBytes(result.size)}
- Faces blurred + {t.altBlurred}
diff --git a/src/islands/image/ImageAnnotate.tsx b/src/islands/image/ImageAnnotate.tsx index 6783b63..c69df79 100644 --- a/src/islands/image/ImageAnnotate.tsx +++ b/src/islands/image/ImageAnnotate.tsx @@ -21,6 +21,7 @@ import { CopyImageButton } from '@/components/ui/CopyImageButton'; import { downloadService } from '@/services/download'; import { usePasteImage } from '@/hooks/usePasteImage'; import { takePendingImage } from '@/services/handoff'; +import type { Lang } from '@/i18n/config'; type Tool = 'select' | 'rect' | 'ellipse' | 'line' | 'arrow' | 'pencil' | 'highlighter' | 'text' | 'blur'; @@ -54,6 +55,85 @@ const TOOLS: { tool: Tool; label: string; Icon: typeof Square }[] = [ { tool: 'blur', label: 'Blur', Icon: Droplets }, ]; +const TR: Record; + importImage: string; + importBtn: string; + color: string; + width: string; + rounded: string; + undo: string; + undoAria: string; + redo: string; + redoAria: string; + clearAnnotations: string; + selectHint: string; + textPlaceholder: string; + downloadPng: string; + clear: string; +}> = { + en: { + dropTitle: 'Drop an image or click to browse', + dropHint: 'Annotate with shapes, arrows, text, highlighter, and blur · Select to move or rename', + tools: { + select: 'Select / move', + rect: 'Rectangle', + ellipse: 'Ellipse', + line: 'Line', + arrow: 'Arrow', + pencil: 'Pencil', + highlighter: 'Highlighter', + text: 'Text', + blur: 'Blur', + }, + importImage: 'Import image', + importBtn: 'Import', + color: 'Color', + width: 'Width', + rounded: 'Rounded', + undo: 'Undo (⌘/Ctrl+Z)', + undoAria: 'Undo', + redo: 'Redo (⌘/Ctrl+Shift+Z)', + redoAria: 'Redo', + clearAnnotations: 'Clear annotations', + selectHint: 'Drag to move · drag a handle to resize · double-click text to rename · Delete to remove', + textPlaceholder: 'Type, then Enter', + downloadPng: 'Download PNG', + clear: 'Clear', + }, + id: { + dropTitle: 'Jatuhkan gambar atau klik untuk memilih', + dropHint: 'Anotasi dengan bentuk, panah, teks, penyorot, dan blur · Pilih untuk memindahkan atau mengganti nama', + tools: { + select: 'Pilih / pindahkan', + rect: 'Persegi panjang', + ellipse: 'Elips', + line: 'Garis', + arrow: 'Panah', + pencil: 'Pensil', + highlighter: 'Penyorot', + text: 'Teks', + blur: 'Blur', + }, + importImage: 'Impor gambar', + importBtn: 'Impor', + color: 'Warna', + width: 'Tebal', + rounded: 'Membulat', + undo: 'Urungkan (⌘/Ctrl+Z)', + undoAria: 'Urungkan', + redo: 'Ulangi (⌘/Ctrl+Shift+Z)', + redoAria: 'Ulangi', + clearAnnotations: 'Bersihkan anotasi', + selectHint: 'Seret untuk memindahkan · seret pegangan untuk mengubah ukuran · klik dua kali teks untuk mengganti nama · Delete untuk menghapus', + textPlaceholder: 'Ketik, lalu Enter', + downloadPng: 'Unduh PNG', + clear: 'Bersihkan', + }, +}; + function drawPath(ctx: CanvasRenderingContext2D, points: [number, number][]) { ctx.beginPath(); points.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))); @@ -326,7 +406,8 @@ function resizeShape(s: Shape, id: HandleId, px: number, py: number, ctx: Canvas return { ...s, x: nx, y: ny, w: nw, h: nh }; } -export default function ImageAnnotate() { +export default function ImageAnnotate({ lang = 'en' }: { lang?: Lang }) { + const tr = TR[lang] ?? TR.en; const viewRef = useRef(null); const baseRef = useRef(null); const blurRef = useRef(null); @@ -780,9 +861,9 @@ export default function ImageAnnotate() { {!file && (
-

Drop an image or click to browse

+

{tr.dropTitle}

- Annotate with shapes, arrows, text, highlighter, and blur · Select to move or rename + {tr.dropHint}

@@ -792,12 +873,12 @@ export default function ImageAnnotate() { <> {/* Toolbar */}
- {TOOLS.map(({ tool: t, label, Icon }) => ( + {TOOLS.map(({ tool: t, Icon }) => ( -
{tool === 'select' && (

- Drag to move · drag a handle to resize · double-click text to rename · Delete to remove + {tr.selectHint}

)} @@ -894,7 +975,7 @@ export default function ImageAnnotate() { if (e.key === 'Enter') commitText(); if (e.key === 'Escape') { setTextEdit(null); setTextValue(''); } }} - placeholder="Type, then Enter" + placeholder={tr.textPlaceholder} className="absolute border-2 border-accent bg-white px-1 text-sm text-black outline-none" style={{ left: textEdit.left, top: textEdit.top }} /> @@ -904,11 +985,11 @@ export default function ImageAnnotate() {
diff --git a/src/islands/image/ImageCompress.tsx b/src/islands/image/ImageCompress.tsx index 4fccc0c..5fb3f77 100644 --- a/src/islands/image/ImageCompress.tsx +++ b/src/islands/image/ImageCompress.tsx @@ -5,13 +5,47 @@ import { Alert } from '@/components/ui/Alert'; import { ImageResult } from '@/components/ui/ImageResult'; import { processImage, formatBytes } from '@/tools/image/canvas.lib'; import { usePasteImage } from '@/hooks/usePasteImage'; +import type { Lang } from '@/i18n/config'; const FORMATS = [ - { mime: 'image/webp', ext: 'webp', label: 'WebP (smaller)' }, - { mime: 'image/jpeg', ext: 'jpg', label: 'JPEG' }, + { mime: 'image/webp', ext: 'webp', label: { en: 'WebP (smaller)', id: 'WebP (lebih kecil)' } }, + { mime: 'image/jpeg', ext: 'jpg', label: { en: 'JPEG', id: 'JPEG' } }, ]; -export default function ImageCompress() { +const TR: Record = { + en: { + dropTitle: 'Drop an image or click to browse', + dropHint: 'Shrink an image by re-encoding it · or paste (⌘V)', + format: 'Format', + quality: 'Quality', + compressing: 'Compressing…', + compress: 'Compress', + clear: 'Clear', + failed: 'Compression failed', + }, + id: { + dropTitle: 'Jatuhkan gambar atau klik untuk menelusuri', + dropHint: 'Perkecil ukuran gambar dengan mengode ulang · atau tempel (⌘V)', + format: 'Format', + quality: 'Kualitas', + compressing: 'Mengompres…', + compress: 'Kompres', + clear: 'Bersihkan', + failed: 'Kompresi gagal', + }, +}; + +export default function ImageCompress({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [file, setFile] = useState(null); const [mime, setMime] = useState('image/webp'); const [quality, setQuality] = useState(75); @@ -39,7 +73,7 @@ export default function ImageCompress() { const { blob } = await processImage(file, { mimeType: mime, quality: quality / 100 }); setResult(blob); } catch (e) { - setError(e instanceof Error ? e.message : 'Compression failed'); + setError(e instanceof Error ? e.message : t.failed); } finally { setBusy(false); } @@ -49,8 +83,8 @@ export default function ImageCompress() {
-

Drop an image or click to browse

-

Shrink an image by re-encoding it · or paste (⌘V)

+

{t.dropTitle}

+

{t.dropHint}

@@ -62,7 +96,7 @@ export default function ImageCompress() {
- Format + {t.format}
{FORMATS.map(({ mime: value, label }) => ( @@ -72,7 +106,7 @@ export default function ImageCompress() { aria-pressed={mime === value} onClick={() => setMime(value)} > - {label} + {label[lang] ?? label.en} ))}
@@ -80,7 +114,7 @@ export default function ImageCompress() {
diff --git a/src/islands/image/ImageConvert.tsx b/src/islands/image/ImageConvert.tsx index fde6bda..c464fa5 100644 --- a/src/islands/image/ImageConvert.tsx +++ b/src/islands/image/ImageConvert.tsx @@ -6,6 +6,52 @@ import { ImageResult } from '@/components/ui/ImageResult'; import { processImage } from '@/tools/image/canvas.lib'; import { imageToIco, imageToGif, imageToSvg, canvasSupportsType } from '@/tools/image/encode.lib'; import { usePasteImage } from '@/hooks/usePasteImage'; +import type { Lang } from '@/i18n/config'; + +const TR: Record string; + outputFormat: string; + quality: string; + converting: string; + convertTo: (label: string) => string; + clear: string; + conversionFailed: string; + notes: Record; +}> = { + en: { + drop: 'Drop an image or click to browse', + sub: (avif) => `Convert to PNG, JPEG, WebP${avif ? ', AVIF' : ''}, GIF, ICO, or SVG · or paste (⌘V)`, + outputFormat: 'Output format', + quality: 'Quality', + converting: 'Converting…', + convertTo: (label) => `Convert to ${label}`, + clear: 'Clear', + conversionFailed: 'Conversion failed', + notes: { + avif: 'AVIF (AV1 image) — great compression, modern browsers.', + gif: 'Single frame, 256 colors.', + ico: 'Multi-size favicon: 16, 32, and 48px in one .ico.', + svg: 'Wraps the image inside an SVG (embedded, not vectorized).', + }, + }, + id: { + drop: 'Letakkan gambar atau klik untuk memilih', + sub: (avif) => `Konversi ke PNG, JPEG, WebP${avif ? ', AVIF' : ''}, GIF, ICO, atau SVG · atau tempel (⌘V)`, + outputFormat: 'Format keluaran', + quality: 'Kualitas', + converting: 'Mengonversi…', + convertTo: (label) => `Konversi ke ${label}`, + clear: 'Bersihkan', + conversionFailed: 'Konversi gagal', + notes: { + avif: 'AVIF (AV1 image) — kompresi bagus, browser modern.', + gif: 'Satu frame, 256 warna.', + ico: 'Favicon multi-ukuran: 16, 32, dan 48px dalam satu .ico.', + svg: 'Membungkus gambar di dalam SVG (disematkan, bukan divektorkan).', + }, + }, +}; type Kind = 'canvas' | 'ico' | 'gif' | 'svg'; @@ -51,7 +97,8 @@ const FORMATS: Format[] = [ }, ]; -export default function ImageConvert() { +export default function ImageConvert({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [file, setFile] = useState(null); const [key, setKey] = useState('png'); const [quality, setQuality] = useState(90); @@ -95,7 +142,7 @@ export default function ImageConvert() { } setResult(blob); } catch (e) { - setError(e instanceof Error ? e.message : 'Conversion failed'); + setError(e instanceof Error ? e.message : t.conversionFailed); } finally { setBusy(false); } @@ -105,9 +152,9 @@ export default function ImageConvert() {
-

Drop an image or click to browse

+

{t.drop}

- Convert to PNG, JPEG, WebP{avifOk ? ', AVIF' : ''}, GIF, ICO, or SVG · or paste (⌘V) + {t.sub(avifOk)}

@@ -116,7 +163,7 @@ export default function ImageConvert() {
- Output format + {t.outputFormat}
{available.map(f => ( @@ -130,13 +177,13 @@ export default function ImageConvert() { ))}
- {fmt.note &&

{fmt.note}

} + {fmt.note &&

{t.notes[fmt.key] ?? fmt.note}

}
{fmt.lossy && (
diff --git a/src/islands/image/ImageCrop.tsx b/src/islands/image/ImageCrop.tsx index ac1f19a..bb79c6f 100644 --- a/src/islands/image/ImageCrop.tsx +++ b/src/islands/image/ImageCrop.tsx @@ -5,6 +5,7 @@ import { Alert } from '@/components/ui/Alert'; import { ImageResult } from '@/components/ui/ImageResult'; import { cropImage, keepFormat } from '@/tools/image/canvas.lib'; import { usePasteImage } from '@/hooks/usePasteImage'; +import type { Lang } from '@/i18n/config'; interface Rect { x: number; @@ -15,7 +16,43 @@ interface Rect { type Mode = 'draw' | 'body' | 'nw' | 'ne' | 'sw' | 'se'; const CORNERS = ['nw', 'ne', 'sw', 'se'] as const; -export default function ImageCrop() { +const TR: Record = { + en: { + dropTitle: 'Drop an image or click to browse', + dropHint: 'Drag the crop box; resize it from the corners · or paste (⌘V)', + sourceAlt: 'Source', + fileHint: ' — drag the box to move, corners to resize · ', + selectFirst: 'Draw or adjust a selection first.', + cropFailed: 'Crop failed', + cropping: 'Cropping…', + crop: 'Crop', + clear: 'Clear', + }, + id: { + dropTitle: 'Jatuhkan gambar atau klik untuk memilih', + dropHint: 'Seret kotak crop; ubah ukuran dari sudutnya · atau tempel (⌘V)', + sourceAlt: 'Sumber', + fileHint: ' — seret kotak untuk memindahkan, sudut untuk mengubah ukuran · ', + selectFirst: 'Gambar atau sesuaikan seleksi terlebih dahulu.', + cropFailed: 'Crop gagal', + cropping: 'Memotong…', + crop: 'Potong', + clear: 'Bersihkan', + }, +}; + +export default function ImageCrop({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const imgRef = useRef(null); const action = useRef<{ mode: Mode; sx: number; sy: number; start: Rect } | null>(null); const [file, setFile] = useState(null); @@ -107,7 +144,7 @@ export default function ImageCrop() { const crop = async () => { const img = imgRef.current; if (!file || !img || !sel || sel.w < 2 || sel.h < 2) { - setError('Draw or adjust a selection first.'); + setError(t.selectFirst); return; } const s = img.naturalWidth / img.clientWidth; @@ -123,7 +160,7 @@ export default function ImageCrop() { }); setResult(blob); } catch (e) { - setError(e instanceof Error ? e.message : 'Crop failed'); + setError(e instanceof Error ? e.message : t.cropFailed); } finally { setBusy(false); } @@ -138,8 +175,8 @@ export default function ImageCrop() { {!file && (
-

Drop an image or click to browse

-

Drag the crop box; resize it from the corners · or paste (⌘V)

+

{t.dropTitle}

+

{t.dropHint}

)} @@ -147,8 +184,7 @@ export default function ImageCrop() { {file && srcUrl && ( <>

- {file.name} — drag the box to move, - corners to resize · {natW}×{natH} + {file.name}{t.fileHint}{natW}×{natH}

diff --git a/src/islands/image/ImageMerge.tsx b/src/islands/image/ImageMerge.tsx index 7ef6885..c8ac8aa 100644 --- a/src/islands/image/ImageMerge.tsx +++ b/src/islands/image/ImageMerge.tsx @@ -6,6 +6,7 @@ import { Alert } from '@/components/ui/Alert'; import { ImageResult } from '@/components/ui/ImageResult'; import { mergeImages, type MergeDirection } from '@/tools/image/merge.lib'; import { usePasteImage } from '@/hooks/usePasteImage'; +import type { Lang } from '@/i18n/config'; interface Item { id: string; @@ -15,8 +16,57 @@ interface Item { let counter = 0; +const TR: Record string; + colSummary: (cols: number, rows: number) => string; + drop: string; dropSub: string; + moveUp: string; moveDown: string; remove: string; + direction: string; vertical: string; horizontal: string; grid: string; + chooseGridColumns: string; gap: string; + match: (horizontal: boolean) => string; + transparent: string; background: string; + gridSummary: (cols: number, rows: number) => string; change: string; + merging: string; mergeBtn: (n: number) => string; clear: string; + errMin: string; failed: string; +}> = { + en: { + columns: 'Columns', + colAria: c => `${c} column${c > 1 ? 's' : ''}`, + colSummary: (cols, rows) => `${cols} column${cols > 1 ? 's' : ''} × ${rows} row${rows > 1 ? 's' : ''}`, + drop: 'Drop images or click to browse', + dropSub: 'Combine multiple images into one · reorder them below · or paste (⌘V)', + moveUp: 'Move up', moveDown: 'Move down', remove: 'Remove', + direction: 'Direction', vertical: 'Vertical', horizontal: 'Horizontal', grid: 'Grid', + chooseGridColumns: 'Choose grid columns', gap: 'Gap (px)', + match: horizontal => `Match ${horizontal ? 'heights' : 'widths'}`, + transparent: 'Transparent', background: 'Background', + gridSummary: (cols, rows) => `${cols} column${cols > 1 ? 's' : ''} × ${rows} rows —`, + change: 'change', + merging: 'Merging…', mergeBtn: n => `Merge ${n || ''} images`.trim(), clear: 'Clear', + errMin: 'Add at least two images to merge.', failed: 'Merge failed', + }, + id: { + columns: 'Kolom', + colAria: c => `${c} kolom`, + colSummary: (cols, rows) => `${cols} kolom × ${rows} baris`, + drop: 'Letakkan gambar atau klik untuk telusuri', + dropSub: 'Gabungkan beberapa gambar menjadi satu · atur ulang urutannya di bawah · atau tempel (⌘V)', + moveUp: 'Naikkan', moveDown: 'Turunkan', remove: 'Hapus', + direction: 'Arah', vertical: 'Vertikal', horizontal: 'Horizontal', grid: 'Grid', + chooseGridColumns: 'Pilih kolom grid', gap: 'Jarak (px)', + match: horizontal => `Samakan ${horizontal ? 'tinggi' : 'lebar'}`, + transparent: 'Transparan', background: 'Latar belakang', + gridSummary: (cols, rows) => `${cols} kolom × ${rows} baris —`, + change: 'ubah', + merging: 'Menggabungkan…', mergeBtn: n => `Gabungkan ${n || ''} gambar`.trim(), clear: 'Bersihkan', + errMin: 'Tambahkan minimal dua gambar untuk digabungkan.', failed: 'Gagal menggabungkan', + }, +}; + /** Google-Docs-style grid picker: hover/click a cell to choose the column count. */ -function ColumnPicker({ count, columns, onChange }: { count: number; columns: number; onChange: (cols: number) => void }) { +function ColumnPicker({ count, columns, onChange, lang }: { count: number; columns: number; onChange: (cols: number) => void; lang: Lang }) { + const t = TR[lang] ?? TR.en; const [hover, setHover] = useState(0); const maxCols = Math.min(8, Math.max(1, count)); const active = hover || columns; @@ -26,7 +76,7 @@ function ColumnPicker({ count, columns, onChange }: { count: number; columns: nu return (
- Columns + {t.columns}
setHover(c)} onFocus={() => setHover(c)} onClick={() => onChange(c)} - aria-label={`${c} column${c > 1 ? 's' : ''}`} + aria-label={t.colAria(c)} className={`h-5 w-5 border-2 ${on ? 'border-accent bg-accent/40' : 'border-border bg-background'}`} /> ); })}

- {active} column{active > 1 ? 's' : ''} × {Math.ceil(count / active)} row{Math.ceil(count / active) > 1 ? 's' : ''} + {t.colSummary(active, Math.ceil(count / active))}

); } -export default function ImageMerge() { +export default function ImageMerge({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; const [items, setItems] = useState([]); const [direction, setDirection] = useState('vertical'); const [gap, setGap] = useState(0); @@ -105,7 +156,7 @@ export default function ImageMerge() { const run = async () => { if (items.length < 2) { - setError('Add at least two images to merge.'); + setError(t.errMin); return; } setBusy(true); @@ -121,7 +172,7 @@ export default function ImageMerge() { }); setResult(blob); } catch (e) { - setError(e instanceof Error ? e.message : 'Merge failed'); + setError(e instanceof Error ? e.message : t.failed); } finally { setBusy(false); } @@ -131,9 +182,9 @@ export default function ImageMerge() {
-

Drop images or click to browse

+

{t.drop}

- Combine multiple images into one · reorder them below · or paste (⌘V) + {t.dropSub}

@@ -148,7 +199,7 @@ export default function ImageMerge() { {/* The picker anchors to this button, dropping straight below it like a select menu. */}
@@ -196,14 +247,14 @@ export default function ImageMerge() { onClick={() => { if (direction === 'grid') { setPickerOpen(o => !o); } else { setDirection('grid'); setPickerOpen(true); } }} > - Grid + {t.grid} {direction === 'grid' && pickerOpen && (
@@ -211,6 +262,7 @@ export default function ImageMerge() { count={items.length} columns={columns} onChange={c => { setColumns(c); setPickerOpen(false); }} + lang={lang} />
)} @@ -219,7 +271,7 @@ export default function ImageMerge() {
{!transparent && ( -