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
132 changes: 132 additions & 0 deletions src/islands/dev/CssGenerator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { linearGradientCss, radialGradientCss, boxShadowCss, borderRadiusCss } from '@/tools/dev/css-generators.lib';
import type { Lang } from '@/i18n/config';

type Tab = 'gradient' | 'shadow' | 'radius';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Generate CSS for gradients, box-shadows and border-radius with a live preview — copy the code straight into your stylesheet. Runs in your browser.',
gradient: 'Gradient', shadow: 'Box shadow', radius: 'Border radius',
type: 'Type', linear: 'Linear', radial: 'Radial', angle: 'Angle', color1: 'Color 1', color2: 'Color 2',
x: 'Offset X', y: 'Offset Y', blur: 'Blur', spread: 'Spread', color: 'Color', inset: 'Inset',
tl: 'Top-left', tr: 'Top-right', br: 'Bottom-right', bl: 'Bottom-left', copy: 'Copy CSS',
},
id: {
intro: 'Buat CSS untuk gradien, box-shadow, dan border-radius dengan pratinjau langsung — salin kodenya langsung ke stylesheet Anda. Berjalan di browser Anda.',
gradient: 'Gradien', shadow: 'Box shadow', radius: 'Border radius',
type: 'Tipe', linear: 'Linear', radial: 'Radial', angle: 'Sudut', color1: 'Warna 1', color2: 'Warna 2',
x: 'Offset X', y: 'Offset Y', blur: 'Blur', spread: 'Spread', color: 'Warna', inset: 'Inset',
tl: 'Kiri-atas', tr: 'Kanan-atas', br: 'Kanan-bawah', bl: 'Kiri-bawah', copy: 'Salin CSS',
},
};

function Slider({ label, value, set, min, max }: { label: string; value: number; set: (n: number) => void; min: number; max: number }) {
return (
<label className="space-y-1 text-sm">
<span className="flex justify-between"><span className="font-semibold">{label}</span><span className="font-mono text-muted-foreground">{value}</span></span>
<input type="range" min={min} max={max} value={value} onChange={e => set(Number(e.target.value))} className="w-full accent-accent" />
</label>
);
}

export default function CssGenerator({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [tab, setTab] = useState<Tab>('gradient');

const [gType, setGType] = useState<'linear' | 'radial'>('linear');
const [angle, setAngle] = useState(90);
const [c1, setC1] = useState('#7c3aed');
const [c2, setC2] = useState('#22d3ee');

const [sx, setSx] = useState(4); const [sy, setSy] = useState(6); const [sblur, setSblur] = useState(16);
const [sspread, setSspread] = useState(0); const [scolor, setScolor] = useState('#00000040'); const [sinset, setSinset] = useState(false);

const [tl, setTl] = useState(16); const [tr, setTr] = useState(16); const [br, setBr] = useState(16); const [bl, setBl] = useState(16);

const gradient = gType === 'linear'
? linearGradientCss(angle, [{ color: c1, pos: 0 }, { color: c2, pos: 100 }])
: radialGradientCss('circle', [{ color: c1, pos: 0 }, { color: c2, pos: 100 }]);
const shadow = boxShadowCss({ x: sx, y: sy, blur: sblur, spread: sspread, color: scolor, inset: sinset });
const radius = borderRadiusCss(tl, tr, br, bl);

const css = tab === 'gradient' ? `background: ${gradient};`
: tab === 'shadow' ? `box-shadow: ${shadow};`
: `border-radius: ${radius};`;

const previewStyle: React.CSSProperties = tab === 'gradient' ? { background: gradient }
: tab === 'shadow' ? { boxShadow: shadow, background: 'var(--tw-prose-body, #fff)' }
: { borderRadius: radius, background: gradient };

const input = 'w-full border-2 border-border bg-muted p-1.5 text-sm';

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

<div className="flex gap-1">
{(['gradient', 'shadow', 'radius'] as const).map(x => (
<button key={x} onClick={() => setTab(x)} aria-pressed={tab === x}
className={`border-2 px-4 py-2 text-sm font-bold uppercase tracking-wide ${tab === x ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border'}`}>
{t[x]}
</button>
))}
</div>

<div className="grid gap-4 lg:grid-cols-[1fr_240px]">
<div className="space-y-3">
{tab === 'gradient' && (
<>
<div className="flex gap-1 text-sm">
{(['linear', 'radial'] as const).map(g => (
<button key={g} onClick={() => setGType(g)} aria-pressed={gType === g}
className={`border-2 px-3 py-1 font-medium ${gType === g ? 'border-border bg-accent text-accent-foreground' : 'border-border'}`}>{t[g]}</button>
))}
</div>
{gType === 'linear' && <Slider label={t.angle} value={angle} set={setAngle} min={0} max={360} />}
<div className="grid grid-cols-2 gap-3">
<label className="space-y-1 text-sm"><span className="font-semibold">{t.color1}</span><input type="color" value={c1} onChange={e => setC1(e.target.value)} className="h-9 w-full border-2 border-border" /></label>
<label className="space-y-1 text-sm"><span className="font-semibold">{t.color2}</span><input type="color" value={c2} onChange={e => setC2(e.target.value)} className="h-9 w-full border-2 border-border" /></label>
</div>
</>
)}
{tab === 'shadow' && (
<>
<div className="grid grid-cols-2 gap-3">
<Slider label={t.x} value={sx} set={setSx} min={-50} max={50} />
<Slider label={t.y} value={sy} set={setSy} min={-50} max={50} />
<Slider label={t.blur} value={sblur} set={setSblur} min={0} max={100} />
<Slider label={t.spread} value={sspread} set={setSspread} min={-50} max={50} />
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm"><span className="font-semibold">{t.color}</span><input value={scolor} onChange={e => setScolor(e.target.value)} className={input} /></label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={sinset} onChange={e => setSinset(e.target.checked)} className="h-4 w-4 accent-accent" /><span className="font-semibold">{t.inset}</span></label>
</div>
</>
)}
{tab === 'radius' && (
<div className="grid grid-cols-2 gap-3">
<Slider label={t.tl} value={tl} set={setTl} min={0} max={100} />
<Slider label={t.tr} value={tr} set={setTr} min={0} max={100} />
<Slider label={t.bl} value={bl} set={setBl} min={0} max={100} />
<Slider label={t.br} value={br} set={setBr} min={0} max={100} />
</div>
)}
</div>

<div className="flex items-center justify-center border-2 border-border bg-[repeating-conic-gradient(#e5e5e5_0_25%,#fff_0_50%)] bg-[length:20px_20px] p-6">
<div className="h-28 w-28" style={previewStyle} />
</div>
</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">CSS</span>
<CopyButton value={css} label={t.copy} />
</div>
<textarea readOnly value={css} rows={2} className="w-full border-2 border-border bg-muted p-2 font-mono text-sm" />
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions src/islands/dev/FancyText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { allStyles } from '@/tools/dev/fancytext.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Turn plain text into fancy Unicode styles for bios, usernames and posts. These are real Unicode characters, so they paste anywhere — Instagram, TikTok, Discord and more.',
placeholder: 'Type your text…', copy: 'Copy',
},
id: {
intro: 'Ubah teks biasa menjadi gaya Unicode keren untuk bio, username, dan postingan. Ini karakter Unicode asli, jadi bisa ditempel di mana saja — Instagram, TikTok, Discord, dan lainnya.',
placeholder: 'Ketik teks Anda…', copy: 'Salin',
},
};

export default function FancyText({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('Hello world');
const styles = allStyles(text || '');

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>
<TextArea value={text} onChange={e => setText(e.target.value)} rows={2} placeholder={t.placeholder} />

<ul className="divide-y-2 divide-border border-2 border-border">
{styles.map(s => (
<li key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{s.label}</div>
<div className="truncate text-lg" title={s.output}>{s.output || ' '}</div>
</div>
<CopyButton value={s.output} label={t.copy} disabled={!s.output} />
</li>
))}
</ul>
</div>
);
}
78 changes: 78 additions & 0 deletions src/islands/dev/HtmlMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { ArrowLeftRight } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { htmlToMarkdown, markdownToHtml } from '@/tools/dev/htmlmd.lib';
import type { Lang } from '@/i18n/config';

type Dir = 'h2m' | 'm2h';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Convert HTML to Markdown or Markdown to HTML, entirely in your browser. Nothing is uploaded.',
h2m: 'HTML → Markdown', m2h: 'Markdown → HTML', swap: 'Swap',
input: 'Input', output: 'Output', convert: 'Convert', copy: 'Copy', failed: 'Could not convert.',
},
id: {
intro: 'Konversi HTML ke Markdown atau Markdown ke HTML, sepenuhnya di browser Anda. Tidak ada yang diunggah.',
h2m: 'HTML → Markdown', m2h: 'Markdown → HTML', swap: 'Tukar',
input: 'Masukan', output: 'Keluaran', convert: 'Konversi', copy: 'Salin', failed: 'Tidak dapat mengonversi.',
},
};

export default function HtmlMarkdown({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [dir, setDir] = useState<Dir>('h2m');
const [src, setSrc] = useState('<h1>Hello</h1>\n<p>A <strong>quick</strong> demo.</p>');
const [out, setOut] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const convert = async () => {
setBusy(true); setError('');
try {
setOut(dir === 'h2m' ? await htmlToMarkdown(src) : await markdownToHtml(src));
} catch {
setError(t.failed);
} finally {
setBusy(false);
}
};

const swap = () => {
setDir(d => (d === 'h2m' ? 'm2h' : 'h2m'));
setSrc(out || src);
setOut('');
};

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 === 'h2m' ? t.h2m : t.m2h}
</span>
<Button variant="ghost" onClick={swap}><ArrowLeftRight className="h-4 w-4" />{t.swap}</Button>
</div>

<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={12} 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={12} 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={convert} disabled={busy || !src.trim()}>{busy ? '…' : t.convert}</Button>
</div>
);
}
78 changes: 78 additions & 0 deletions src/islands/dev/ImageBase64.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { Dropzone } from '@/components/ui/Dropzone';
import { CopyButton } from '@/components/ui/CopyButton';
import { formatBytes } from '@/tools/image/canvas.lib';
import { decodedSize, toCssBackground, toImgTag } from '@/tools/dev/datauri.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, Record<string, string>> = {
en: {
intro: 'Convert an image to a Base64 data URI you can paste straight into CSS, HTML or JSON — no upload, it is encoded in your browser.',
drop: 'Drop an image or click to browse', dropSub: 'PNG, JPG, GIF, SVG, WebP…',
dataUri: 'Data URI', cssBg: 'CSS background', imgTag: 'HTML img tag', encoded: 'Encoded size', copy: 'Copy', clear: 'Clear',
},
id: {
intro: 'Ubah gambar menjadi data URI Base64 yang bisa langsung ditempel ke CSS, HTML, atau JSON — tanpa unggah, dienkode di browser Anda.',
drop: 'Letakkan gambar atau klik untuk memilih', dropSub: 'PNG, JPG, GIF, SVG, WebP…',
dataUri: 'Data URI', cssBg: 'CSS background', imgTag: 'HTML img tag', encoded: 'Ukuran terenkode', copy: 'Salin', clear: 'Bersihkan',
},
};

export default function ImageBase64({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [name, setName] = useState('');
const [origSize, setOrigSize] = useState(0);
const [dataUri, setDataUri] = useState('');
const [error, setError] = useState('');

const onDrop = (files: File[]) => {
const img = files.find(f => f.type.startsWith('image/'));
if (!img) return;
setName(img.name); setOrigSize(img.size); setError('');
const reader = new FileReader();
reader.onload = () => setDataUri(String(reader.result || ''));
reader.onerror = () => setError('Could not read this image.');
reader.readAsDataURL(img);
};

const base64 = dataUri.includes(',') ? dataUri.slice(dataUri.indexOf(',') + 1) : '';
const encoded = base64 ? decodedSize(base64) : 0;

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

{dataUri && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<img src={dataUri} alt={name} className="h-16 w-16 border-2 border-border object-contain" />
<span><span className="font-bold text-foreground">{name}</span> — {formatBytes(origSize)} → {formatBytes(encoded)} {t.encoded.toLowerCase()}</span>
</div>

{([
[t.dataUri, dataUri],
[t.cssBg, toCssBackground(dataUri)],
[t.imgTag, toImgTag(dataUri, name)],
] as const).map(([label, val]) => (
<div key={label} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</span>
<CopyButton value={val} label={t.copy} />
</div>
<textarea readOnly value={val} rows={3} className="w-full break-all border-2 border-border bg-muted p-2 font-mono text-xs" />
</div>
))}
</div>
)}
</div>
);
}
4 changes: 4 additions & 0 deletions src/registry/tool-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ const ID_LABELS: Record<string, { name: string; summary: string }> = {
"http-status": { name: "Kode Status HTTP", summary: "Cari kode status HTTP apa pun — angka, kelas, atau nama" },
"mime-lookup": { name: "Pencarian Tipe MIME", summary: "Temukan tipe MIME untuk ekstensi berkas (dan sebaliknya)" },
"lorem-ipsum": { name: "Generator Lorem Ipsum", summary: "Buat teks placeholder Lorem Ipsum per paragraf, kalimat, atau kata" },
"fancy-text": { name: "Generator Teks Keren", summary: "Ubah teks menjadi gaya Unicode keren untuk bio dan postingan" },
"image-to-base64": { name: "Gambar ke Base64", summary: "Ubah gambar menjadi data URI Base64 untuk CSS/HTML" },
"css-generator": { name: "Generator CSS", summary: "Buat CSS gradien, box-shadow & border-radius dengan pratinjau" },
"html-markdown": { name: "HTML ↔ Markdown", summary: "Konversi antara HTML dan Markdown, dua arah" },
"browser-info": { name: "Apa Browser Saya", summary: "Lihat user agent, browser, ukuran layar & viewport Anda dan lainnya" },
"emoji-picker": { name: "Pemilih Emoji & Simbol", summary: "Cari dan salin emoji & karakter khusus (—, °, ±, €, ⌘)" },
"roman-numerals": { name: "Konverter Angka Romawi", summary: "Konversi angka ke angka Romawi dan sebaliknya (1–3999)" },
Expand Down
Loading
Loading