Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/islands/dev/TextEncrypt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useState } from 'react';
import { Lock, Unlock } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { encryptText, decryptText } from '@/tools/dev/textcrypt.lib';
import type { Lang } from '@/i18n/config';

type Mode = 'encrypt' | 'decrypt';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Encrypt a message with a password (AES-256) so only someone with the password can read it — then share the scrambled text safely. All in your browser; the password never leaves your device.',
encrypt: 'Encrypt', decrypt: 'Decrypt', password: 'Password',
inEnc: 'Message to encrypt', inDec: 'Encrypted text to decrypt',
outEnc: 'Encrypted text', outDec: 'Decrypted message',
run: 'Encrypt', runDec: 'Decrypt', copy: 'Copy',
needPw: 'Enter a password.', failEnc: 'Could not encrypt.', failDec: 'Wrong password or invalid text.',
placeholderEnc: 'Type a secret message…', placeholderDec: 'Paste the encrypted text…',
},
id: {
intro: 'Enkripsi pesan dengan kata sandi (AES-256) agar hanya yang punya kata sandi bisa membacanya — lalu bagikan teks teracak dengan aman. Semua di browser Anda; kata sandi tidak pernah keluar dari perangkat.',
encrypt: 'Enkripsi', decrypt: 'Dekripsi', password: 'Kata sandi',
inEnc: 'Pesan untuk dienkripsi', inDec: 'Teks terenkripsi untuk didekripsi',
outEnc: 'Teks terenkripsi', outDec: 'Pesan terdekripsi',
run: 'Enkripsi', runDec: 'Dekripsi', copy: 'Salin',
needPw: 'Masukkan kata sandi.', failEnc: 'Tidak dapat mengenkripsi.', failDec: 'Kata sandi salah atau teks tidak valid.',
placeholderEnc: 'Ketik pesan rahasia…', placeholderDec: 'Tempel teks terenkripsi…',
},
};

export default function TextEncrypt({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [mode, setMode] = useState<Mode>('encrypt');
const [text, setText] = useState('');
const [password, setPassword] = useState('');
const [out, setOut] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const run = async () => {
if (!password) { setError(t.needPw); return; }
setBusy(true); setError(''); setOut('');
try {
setOut(mode === 'encrypt' ? await encryptText(text, password) : await decryptText(text, password));
} catch {
setError(mode === 'encrypt' ? t.failEnc : t.failDec);
} finally {
setBusy(false);
}
};

const switchMode = (m: Mode) => { setMode(m); setOut(''); setError(''); };

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="flex gap-1">
<button onClick={() => switchMode('encrypt')} aria-pressed={mode === 'encrypt'}
className={`flex items-center gap-2 border-2 px-4 py-2 text-sm font-bold uppercase tracking-wide ${mode === 'encrypt' ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border'}`}>
<Lock className="h-4 w-4" />{t.encrypt}
</button>
<button onClick={() => switchMode('decrypt')} aria-pressed={mode === 'decrypt'}
className={`flex items-center gap-2 border-2 px-4 py-2 text-sm font-bold uppercase tracking-wide ${mode === 'decrypt' ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border'}`}>
<Unlock className="h-4 w-4" />{t.decrypt}
</button>
</div>

<div className="space-y-1">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{mode === 'encrypt' ? t.inEnc : t.inDec}</span>
<TextArea value={text} onChange={e => setText(e.target.value)} rows={5}
placeholder={mode === 'encrypt' ? t.placeholderEnc : t.placeholderDec} className="font-mono text-sm" />
</div>

<label className="block space-y-1">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.password}</span>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="off"
className="w-full border-2 border-border bg-muted p-2 text-sm" />
</label>

<Button onClick={run} disabled={busy || !text.trim()}>{busy ? '…' : (mode === 'encrypt' ? t.run : t.runDec)}</Button>

{error && <p className="text-sm text-red-600">{error}</p>}

{out && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{mode === 'encrypt' ? t.outEnc : t.outDec}</span>
<CopyButton value={out} label={t.copy} />
</div>
<textarea readOnly value={out} rows={5} className="w-full break-all border-2 border-border bg-muted p-2 font-mono text-sm" />
</div>
)}
</div>
);
}
110 changes: 110 additions & 0 deletions src/islands/image/ImagePalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Dropzone } from '@/components/ui/Dropzone';
import { CopyButton } from '@/components/ui/CopyButton';
import { extractPalette, type Swatch } from '@/tools/image/palette.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Extract the dominant color palette from any image — get the hex codes for design, CSS or moodboards. Runs in your browser; nothing is uploaded.',
drop: 'Drop an image or click to browse', dropSub: 'PNG, JPG, WebP, GIF…',
colors: 'Colors', count: 'Number of colors', copyAll: 'Copy all hex', copy: 'Copy', failed: 'Could not read this image.',
},
id: {
intro: 'Ekstrak palet warna dominan dari gambar apa pun — dapatkan kode hex untuk desain, CSS, atau moodboard. Berjalan di browser Anda; tidak ada yang diunggah.',
drop: 'Letakkan gambar atau klik untuk memilih', dropSub: 'PNG, JPG, WebP, GIF…',
colors: 'Warna', count: 'Jumlah warna', copyAll: 'Salin semua hex', copy: 'Salin', failed: 'Tidak dapat membaca gambar ini.',
},
};

export default function ImagePalette({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState('');
const [swatches, setSwatches] = useState<Swatch[]>([]);
const [k, setK] = useState(6);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const run = async (file: File, count: number) => {
setBusy(true); setError('');
try {
const bmp = await createImageBitmap(file);
const scale = Math.min(1, 400 / Math.max(bmp.width, bmp.height));
const w = Math.max(1, Math.round(bmp.width * scale));
const h = Math.max(1, Math.round(bmp.height * scale));
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas');
ctx.drawImage(bmp, 0, 0, w, h);
bmp.close?.();
const { data } = ctx.getImageData(0, 0, w, h);
setSwatches(extractPalette(data, count, 1));
} catch {
setError(t.failed);
} finally {
setBusy(false);
}
};

const onDrop = (files: File[]) => {
const img = files.find(f => f.type.startsWith('image/'));
if (!img) return;
setFile(img);
setPreview(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(img); });
void run(img, k);
};

const changeK = (n: number) => {
setK(n);
if (file) void run(file, n);
};

const allHex = swatches.map(s => s.hex).join(', ');

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<Dropzone onDrop={onDrop} accept="image/*" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">{t.drop}</p>
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
</div>
</Dropzone>

{error && <p className="text-sm text-red-600">{error}</p>}

{preview && (
<div className="flex flex-wrap items-start gap-4">
<img src={preview} alt="" className="max-h-48 w-auto border-2 border-border object-contain" />
<label className="text-sm">
<span className="mr-2 font-semibold">{t.count}</span>
<select value={k} onChange={e => changeK(Number(e.target.value))} className="border-2 border-border bg-muted p-1">
{[4, 6, 8, 10, 12].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</label>
</div>
)}

{swatches.length > 0 && !busy && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.colors}</span>
<CopyButton value={allHex} label={t.copyAll} />
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{swatches.map(s => (
<div key={s.hex} className="flex items-center gap-2 border-2 border-border bg-muted p-2">
<span className="h-9 w-9 shrink-0 border-2 border-border" style={{ backgroundColor: s.hex }} />
<span className="font-mono text-sm uppercase">{s.hex}</span>
<span className="ml-auto"><CopyButton value={s.hex} label={t.copy} /></span>
</div>
))}
</div>
</div>
)}
</div>
);
}
128 changes: 128 additions & 0 deletions src/islands/image/SocialResize.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { Download } from 'lucide-react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { downloadService } from '@/services/download';
import { encodeCanvas, formatBytes } from '@/tools/image/canvas.lib';
import { SOCIAL_PRESETS, fitRect, type FitMode } from '@/tools/image/social-resize.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Resize an image to exact social-media dimensions — Instagram, TikTok, YouTube, Facebook, X and LinkedIn. Done in your browser, nothing uploaded.',
drop: 'Drop an image or click to browse', dropSub: 'It will be resized to the size you pick',
preset: 'Size', fit: 'Fit', cover: 'Crop to fill', contain: 'Fit (with background)', bg: 'Background',
download: 'Download', failed: 'Could not process this image.', result: 'Result',
},
id: {
intro: 'Ubah ukuran gambar ke dimensi media sosial yang tepat — Instagram, TikTok, YouTube, Facebook, X, dan LinkedIn. Dilakukan di browser Anda, tanpa unggah.',
drop: 'Letakkan gambar atau klik untuk memilih', dropSub: 'Akan diubah ke ukuran yang Anda pilih',
preset: 'Ukuran', fit: 'Penyesuaian', cover: 'Potong penuh', contain: 'Muat (dengan latar)', bg: 'Latar',
download: 'Unduh', failed: 'Tidak dapat memproses gambar ini.', result: 'Hasil',
},
};

export default function SocialResize({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [file, setFile] = useState<File | null>(null);
const [presetId, setPresetId] = useState(SOCIAL_PRESETS[0].id);
const [fit, setFit] = useState<FitMode>('cover');
const [bg, setBg] = useState('#ffffff');
const [result, setResult] = useState<Blob | null>(null);
const [resultUrl, setResultUrl] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const preset = SOCIAL_PRESETS.find(p => p.id === presetId) ?? SOCIAL_PRESETS[0];

useEffect(() => () => { if (resultUrl) URL.revokeObjectURL(resultUrl); }, [resultUrl]);

const run = async (f: File, dstW: number, dstH: number, mode: FitMode, background: string) => {
setBusy(true); setError('');
try {
const bmp = await createImageBitmap(f);
const canvas = document.createElement('canvas');
canvas.width = dstW; canvas.height = dstH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas');
if (mode === 'contain') { ctx.fillStyle = background; ctx.fillRect(0, 0, dstW, dstH); }
const r = fitRect(bmp.width, bmp.height, dstW, dstH, mode);
ctx.drawImage(bmp, r.sx, r.sy, r.sw, r.sh, r.dx, r.dy, r.dw, r.dh);
bmp.close?.();
const blob = await encodeCanvas(canvas, 'image/png');
setResult(blob);
setResultUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(blob); });
} catch {
setError(t.failed);
} finally {
setBusy(false);
}
};

const rerun = (f = file, id = presetId, mode = fit, background = bg) => {
const p = SOCIAL_PRESETS.find(x => x.id === id) ?? SOCIAL_PRESETS[0];
if (f) void run(f, p.w, p.h, mode, background);
};

const onDrop = (files: File[]) => {
const img = files.find(f => f.type.startsWith('image/'));
if (!img) return;
setFile(img);
rerun(img, presetId, fit, bg);
};

const download = () => {
if (!result || !file) return;
downloadService.download(result, file.name.replace(/\.[^.]+$/, '') + `-${preset.w}x${preset.h}.png`);
};

const input = 'h-9 border-2 border-border bg-muted px-2 text-sm';

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<Dropzone onDrop={onDrop} accept="image/*" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">{t.drop}</p>
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
</div>
</Dropzone>

<div className="flex flex-wrap items-end gap-3 text-sm">
<label className="flex flex-col gap-1">
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.preset}</span>
<select value={presetId} onChange={e => { setPresetId(e.target.value); rerun(file, e.target.value, fit, bg); }} className={input}>
{SOCIAL_PRESETS.map(p => <option key={p.id} value={p.id}>{p.label} — {p.w}×{p.h}</option>)}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.fit}</span>
<select value={fit} onChange={e => { const m = e.target.value as FitMode; setFit(m); rerun(file, presetId, m, bg); }} className={input}>
<option value="cover">{t.cover}</option>
<option value="contain">{t.contain}</option>
</select>
</label>
{fit === 'contain' && (
<label className="flex flex-col gap-1">
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.bg}</span>
<input type="color" value={bg} onChange={e => { setBg(e.target.value); rerun(file, presetId, fit, e.target.value); }} className="h-9 w-14 border-2 border-border" />
</label>
)}
</div>

{error && <p className="text-sm text-red-600">{error}</p>}

{result && resultUrl && !busy && (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.result}</span>
<span className="font-mono text-muted-foreground">{preset.w}×{preset.h} · {formatBytes(result.size)}</span>
</div>
<img src={resultUrl} alt="" className="max-h-[60vh] w-auto max-w-full border-2 border-border" />
<Button onClick={download}><Download className="h-4 w-4" />{t.download}</Button>
</div>
)}
</div>
);
}
Loading
Loading