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
20 changes: 20 additions & 0 deletions docs/superpowers/plans/2026-08-14-pdf-favicon-sign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Sign PDF + Organize PDF + Favicon Generator — Plan

**Date:** 2026-08-14. One branch/PR/promotion.

## Favicon Generator (Image, icon AppWindow, `favicon-generator`)
Lib `src/tools/image/favicon.lib.ts`: `buildManifest`/`htmlSnippet` (pure, tested); `generateFavicons(file, name)` → ICO (`imageToIco`) + PNGs (`processImage` per size) + manifest + snippet. Island → Dropzone → preview grid → `createZip` + download.

## Organize PDF (PDF, icon ListOrdered, `pdf-organize`)
Pure `src/tools/pdf/layout.lib.ts`: `pageNumberXY` (tested). `pdf.lib.ts` gains `organizePdf(file, order, pageNumbers?)` — mupdf-normalize → copy pages in `order` into fresh doc → optional `drawText` page numbers. Island: `openPdfRenderer` thumbnails, native HTML5 drag reorder + delete, page-number controls.

## Sign PDF (PDF, icon FileSignature, `pdf-sign`)
Pure `layout.lib.ts`: `placementToPdfRect` (top-left ratio → bottom-left rect, y-flip; tested). `pdf.lib.ts` gains `signPdf(file, pngBytes, placements)` — embedPng + drawImage per placement. Island: `signature_pad` draw or PNG upload; drag-to-place over `openPdfRenderer` page; multi-page placements → `signPdf`.

## Notes
- pdf.lib gotcha honored: copy pages into a fresh `PDFDocument.create()` (never draw on the mupdf-loaded doc).
- No new DnD dep — native HTML5 drag for reorder.
- EN + ID SEO for all three (Sign PDF FAQ flags e-signature legality caveat).

## DoD
libs unit-tested · EN+ID SEO · vitest+lint+build green · 6 new routes · develop→main→live verified.
21 changes: 21 additions & 0 deletions docs/superpowers/plans/2026-08-14-text-toolkit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Basic Text Toolkit — Plan

**Date:** 2026-08-14 · Category: Dev · Three focused tools sharing one lib.

Rationale: separate keyword-targeted pages ("word counter" alone is millions of searches/mo) beat one combined page that can only rank for one term.

## Shared lib `src/tools/dev/text.lib.ts` (pure, unit-tested)
- `countText(text): TextStats` — characters, charactersNoSpaces, words, sentences, paragraphs, lines, readingMinutes (≈200 wpm).
- Case fns + `CASES` list: upper, lower, Title, Sentence, camel, Pascal, snake, kebab, CONSTANT (tokenizer respects camelCase + separators).
- Cleanup fns + `CLEANUP_OPS` + `cleanup(text, keys)`: trimLines, collapseSpaces, removeBlankLines, removeLineBreaks, stripHtml, removeAccents (NFD + strip ̀–ͯ), dedupeLines, sortLines.

## Tools (islands, thin)
- `word-counter` (icon Baseline) → `WordCounter.tsx`: live stats grid + textarea.
- `case-converter` (icon CaseSensitive) → `CaseConverter.tsx`: input → case buttons → output + copy.
- `text-cleanup` (icon Brush) → `TextCleanup.tsx`: op checkboxes → live cleaned output + copy/download.

## Registry + SEO
3 ToolDefs in `tools.ts`; EN + ID SEO for each in `tool-seo.ts` (title/description/intro/howTo/faqs). Bahasa "tool" loanword.

## DoD
lib unit-tested (20 cases) · EN+ID SEO · vitest+lint+build green · 6 new routes built · develop→main→live verified.
56 changes: 56 additions & 0 deletions src/islands/dev/CaseConverter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { Button } from '@/components/ui/Button';
import { CopyButton } from '@/components/ui/CopyButton';
import { CASES } from '@/tools/dev/text.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, { intro: string; input: string; output: string; placeholder: string }> = {
en: {
intro: 'Convert text between UPPERCASE, lowercase, Title Case, Sentence case, camelCase, snake_case, kebab-case and more. Runs in your browser.',
input: 'Text',
output: 'Result',
placeholder: 'Type or paste your text…',
},
id: {
intro: 'Ubah teks antara UPPERCASE, lowercase, Title Case, Sentence case, camelCase, snake_case, kebab-case, dan lainnya. Berjalan di browser Anda.',
input: 'Teks',
output: 'Hasil',
placeholder: 'Ketik atau tempel teks Anda…',
},
};

export default function CaseConverter({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const [output, setOutput] = useState('');

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={6} placeholder={t.placeholder} monospace={false} />
</div>

<div className="flex flex-wrap gap-2">
{CASES.map(c => (
<Button key={c.key} variant="secondary" onClick={() => setOutput(c.fn(text))} disabled={!text}>
{c.label}
</Button>
))}
</div>

{output && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{t.output}</span>
<CopyButton value={output} />
</div>
<TextArea value={output} readOnly rows={6} monospace={false} />
</div>
)}
</div>
);
}
74 changes: 74 additions & 0 deletions src/islands/dev/TextCleanup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useMemo, useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { DownloadTextButton } from '@/components/ui/DownloadTextButton';
import { CLEANUP_OPS, cleanup } from '@/tools/dev/text.lib';
import type { Lang } from '@/i18n/config';

const OP_LABELS: Record<Lang, Record<string, string>> = {
en: Object.fromEntries(CLEANUP_OPS.map(o => [o.key, o.label])),
id: {
trimLines: 'Rapikan tiap baris', collapseSpaces: 'Gabungkan spasi berulang',
removeBlankLines: 'Hapus baris kosong', removeLineBreaks: 'Hapus jeda baris (gabung)',
stripHtml: 'Hapus tag HTML', removeAccents: 'Hapus aksen/diakritik',
dedupeLines: 'Hapus baris duplikat', sortLines: 'Urutkan baris A→Z',
},
};

const TR: Record<Lang, { intro: string; input: string; output: string; ops: string; placeholder: string }> = {
en: {
intro: 'Clean up messy text — trim whitespace, collapse spaces, remove blank lines or line breaks, strip HTML, remove accents, dedupe and sort lines. Runs in your browser.',
input: 'Text', output: 'Cleaned', ops: 'Operations', placeholder: 'Paste messy text…',
},
id: {
intro: 'Bersihkan teks berantakan — rapikan spasi, gabungkan spasi, hapus baris kosong atau jeda baris, hapus HTML, hapus aksen, hapus duplikat, dan urutkan baris. Berjalan di browser Anda.',
input: 'Teks', output: 'Hasil bersih', ops: 'Operasi', placeholder: 'Tempel teks berantakan…',
},
};

export default function TextCleanup({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const [ops, setOps] = useState<string[]>(['trimLines', 'collapseSpaces', 'removeBlankLines']);

const output = useMemo(() => cleanup(text, ops), [text, ops]);
const labels = OP_LABELS[lang] ?? OP_LABELS.en;

const toggle = (key: string) =>
setOps(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]));

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.ops}</span>
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{CLEANUP_OPS.map(o => (
<label key={o.key} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={ops.includes(o.key)} onChange={() => toggle(o.key)} className="h-4 w-4 accent-accent" />
{labels[o.key]}
</label>
))}
</div>
</div>

<div className="grid gap-3 lg:grid-cols-2">
<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={12} placeholder={t.placeholder} monospace={false} />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{t.output}</span>
<div className="flex gap-2">
<DownloadTextButton text={output} filename="cleaned.txt" />
<CopyButton value={output} />
</div>
</div>
<TextArea value={output} readOnly rows={12} monospace={false} />
</div>
</div>
</div>
);
}
67 changes: 67 additions & 0 deletions src/islands/dev/WordCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useMemo, useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { countText } from '@/tools/dev/text.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string;
placeholder: string;
words: string;
characters: string;
charactersNoSpaces: string;
sentences: string;
paragraphs: string;
lines: string;
reading: string;
min: string;
}> = {
en: {
intro: 'Count words, characters, sentences, paragraphs and reading time as you type. Everything runs in your browser — nothing is uploaded.',
placeholder: 'Type or paste your text…',
words: 'Words', characters: 'Characters', charactersNoSpaces: 'Characters (no spaces)',
sentences: 'Sentences', paragraphs: 'Paragraphs', lines: 'Lines', reading: 'Reading time', min: 'min',
},
id: {
intro: 'Hitung kata, karakter, kalimat, paragraf, dan waktu baca saat Anda mengetik. Semuanya berjalan di browser Anda — tidak ada yang diunggah.',
placeholder: 'Ketik atau tempel teks Anda…',
words: 'Kata', characters: 'Karakter', charactersNoSpaces: 'Karakter (tanpa spasi)',
sentences: 'Kalimat', paragraphs: 'Paragraf', lines: 'Baris', reading: 'Waktu baca', min: 'mnt',
},
};

export default function WordCounter({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const stats = useMemo(() => countText(text), [text]);

const readMin = stats.readingMinutes < 1 && stats.words > 0
? '< 1'
: String(Math.round(stats.readingMinutes));

const cards = [
{ label: t.words, value: stats.words.toLocaleString() },
{ label: t.characters, value: stats.characters.toLocaleString() },
{ label: t.charactersNoSpaces, value: stats.charactersNoSpaces.toLocaleString() },
{ label: t.sentences, value: stats.sentences.toLocaleString() },
{ label: t.paragraphs, value: stats.paragraphs.toLocaleString() },
{ label: t.lines, value: stats.lines.toLocaleString() },
{ label: t.reading, value: `${readMin} ${t.min}` },
];

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

<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-7">
{cards.map(c => (
<div key={c.label} className="border-2 border-border bg-muted p-3 text-center">
<div className="text-2xl font-bold tabular-nums">{c.value}</div>
<div className="text-xs text-muted-foreground">{c.label}</div>
</div>
))}
</div>

<TextArea value={text} onChange={e => setText(e.target.value)} rows={12} placeholder={t.placeholder} monospace={false} />
</div>
);
}
115 changes: 115 additions & 0 deletions src/islands/image/FaviconGenerator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { usePasteImage } from '@/hooks/usePasteImage';
import { generateFavicons, type FaviconFile } from '@/tools/image/favicon.lib';
import { createZip } from '@/tools/files/zip.lib';
import { downloadService } from '@/services/download';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string;
drop: string;
dropSub: string;
working: string;
failed: string;
result: string;
downloadZip: string;
}> = {
en: {
intro: 'Turn any image (ideally square, 512×512 or larger) into a complete favicon set — favicon.ico, PNGs, an Apple touch icon and a web manifest. Everything runs in your browser.',
drop: 'Drop an image or click to browse',
dropSub: 'A square PNG works best · or paste (⌘V)',
working: 'Generating favicons…',
failed: 'Could not process that image.',
result: 'Favicon set',
downloadZip: 'Download all (ZIP)',
},
id: {
intro: 'Ubah gambar apa pun (idealnya persegi, 512×512 atau lebih) menjadi set favicon lengkap — favicon.ico, PNG, Apple touch icon, dan web manifest. Semuanya berjalan di browser Anda.',
drop: 'Letakkan gambar atau klik untuk memilih',
dropSub: 'PNG persegi paling bagus · atau tempel (⌘V)',
working: 'Membuat favicon…',
failed: 'Tidak dapat memproses gambar itu.',
result: 'Set favicon',
downloadZip: 'Unduh semua (ZIP)',
},
};

export default function FaviconGenerator({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [files, setFiles] = useState<FaviconFile[]>([]);
const [urls, setUrls] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

useEffect(() => () => { Object.values(urls).forEach(URL.revokeObjectURL); }, [urls]);

const onDrop = async (dropped: File[]) => {
const file = dropped.find(f => f.type.startsWith('image/'));
if (!file) return;
setBusy(true);
setError('');
Object.values(urls).forEach(URL.revokeObjectURL);
setFiles([]);
setUrls({});
try {
const out = await generateFavicons(file, 'My Site');
const u: Record<string, string> = {};
for (const f of out) if (f.size) u[f.name] = URL.createObjectURL(f.blob);
setFiles(out);
setUrls(u);
} catch (e) {
setError(e instanceof Error ? e.message : t.failed);
} finally {
setBusy(false);
}
};

usePasteImage(f => onDrop([f]));

const downloadZip = async () => {
const entries = await Promise.all(
files.map(async f => ({ name: f.name, data: new Uint8Array(await f.blob.arrayBuffer()) })),
);
await downloadService.download(new Blob([createZip(entries)], { type: 'application/zip' }), 'favicons.zip');
};

const previews = files.filter(f => f.size);

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>

{busy && <p className="text-sm text-muted-foreground">{t.working}</p>}
{error && <Alert variant="error">{error}</Alert>}

{previews.length > 0 && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold">{t.result}</span>
<Button onClick={downloadZip}>{t.downloadZip}</Button>
</div>
<div className="flex flex-wrap items-end gap-4">
{previews.map(f => (
<div key={f.name} className="flex flex-col items-center gap-1">
<div className="flex h-24 w-24 items-center justify-center border-2 border-border bg-[repeating-conic-gradient(#e5e5e5_0_25%,#fff_0_50%)] bg-[length:16px_16px]">
<img src={urls[f.name]} alt={f.name} width={Math.min(f.size!, 96)} height={Math.min(f.size!, 96)} />
</div>
<span className="text-xs text-muted-foreground">{f.size}×{f.size}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
Loading
Loading