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
10 changes: 10 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ export default defineConfig({
manualChunks(id) {
if (id.includes('node_modules/epubjs')) return 'epubjs';
if (id.includes('node_modules/jszip')) return 'jszip';
// Prettier (Code Beautifier) is large and lazily-imported per language.
// Give every prettier chunk a stable `prettier-` prefix so it can be
// kept out of the PWA precache via globIgnores while still code-split
// per language plugin (loaded on demand, not in the island chunk).
if (id.includes('node_modules/prettier/plugins/')) {
const m = id.match(/plugins\/([a-z0-9]+)/i);
return m ? `prettier-${m[1]}` : 'prettier-plugin';
}
if (id.includes('node_modules/prettier/')) return 'prettier-standalone';
},
},
},
Expand Down Expand Up @@ -142,6 +151,7 @@ export default defineConfig({
'**/terser*.js',
'**/csso*.js',
'**/zxing*.js',
'**/prettier-*.js',
'og/*.png',
],
runtimeCaching: [
Expand Down
69 changes: 69 additions & 0 deletions src/islands/dev/CodeBeautify.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { beautify, BEAUTIFY_LANGS, type BeautifyLang } from '@/tools/dev/code-beautify.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Beautify and format code — JavaScript, TypeScript, CSS, HTML, JSON, Markdown and YAML — with Prettier, entirely in your browser. Nothing is uploaded.',
language: 'Language', input: 'Input', output: 'Output', run: 'Beautify', copy: 'Copy',
placeholder: 'Paste your code…', failed: 'Could not format — check the syntax.',
},
id: {
intro: 'Percantik dan format kode — JavaScript, TypeScript, CSS, HTML, JSON, Markdown, dan YAML — dengan Prettier, sepenuhnya di browser Anda. Tidak ada yang diunggah.',
language: 'Bahasa', input: 'Masukan', output: 'Keluaran', run: 'Percantik', copy: 'Salin',
placeholder: 'Tempel kode Anda…', failed: 'Tidak dapat memformat — periksa sintaksnya.',
},
};

export default function CodeBeautify({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [codeLang, setCodeLang] = useState<BeautifyLang>('js');
const [src, setSrc] = useState('const greet=(name)=>{return "hi "+name}\nconsole.log(greet("world"))');
const [out, setOut] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const run = async () => {
setBusy(true); setError(''); setOut('');
try {
setOut(await beautify(src, codeLang));
} catch {
setError(t.failed);
} finally {
setBusy(false);
}
};

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

<label className="flex items-center gap-2 text-sm">
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.language}</span>
<select value={codeLang} onChange={e => setCodeLang(e.target.value as BeautifyLang)} className="h-9 border-2 border-border bg-muted px-2">
{BEAUTIFY_LANGS.map(l => <option key={l.id} value={l.id}>{l.label}</option>)}
</select>
</label>

<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-1">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.input}</span>
<TextArea value={src} onChange={e => setSrc(e.target.value)} rows={14} placeholder={t.placeholder} className="font-mono text-sm" />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.output}</span>
<CopyButton value={out} label={t.copy} disabled={!out} />
</div>
<textarea readOnly value={out} rows={14} className="w-full border-2 border-border bg-muted p-2 font-mono text-sm" />
</div>
</div>

{error && <p className="text-sm text-red-600">{error}</p>}
<Button onClick={run} disabled={busy || !src.trim()}>{busy ? '…' : t.run}</Button>
</div>
);
}
60 changes: 60 additions & 0 deletions src/islands/dev/NpwpTool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { analyzeNpwp } from '@/tools/dev/npwp.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Validate and format an Indonesian NPWP (taxpayer ID). Type the number and it checks the length and formats a 15-digit NPWP. Runs in your browser.',
label: 'NPWP number', formatted: 'Formatted', type: 'Type',
valid: 'Valid', invalid: 'Invalid — an NPWP has 15 digits (legacy) or 16 (NIK-based).',
legacy: 'Legacy 15-digit NPWP', nik: '16-digit (NIK-based) NPWP',
},
id: {
intro: 'Validasi dan format NPWP (Nomor Pokok Wajib Pajak) Indonesia. Ketik nomornya dan tool memeriksa panjang serta memformat NPWP 15 digit. Berjalan di browser Anda.',
label: 'Nomor NPWP', formatted: 'Terformat', type: 'Jenis',
valid: 'Valid', invalid: 'Tidak valid — NPWP terdiri dari 15 digit (lama) atau 16 (berbasis NIK).',
legacy: 'NPWP lama 15 digit', nik: 'NPWP 16 digit (berbasis NIK)',
},
};

export default function NpwpTool({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [value, setValue] = useState('09.254.294.3-407.000');
const info = analyzeNpwp(value);

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

<label className="block space-y-1">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.label}</span>
<input value={value} onChange={e => setValue(e.target.value)} inputMode="numeric"
className="w-full border-2 border-border bg-muted p-2 font-mono text-lg tabular-nums" />
</label>

{value.trim() && (
info.valid ? (
<div className="space-y-3 border-2 border-border bg-lime-200 p-4 text-black shadow-brutal dark:bg-lime-900/40 dark:text-white">
<div className="text-sm font-bold uppercase tracking-wide">✓ {t.valid} · {info.kind === 'legacy-15' ? t.legacy : t.nik}</div>
<div className="flex flex-wrap items-center gap-3">
<div>
<div className="text-[11px] font-bold uppercase tracking-wide opacity-80">{t.formatted}</div>
<div className="font-mono text-2xl font-black tabular-nums">{info.formatted}</div>
</div>
<CopyButton value={info.formatted} label="Copy" />
</div>
{info.taxpayerType && (
<div>
<div className="text-[11px] font-bold uppercase tracking-wide opacity-80">{t.type}</div>
<div className="text-sm">{info.taxpayerType}</div>
</div>
)}
</div>
) : (
<div className="border-2 border-border bg-yellow-300 p-3 text-sm font-medium text-black shadow-brutal-sm">{t.invalid}</div>
)
)}
</div>
);
}
96 changes: 96 additions & 0 deletions src/islands/dev/VcardCsv.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { ArrowLeftRight, Download } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { Dropzone } from '@/components/ui/Dropzone';
import { downloadService } from '@/services/download';
import { parseVcards, buildVcards, contactsToCsv, csvToContacts } from '@/tools/dev/vcard.lib';
import type { Lang } from '@/i18n/config';

type Dir = 'v2c' | 'c2v';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Convert contacts between vCard (.vcf) and CSV — export your phone contacts to a spreadsheet, or build a .vcf to import. Runs in your browser; nothing is uploaded.',
v2c: 'vCard → CSV', c2v: 'CSV → vCard', swap: 'Swap', drop: 'Drop a .vcf/.csv file or paste below',
input: 'Input', output: 'Output', convert: 'Convert', copy: 'Copy', download: 'Download', count: (n: number) => `${n} contact${n === 1 ? '' : 's'}`,
},
id: {
intro: 'Konversi kontak antara vCard (.vcf) dan CSV — ekspor kontak ponsel ke spreadsheet, atau buat .vcf untuk diimpor. Berjalan di browser Anda; tidak ada yang diunggah.',
v2c: 'vCard → CSV', c2v: 'CSV → vCard', swap: 'Tukar', drop: 'Letakkan file .vcf/.csv atau tempel di bawah',
input: 'Masukan', output: 'Keluaran', convert: 'Konversi', copy: 'Salin', download: 'Unduh', count: (n: number) => `${n} kontak`,
},
};

export default function VcardCsv({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [dir, setDir] = useState<Dir>('v2c');
const [src, setSrc] = useState('');
const [out, setOut] = useState('');
const [count, setCount] = useState(0);

const convert = () => {
if (dir === 'v2c') {
const contacts = parseVcards(src);
setOut(contactsToCsv(contacts));
setCount(contacts.length);
} else {
const contacts = csvToContacts(src);
setOut(buildVcards(contacts));
setCount(contacts.length);
}
};

const swap = () => { setDir(d => (d === 'v2c' ? 'c2v' : 'v2c')); setSrc(out || src); setOut(''); setCount(0); };

const onDrop = async (files: File[]) => {
const f = files[0];
if (!f) return;
setSrc(await f.text());
setOut('');
};

const download = () => {
if (!out) return;
const name = dir === 'v2c' ? 'contacts.csv' : 'contacts.vcf';
const mime = dir === 'v2c' ? 'text/csv' : 'text/vcard';
downloadService.download(new Blob([out], { type: mime }), name);
};

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

<div className="flex flex-wrap items-center gap-2">
<span className="border-2 border-border bg-accent px-3 py-1.5 text-sm font-bold uppercase tracking-wide text-accent-foreground shadow-brutal-sm">
{dir === 'v2c' ? t.v2c : t.c2v}
</span>
<Button variant="ghost" onClick={swap}><ArrowLeftRight className="h-4 w-4" />{t.swap}</Button>
</div>

<Dropzone onDrop={onDrop} accept=".vcf,.csv,text/vcard,text/csv" multiple={false}>
<p className="text-sm font-bold">{t.drop}</p>
</Dropzone>

<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-1">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.input}</span>
<TextArea value={src} onChange={e => setSrc(e.target.value)} rows={10} className="font-mono text-xs" />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.output}{count > 0 && ` · ${t.count(count)}`}</span>
<CopyButton value={out} label={t.copy} disabled={!out} />
</div>
<textarea readOnly value={out} rows={10} className="w-full border-2 border-border bg-muted p-2 font-mono text-xs" />
</div>
</div>

<div className="flex flex-wrap gap-2">
<Button onClick={convert} disabled={!src.trim()}>{t.convert}</Button>
<Button variant="secondary" onClick={download} disabled={!out}><Download className="h-4 w-4" />{t.download}</Button>
</div>
</div>
);
}
134 changes: 134 additions & 0 deletions src/islands/image/PhotoCollage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 { gridLayout } from '@/tools/image/collage.lib';
import { fitRect } from '@/tools/image/social-resize.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Combine several photos into one collage on a grid — pick the columns, gap and background, then download. Made in your browser; nothing is uploaded.',
drop: 'Drop photos or click to browse', dropSub: 'Add 2 or more images',
cols: 'Columns', gap: 'Gap', bg: 'Background', size: 'Output width', make: 'Make collage', making: 'Building…',
download: 'Download', result: 'Collage', clear: 'Clear', failed: 'Could not build the collage.',
},
id: {
intro: 'Gabungkan beberapa foto menjadi satu kolase pada grid — pilih kolom, jarak, dan latar, lalu unduh. Dibuat di browser Anda; tidak ada yang diunggah.',
drop: 'Letakkan foto atau klik untuk memilih', dropSub: 'Tambahkan 2 gambar atau lebih',
cols: 'Kolom', gap: 'Jarak', bg: 'Latar', size: 'Lebar output', make: 'Buat kolase', making: 'Membuat…',
download: 'Unduh', result: 'Kolase', clear: 'Bersihkan', failed: 'Tidak dapat membuat kolase.',
},
};

export default function PhotoCollage({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [files, setFiles] = useState<File[]>([]);
const [cols, setCols] = useState(2);
const [gap, setGap] = useState(16);
const [bg, setBg] = useState('#ffffff');
const [width, setWidth] = useState(1080);
const [result, setResult] = useState<Blob | null>(null);
const [resultUrl, setResultUrl] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

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

const onDrop = (dropped: File[]) => {
const imgs = dropped.filter(f => f.type.startsWith('image/'));
if (imgs.length === 0) return;
setFiles(prev => [...prev, ...imgs]);
setResult(null);
};

const make = async () => {
if (files.length === 0) return;
setBusy(true); setError('');
try {
const bitmaps = await Promise.all(files.map(f => createImageBitmap(f)));
const rows = Math.ceil(bitmaps.length / cols);
// Derive a square-ish cell from the first image's aspect for the height.
const cellW = (width - gap * (cols + 1)) / cols;
const avgAspect = bitmaps.reduce((s, b) => s + b.width / b.height, 0) / bitmaps.length;
const cellH = cellW / avgAspect;
const height = Math.round(cellH * rows + gap * (rows + 1));

const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, height);

const cells = gridLayout(bitmaps.length, cols, width, height, gap);
cells.forEach(cell => {
const b = bitmaps[cell.index];
const r = fitRect(b.width, b.height, cell.w, cell.h, 'cover');
ctx.drawImage(b, r.sx, r.sy, r.sw, r.sh, cell.x + r.dx, cell.y + r.dy, r.dw, r.dh);
});
bitmaps.forEach(b => b.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 download = () => { if (result) downloadService.download(result, 'collage.png'); };
const clear = () => { setFiles([]); setResult(null); setError(''); };

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>
<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>

{files.length > 0 && (
<p className="text-sm text-muted-foreground">{files.length} · {files.map(f => f.name).join(', ')}</p>
)}

<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.cols}</span>
<select value={cols} onChange={e => setCols(Number(e.target.value))} className={input}>{[1, 2, 3, 4, 5].map(n => <option key={n} value={n}>{n}</option>)}</select></label>
<label className="flex flex-col gap-1"><span className="font-bold uppercase tracking-wide text-muted-foreground">{t.gap}</span>
<input type="number" min={0} max={100} value={gap} onChange={e => setGap(Math.max(0, Number(e.target.value)))} className={`${input} w-20`} /></label>
<label className="flex flex-col gap-1"><span className="font-bold uppercase tracking-wide text-muted-foreground">{t.size}</span>
<input type="number" min={200} max={4000} step={20} value={width} onChange={e => setWidth(Math.max(200, Number(e.target.value)))} className={`${input} w-24`} /></label>
<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)} className="h-9 w-14 border-2 border-border" /></label>
</div>

<div className="flex flex-wrap gap-2">
<Button onClick={make} disabled={busy || files.length === 0}>{busy ? t.making : t.make}</Button>
<Button variant="ghost" onClick={clear}>{t.clear}</Button>
</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">{formatBytes(result.size)}</span>
</div>
<img src={resultUrl} alt="" className="max-h-[70vh] 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