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
2 changes: 2 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export default defineConfig({
'**/html2canvas*.js',
'**/heic-to*.js',
'**/libheif*.js',
'**/terser*.js',
'**/csso*.js',
'og/*.png',
],
runtimeCaching: [
Expand Down
36 changes: 36 additions & 0 deletions docs/superpowers/plans/2026-08-14-tools-batch-7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Seven-Tool Batch — Spec + Plan

**Date:** 2026-08-14. One branch (`feat/tools-batch-7`), one PR → develop, bundled prod promotion.

All client-side. Each tool: pure lib (+ Vitest) where there's real logic, thin island, registry entry, **EN + ID SEO with howTo**. Bahasa uses "tool" loanword.

## Global Constraints
- Commit under personal noreply identity; no AI-attribution trailers; no absolute machine paths.
- Heavy deps dynamic-imported in the lib + chunk added to `workbox.globIgnores`.
- New tools `status: 'beta'`.

## Tools

### 1. CIDR Calculator (`cidr-calculator`, Dev, icon `Network`)
Pure lib `src/tools/dev/cidr.lib.ts`: `parseCidr('192.168.1.0/24')` → network, broadcast, netmask, wildcard, first/last host, host count, /prefix. IPv4. Unit-tested (parse, edge /31 /32, invalid). Island: input → summary rows + Copy.

### 2. Hash Text (`hash-text`, Dev, icon `Hash`)
Lib `src/tools/dev/hash-text.lib.ts`: `hashText(text, algo)` via `hash-wasm` one-shot (md5/sha1/sha256/sha512/crc32), `ALGORITHMS` list. Dynamic-import hash-wasm. Tested with a known vector (`sha256('abc')`). Island: textarea → all-algos or selected → rows + Copy.

### 3. SRT/VTT Editor (`subtitle-editor`, Media, icon `Subtitles`)
Lib `src/tools/media/subtitle.lib.ts`: `parseSubtitles(text)` (auto-detect SRT vs VTT) → `Cue[] = {index,start,end,text}`; `toSrt(cues)`, `toVtt(cues)`, `formatTimestamp`. Unit-tested (round-trip, cross-convert, malformed tolerance). Island: paste/upload → editable cue list + convert + download .srt/.vtt.

### 4. HTML/CSS/JS Minifier (`minifier`, Dev, icon `Minimize2`)
Lib `src/tools/dev/minify.lib.ts`: `minifyCss(css)` (csso, dynamic), `minifyJs(js)` (terser, dynamic, async), `minifyHtml(html)` (hand-rolled: strip comments, collapse inter-tag whitespace, preserve `<pre>/<textarea>/<script>/<style>`). HTML minifier unit-tested (pure). Add `**/terser*.js`, `**/csso*.js` to globIgnores. Island: language tabs (HTML/CSS/JS) → minify → output + size delta + Copy/Download.

### 5. Voice Recorder (`voice-recorder`, Media, icon `Mic`)
Island `src/islands/media/VoiceRecorder.tsx` consumes existing `useAudioRecorder` hook: record → timer → `<audio controls>` playback (with the `duration=Infinity` seek fix) → download `recording.webm`. No new lib (hook already tested). Permission errors via `Alert`.

### 6. Extract Images from PDF (`pdf-extract-images`, PDF, icon `FileImage`)
Lib `src/tools/pdf/extract-images.lib.ts`: via `pdfjs-dist` — `getOperatorList` per page, collect `OPS.paintImageXObject` names, resolve `page.objs.get(name)` → draw to canvas → PNG blob; dedupe by name. Returns `{name, blob, width, height}[]`. Dynamic-import pdfjs (already globIgnored region). Island: Dropzone(pdf) → grid of images, per-image download + ZIP-all (`downloadService.downloadZip`). Pure helpers (dedupe/name) unit-tested; pdfjs path smoke-only.

### 7. PPTX Viewer (`pptx-viewer`, Documents, icon `Presentation`)
Lib `src/tools/documents/pptx.lib.ts` mirroring `odt.lib`: `fflate.unzipSync` → read `ppt/slides/slideN.xml` (ordered via `ppt/presentation.xml` / slide rels), parse with `DOMParser`, extract text runs (`a:t`) + basic structure into a safe HTML string per slide (escaped); map `ppt/media/*` for images (best-effort). Honest fidelity: text + images + layout order, NOT pixel-perfect rendering. Unit-tested (parse a synthetic minimal pptx built with fflate `zipSync`). Island mirrors `OdtViewer`: dynamic-import lib, render slides via `dangerouslySetInnerHTML` into styled slide frames, slide nav, Print.

## Verify + Ship
Per tool: failing test → implement → pass. Then full `vitest + lint + build` green; both `/tools/<id>/` and `/id/` built for all 7. One PR → develop → merge → promote develop→main (bundles PR #206 + these 7) → verify each live URL.
47 changes: 38 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"astro": "^4.16.19",
"cmdk": "^0.2.1",
"comlink": "^4.4.2",
"csso": "^5.0.5",
"docx": "^9.7.1",
"docx-preview": "^0.4.0",
"dompurify": "^3.4.12",
Expand Down Expand Up @@ -110,6 +111,7 @@
"smol-toml": "^1.7.0",
"sql-formatter": "^15.8.2",
"tailwindcss": "^3.4.19",
"terser": "^5.50.0",
"turndown": "^7.2.4",
"upscaler": "^1.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
Expand Down
97 changes: 97 additions & 0 deletions src/islands/dev/CidrCalculator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { CopyButton } from '@/components/ui/CopyButton';
import { parseCidr, type CidrInfo } from '@/tools/dev/cidr.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string;
label: string;
invalid: string;
rows: Record<keyof Omit<CidrInfo, 'prefix'>, string>;
hosts: string;
}> = {
en: {
intro: 'Enter an IPv4 address with a CIDR prefix (e.g. 192.168.1.0/24) to see the network, broadcast, mask, host range and count. Everything is computed in your browser.',
label: 'IPv4 address / CIDR',
invalid: 'Enter a valid IPv4 address and prefix, e.g. 10.0.0.0/24.',
rows: {
address: 'Address', netmask: 'Netmask', wildcard: 'Wildcard mask', network: 'Network address',
broadcast: 'Broadcast address', firstHost: 'First usable host', lastHost: 'Last usable host',
totalHosts: 'Total addresses', usableHosts: 'Usable hosts', ipClass: 'Class',
},
hosts: 'hosts',
},
id: {
intro: 'Masukkan alamat IPv4 dengan prefix CIDR (mis. 192.168.1.0/24) untuk melihat network, broadcast, mask, rentang host, dan jumlahnya. Semua dihitung di browser Anda.',
label: 'Alamat IPv4 / CIDR',
invalid: 'Masukkan alamat IPv4 dan prefix yang valid, mis. 10.0.0.0/24.',
rows: {
address: 'Alamat', netmask: 'Netmask', wildcard: 'Wildcard mask', network: 'Alamat network',
broadcast: 'Alamat broadcast', firstHost: 'Host pertama', lastHost: 'Host terakhir',
totalHosts: 'Total alamat', usableHosts: 'Host tersedia', ipClass: 'Kelas',
},
hosts: 'host',
},
};

const ORDER: (keyof Omit<CidrInfo, 'prefix'>)[] = [
'address', 'network', 'broadcast', 'netmask', 'wildcard',
'firstHost', 'lastHost', 'usableHosts', 'totalHosts', 'ipClass',
];

export default function CidrCalculator({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [value, setValue] = useState('192.168.1.0/24');

const parsed = useMemo(() => {
try {
return { info: parseCidr(value) };
} catch {
return { error: true as const };
}
}, [value]);

const info = 'info' in parsed ? parsed.info : undefined;
const copyText = info
? ORDER.map(k => `${t.rows[k]}: ${info[k]}`).join('\n')
: '';

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

<label className="block space-y-1">
<span className="block text-sm font-semibold">{t.label}</span>
<input
value={value}
onChange={e => setValue(e.target.value)}
spellCheck={false}
className="w-full border-2 border-border bg-muted p-3 font-mono text-sm"
placeholder="192.168.1.0/24"
/>
</label>

{value.trim() && !info && <Alert variant="error">{t.invalid}</Alert>}

{info && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">/{info.prefix}</span>
<CopyButton value={copyText} />
</div>
<div className="divide-y divide-border border-2 border-border">
{ORDER.map(k => (
<div key={k} className="flex flex-wrap items-baseline gap-x-3 px-3 py-2 text-sm">
<span className="w-40 shrink-0 font-medium text-muted-foreground">{t.rows[k]}</span>
<span className="break-all font-mono">
{info[k]}{(k === 'totalHosts' || k === 'usableHosts') ? ` ${t.hosts}` : ''}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
68 changes: 68 additions & 0 deletions src/islands/dev/HashText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { HASH_ALGOS, hashAll, type HashAlgo } from '@/tools/dev/hash-text.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, { intro: string; input: string; placeholder: string; empty: string; uppercase: string }> = {
en: {
intro: 'Generate MD5, SHA-1, SHA-256, SHA-512 and CRC32 hashes of any text, instantly and entirely in your browser — nothing is uploaded.',
input: 'Text to hash',
placeholder: 'Type or paste text…',
empty: 'Enter some text to see its hashes.',
uppercase: 'Uppercase',
},
id: {
intro: 'Buat hash MD5, SHA-1, SHA-256, SHA-512, dan CRC32 dari teks apa pun, seketika dan sepenuhnya di browser Anda — tidak ada yang diunggah.',
input: 'Teks untuk di-hash',
placeholder: 'Ketik atau tempel teks…',
empty: 'Masukkan teks untuk melihat hash-nya.',
uppercase: 'Huruf besar',
},
};

export default function HashText({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const [hashes, setHashes] = useState<Record<HashAlgo, string> | null>(null);
const [upper, setUpper] = useState(false);

useEffect(() => {
let cancelled = false;
if (!text) { setHashes(null); return; }
hashAll(text).then(h => { if (!cancelled) setHashes(h); });
return () => { cancelled = true; };
}, [text]);

const format = (h: string) => (upper ? h.toUpperCase() : h);

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

<div className="space-y-1">
<span className="block text-sm font-semibold">{t.input}</span>
<TextArea value={text} onChange={e => setText(e.target.value)} rows={5} placeholder={t.placeholder} />
</div>

<label className="flex items-center gap-2 text-sm font-semibold">
<input type="checkbox" checked={upper} onChange={e => setUpper(e.target.checked)} className="h-4 w-4 accent-accent" />
{t.uppercase}
</label>

{!hashes && <p className="text-sm text-muted-foreground">{t.empty}</p>}

{hashes && (
<div className="divide-y divide-border border-2 border-border">
{HASH_ALGOS.map(a => (
<div key={a.key} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
<span className="w-24 shrink-0 font-bold">{a.label}</span>
<span className="min-w-0 flex-1 break-all font-mono text-muted-foreground">{format(hashes[a.key])}</span>
<CopyButton value={format(hashes[a.key])} />
</div>
))}
</div>
)}
</div>
);
}
Loading
Loading