Skip to content

Commit 35ce99e

Browse files
authored
Merge pull request #129 from slaveofcode/develop
Promote: Bahasa Image tool UIs (wave 2)
2 parents f9500cb + bb598c7 commit 35ce99e

24 files changed

Lines changed: 1321 additions & 310 deletions

src/islands/image/BackgroundRemove.tsx

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useState } from 'react';
22
import { Download } from 'lucide-react';
3+
import type { Lang } from '@/i18n/config';
34
import { Dropzone } from '@/components/ui/Dropzone';
45
import { Button } from '@/components/ui/Button';
56
import { Alert } from '@/components/ui/Alert';
@@ -10,7 +11,52 @@ import { downloadService } from '@/services/download';
1011
import { formatBytes } from '@/tools/image/canvas.lib';
1112
import { usePasteImage } from '@/hooks/usePasteImage';
1213

13-
export default function BackgroundRemove() {
14+
const TR: Record<Lang, {
15+
preparing: string;
16+
downloadingModel: string;
17+
removingBg: string;
18+
removeFailed: string;
19+
dropHere: string;
20+
dropSub: string;
21+
privacyNote: string;
22+
working: string;
23+
result: string;
24+
transparentPng: string;
25+
altRemoved: string;
26+
downloadPng: string;
27+
}> = {
28+
en: {
29+
preparing: 'Preparing…',
30+
downloadingModel: 'Downloading AI model…',
31+
removingBg: 'Removing background…',
32+
removeFailed: 'Background removal failed.',
33+
dropHere: 'Drop an image or click to browse',
34+
dropSub: 'Removes the background with an on-device AI model · or paste (⌘V)',
35+
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.",
36+
working: 'Working…',
37+
result: 'Result',
38+
transparentPng: 'transparent PNG',
39+
altRemoved: 'Background removed',
40+
downloadPng: 'Download PNG',
41+
},
42+
id: {
43+
preparing: 'Menyiapkan…',
44+
downloadingModel: 'Mengunduh model AI…',
45+
removingBg: 'Menghapus latar belakang…',
46+
removeFailed: 'Gagal menghapus latar belakang.',
47+
dropHere: 'Jatuhkan gambar atau klik untuk menjelajah',
48+
dropSub: 'Menghapus latar belakang dengan model AI di perangkat · atau tempel (⌘V)',
49+
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.',
50+
working: 'Memproses…',
51+
result: 'Hasil',
52+
transparentPng: 'PNG transparan',
53+
altRemoved: 'Latar belakang dihapus',
54+
downloadPng: 'Unduh PNG',
55+
},
56+
};
57+
58+
export default function BackgroundRemove({ lang = 'en' }: { lang?: Lang }) {
59+
const t = TR[lang] ?? TR.en;
1460
const [srcName, setSrcName] = useState('');
1561
const [result, setResult] = useState<Blob | null>(null);
1662
const [resultUrl, setResultUrl] = useState('');
@@ -29,7 +75,7 @@ export default function BackgroundRemove() {
2975
setError('');
3076
setBusy(true);
3177
setPercent(0);
32-
setStage('Preparing…');
78+
setStage(t.preparing);
3379
try {
3480
// Loaded lazily: the library pulls in onnxruntime-web + lodash (CJS),
3581
// which isn't server-render-safe, and this keeps it out of the initial bundle.
@@ -41,7 +87,7 @@ export default function BackgroundRemove() {
4187
progress: (key, current, total) => {
4288
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
4389
setPercent(pct);
44-
setStage(key.startsWith('fetch') ? 'Downloading AI model…' : 'Removing background…');
90+
setStage(key.startsWith('fetch') ? t.downloadingModel : t.removingBg);
4591
},
4692
});
4793
setResult(blob);
@@ -50,7 +96,7 @@ export default function BackgroundRemove() {
5096
return URL.createObjectURL(blob);
5197
});
5298
} catch (e) {
53-
setError(e instanceof Error ? e.message : 'Background removal failed.');
99+
setError(e instanceof Error ? e.message : t.removeFailed);
54100
} finally {
55101
setBusy(false);
56102
setStage('');
@@ -69,21 +115,20 @@ export default function BackgroundRemove() {
69115
<div className="space-y-4">
70116
<Dropzone onDrop={run} accept="image/*" multiple={false}>
71117
<div className="space-y-1">
72-
<p className="text-lg font-bold">Drop an image or click to browse</p>
118+
<p className="text-lg font-bold">{t.dropHere}</p>
73119
<p className="text-sm text-muted-foreground">
74-
Removes the background with an on-device AI model · or paste (⌘V)
120+
{t.dropSub}
75121
</p>
76122
</div>
77123
</Dropzone>
78124

79125
<p className="text-xs text-muted-foreground">
80-
Runs entirely in your browser — the image never leaves your device. The first run downloads
81-
the AI model (~40&nbsp;MB), then it's cached for next time.
126+
{t.privacyNote}
82127
</p>
83128

84129
{busy && (
85130
<div className="space-y-2">
86-
<ProgressBar percent={percent} label={stage || 'Working…'} />
131+
<ProgressBar percent={percent} label={stage || t.working} />
87132
</div>
88133
)}
89134

@@ -92,18 +137,18 @@ export default function BackgroundRemove() {
92137
{result && resultUrl && !busy && (
93138
<div className="space-y-2">
94139
<div className="flex flex-wrap items-center gap-3 text-sm">
95-
<span className="font-bold uppercase tracking-wide text-muted-foreground">Result</span>
140+
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.result}</span>
96141
<span className="font-mono">{formatBytes(result.size)}</span>
97-
<span className="text-muted-foreground">transparent PNG</span>
142+
<span className="text-muted-foreground">{t.transparentPng}</span>
98143
</div>
99144
{/* Checkerboard makes the transparency obvious. */}
100145
<div className="gwt-checkerboard inline-block max-w-full border-2 border-border p-1">
101-
<img src={resultUrl} alt="Background removed" className="block max-h-[70vh] w-auto max-w-full" />
146+
<img src={resultUrl} alt={t.altRemoved} className="block max-h-[70vh] w-auto max-w-full" />
102147
</div>
103148
<div className="flex flex-wrap gap-2">
104149
<Button onClick={download}>
105150
<Download className="h-4 w-4" />
106-
Download PNG
151+
{t.downloadPng}
107152
</Button>
108153
<CopyImageButton blob={result} />
109154
<EditInAnnotatorButton blob={result} filename={(srcName.replace(/\.[^.]+$/, '') || 'image') + '-no-bg.png'} />

src/islands/image/CameraCapture.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,31 @@ import { Button } from '@/components/ui/Button';
33
import { Alert } from '@/components/ui/Alert';
44
import { useCamera } from '@/hooks/useCamera';
55
import { frameToFile } from '@/tools/image/camera.lib';
6+
import type { Lang } from '@/i18n/config';
7+
8+
const TR: Record<Lang, {
9+
useDeviceCamera: string; cancel: string; capturing: string; capture: string; switchCamera: string;
10+
}> = {
11+
en: {
12+
useDeviceCamera: 'Use device camera', cancel: 'Cancel', capturing: 'Capturing…',
13+
capture: 'Capture', switchCamera: 'Switch camera',
14+
},
15+
id: {
16+
useDeviceCamera: 'Gunakan kamera perangkat', cancel: 'Batal', capturing: 'Mengambil…',
17+
capture: 'Ambil', switchCamera: 'Ganti kamera',
18+
},
19+
};
620

721
export default function CameraCapture({
822
onCapture,
923
onCancel,
24+
lang = 'en',
1025
}: {
1126
onCapture: (file: File) => void;
1227
onCancel: () => void;
28+
lang?: Lang;
1329
}) {
30+
const t = TR[lang] ?? TR.en;
1431
const { videoRef, stream, error, hasMultiple, start, stop, switchCamera } = useCamera();
1532
const fileInputRef = useRef<HTMLInputElement>(null);
1633
const [busy, setBusy] = useState(false);
@@ -50,19 +67,19 @@ export default function CameraCapture({
5067
<div className="space-y-2">
5168
<Alert variant="error">{error.message}</Alert>
5269
<div className="flex flex-wrap gap-2">
53-
<Button variant="secondary" onClick={useDeviceCamera}>Use device camera</Button>
54-
<Button variant="ghost" onClick={cancel}>Cancel</Button>
70+
<Button variant="secondary" onClick={useDeviceCamera}>{t.useDeviceCamera}</Button>
71+
<Button variant="ghost" onClick={cancel}>{t.cancel}</Button>
5572
</div>
5673
</div>
5774
) : (
5875
<div className="space-y-3">
5976
<video ref={videoRef} playsInline muted className="max-h-96 w-auto border-2 border-border" />
6077
<div className="flex flex-wrap gap-2">
61-
<Button onClick={capture} disabled={busy || !stream}>{busy ? 'Capturing…' : 'Capture'}</Button>
62-
{hasMultiple && <Button variant="secondary" onClick={switchCamera}>Switch camera</Button>}
78+
<Button onClick={capture} disabled={busy || !stream}>{busy ? t.capturing : t.capture}</Button>
79+
{hasMultiple && <Button variant="secondary" onClick={switchCamera}>{t.switchCamera}</Button>}
6380
{/* Always available — opens the OS camera app on phones. */}
64-
<Button variant="secondary" onClick={useDeviceCamera}>Use device camera</Button>
65-
<Button variant="ghost" onClick={cancel}>Cancel</Button>
81+
<Button variant="secondary" onClick={useDeviceCamera}>{t.useDeviceCamera}</Button>
82+
<Button variant="ghost" onClick={cancel}>{t.cancel}</Button>
6683
</div>
6784
</div>
6885
)}

src/islands/image/CameraTool.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { useState } from 'react';
2+
import type { Lang } from '@/i18n/config';
23
import { Button } from '@/components/ui/Button';
34
import { ImageResult } from '@/components/ui/ImageResult';
45
import CameraCapture from './CameraCapture';
56

6-
export default function CameraTool() {
7+
const TR: Record<Lang, { openCamera: string; retake: string }> = {
8+
en: { openCamera: 'Open camera', retake: 'Retake' },
9+
id: { openCamera: 'Buka kamera', retake: 'Ambil ulang' },
10+
};
11+
12+
export default function CameraTool({ lang = 'en' }: { lang?: Lang }) {
13+
const t = TR[lang] ?? TR.en;
714
const [photo, setPhoto] = useState<File | null>(null);
815
const [capturing, setCapturing] = useState(true);
916

@@ -13,20 +20,21 @@ export default function CameraTool() {
1320
<div className="space-y-4">
1421
{capturing && (
1522
<CameraCapture
23+
lang={lang}
1624
onCapture={(file) => { setPhoto(file); setCapturing(false); }}
1725
onCancel={() => setCapturing(false)}
1826
/>
1927
)}
2028

2129
{!capturing && !photo && (
22-
<Button onClick={() => setCapturing(true)}>Open camera</Button>
30+
<Button onClick={() => setCapturing(true)}>{t.openCamera}</Button>
2331
)}
2432

2533
{photo && (
2634
<div className="space-y-2">
2735
{/* ImageResult already renders Download / Copy image / Edit in Annotator. */}
2836
<ImageResult blob={photo} filename={photo.name} />
29-
<Button variant="secondary" onClick={retake}>Retake</Button>
37+
<Button variant="secondary" onClick={retake}>{t.retake}</Button>
3038
</div>
3139
)}
3240
</div>

src/islands/image/FaceBlur.tsx

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useState, type ReactNode } from 'react';
22
import { Download } from 'lucide-react';
33
import { Dropzone } from '@/components/ui/Dropzone';
44
import { Button } from '@/components/ui/Button';
@@ -9,6 +9,7 @@ import { downloadService } from '@/services/download';
99
import { formatBytes } from '@/tools/image/canvas.lib';
1010
import { expandBox, type Box } from '@/tools/image/face-blur.lib';
1111
import { usePasteImage } from '@/hooks/usePasteImage';
12+
import type { Lang } from '@/i18n/config';
1213

1314
type Effect = 'blur' | 'pixelate' | 'solid';
1415

@@ -18,6 +19,46 @@ const EFFECTS: { key: Effect; label: string }[] = [
1819
{ key: 'solid', label: 'Solid' },
1920
];
2021

22+
const TR: Record<Lang, {
23+
dropTitle: string; dropDesc: string; effect: string; effects: Record<Effect, string>;
24+
privacy: ReactNode; loadingDetector: string; detecting: string; working: string;
25+
errProcess: string; noFaces: string; result: string; facesHidden: (n: number) => string;
26+
altBlurred: string; downloadPng: string;
27+
}> = {
28+
en: {
29+
dropTitle: 'Drop an image or click to browse',
30+
dropDesc: 'Detects and hides faces with on-device AI · or paste (⌘V)',
31+
effect: 'Effect',
32+
effects: { blur: 'Blur', pixelate: 'Pixelate', solid: 'Solid' },
33+
privacy: <>Runs entirely in your browser — the image never leaves your device. Detection is automatic; for privacy-critical images, prefer <strong>Solid</strong> (blur/pixelate can be partly reversed). The first run downloads a small model, then it's cached.</>,
34+
loadingDetector: 'Loading face detector…',
35+
detecting: 'Detecting faces…',
36+
working: 'Working…',
37+
errProcess: 'Could not process this image.',
38+
noFaces: 'No faces detected in this image.',
39+
result: 'Result',
40+
facesHidden: (n) => `${n} face${n === 1 ? '' : 's'} hidden`,
41+
altBlurred: 'Faces blurred',
42+
downloadPng: 'Download PNG',
43+
},
44+
id: {
45+
dropTitle: 'Letakkan gambar atau klik untuk memilih',
46+
dropDesc: 'Mendeteksi dan menyembunyikan wajah dengan AI di perangkat · atau tempel (⌘V)',
47+
effect: 'Efek',
48+
effects: { blur: 'Blur', pixelate: 'Piksel', solid: 'Blok' },
49+
privacy: <>Berjalan sepenuhnya di browser Anda — gambar tidak pernah meninggalkan perangkat Anda. Deteksi berjalan otomatis; untuk gambar yang sangat sensitif, pilih <strong>Blok</strong> (blur/piksel bisa sebagian dipulihkan). Penggunaan pertama mengunduh model kecil, lalu di-cache.</>,
50+
loadingDetector: 'Memuat pendeteksi wajah…',
51+
detecting: 'Mendeteksi wajah…',
52+
working: 'Memproses…',
53+
errProcess: 'Tidak dapat memproses gambar ini.',
54+
noFaces: 'Tidak ada wajah terdeteksi pada gambar ini.',
55+
result: 'Hasil',
56+
facesHidden: (n) => `${n} wajah disembunyikan`,
57+
altBlurred: 'Wajah diburamkan',
58+
downloadPng: 'Unduh PNG',
59+
},
60+
};
61+
2162
// Cache the detector across runs so the model loads once.
2263
let detectorPromise: Promise<{ detect: (img: ImageBitmap) => { detections: { boundingBox?: { originX: number; originY: number; width: number; height: number } }[] } }> | null = null;
2364

@@ -68,7 +109,8 @@ function obscure(ctx: CanvasRenderingContext2D, bmp: ImageBitmap, box: Box, effe
68109
ctx.restore();
69110
}
70111

71-
export default function FaceBlur() {
112+
export default function FaceBlur({ lang = 'en' }: { lang?: Lang }) {
113+
const t = TR[lang] ?? TR.en;
72114
const [effect, setEffect] = useState<Effect>('blur');
73115
const [srcName, setSrcName] = useState('');
74116
const [result, setResult] = useState<Blob | null>(null);
@@ -88,10 +130,10 @@ export default function FaceBlur() {
88130
setError('');
89131
setBusy(true);
90132
try {
91-
setStage('Loading face detector…');
133+
setStage(t.loadingDetector);
92134
const detector = await getDetector();
93135
const bmp = await createImageBitmap(file);
94-
setStage('Detecting faces…');
136+
setStage(t.detecting);
95137
const { detections } = detector.detect(bmp);
96138

97139
const canvas = document.createElement('canvas');
@@ -123,7 +165,7 @@ export default function FaceBlur() {
123165
return URL.createObjectURL(blob);
124166
});
125167
} catch (e) {
126-
setError(e instanceof Error ? e.message : 'Could not process this image.');
168+
setError(e instanceof Error ? e.message : t.errProcess);
127169
} finally {
128170
setBusy(false);
129171
setStage('');
@@ -153,15 +195,15 @@ export default function FaceBlur() {
153195
<div className="space-y-4">
154196
<Dropzone onDrop={onDrop} accept="image/*" multiple={false}>
155197
<div className="space-y-1">
156-
<p className="text-lg font-bold">Drop an image or click to browse</p>
198+
<p className="text-lg font-bold">{t.dropTitle}</p>
157199
<p className="text-sm text-muted-foreground">
158-
Detects and hides faces with on-device AI · or paste (⌘V)
200+
{t.dropDesc}
159201
</p>
160202
</div>
161203
</Dropzone>
162204

163205
<div className="space-y-1.5">
164-
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Effect</span>
206+
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.effect}</span>
165207
<div className="flex flex-wrap gap-2">
166208
{EFFECTS.map(e => (
167209
<Button
@@ -171,37 +213,35 @@ export default function FaceBlur() {
171213
onClick={() => changeEffect(e.key)}
172214
disabled={busy}
173215
>
174-
{e.label}
216+
{t.effects[e.key]}
175217
</Button>
176218
))}
177219
</div>
178220
</div>
179221

180222
<p className="text-xs text-muted-foreground">
181-
Runs entirely in your browser — the image never leaves your device. Detection is automatic;
182-
for privacy-critical images, prefer <strong>Solid</strong> (blur/pixelate can be partly
183-
reversed). The first run downloads a small model, then it's cached.
223+
{t.privacy}
184224
</p>
185225

186-
{busy && <p className="text-sm text-muted-foreground">{stage || 'Working…'}</p>}
226+
{busy && <p className="text-sm text-muted-foreground">{stage || t.working}</p>}
187227
{error && <Alert variant="error">{error}</Alert>}
188228

189229
{faceCount === 0 && !busy && !error && (
190-
<Alert variant="error">No faces detected in this image.</Alert>
230+
<Alert variant="error">{t.noFaces}</Alert>
191231
)}
192232

193233
{result && resultUrl && !busy && faceCount !== null && faceCount > 0 && (
194234
<div className="space-y-2">
195235
<div className="flex flex-wrap items-center gap-3 text-sm">
196-
<span className="font-bold uppercase tracking-wide text-muted-foreground">Result</span>
197-
<span>{faceCount} face{faceCount === 1 ? '' : 's'} hidden</span>
236+
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.result}</span>
237+
<span>{t.facesHidden(faceCount)}</span>
198238
<span className="font-mono text-muted-foreground">{formatBytes(result.size)}</span>
199239
</div>
200-
<img src={resultUrl} alt="Faces blurred" className="block max-h-[70vh] w-auto max-w-full border-2 border-border" />
240+
<img src={resultUrl} alt={t.altBlurred} className="block max-h-[70vh] w-auto max-w-full border-2 border-border" />
201241
<div className="flex flex-wrap gap-2">
202242
<Button onClick={download}>
203243
<Download className="h-4 w-4" />
204-
Download PNG
244+
{t.downloadPng}
205245
</Button>
206246
<CopyImageButton blob={result} />
207247
<EditInAnnotatorButton blob={result} filename={(srcName.replace(/\.[^.]+$/, '') || 'image') + '-blurred.png'} />

0 commit comments

Comments
 (0)