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
116 changes: 116 additions & 0 deletions src/islands/dev/BrowserInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { parseUserAgent } from '@/tools/dev/browser-info.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, { intro: string; copyAll: string; groups: { browser: string; display: string; device: string; locale: string } }> = {
en: {
intro: 'See what your browser reports about itself — user agent, browser & OS, screen and viewport size, timezone and more. Nothing is uploaded; this reads only what your own browser exposes to any web page.',
copyAll: 'Copy all as JSON',
groups: { browser: 'Browser', display: 'Screen & viewport', device: 'Device', locale: 'Locale & network' },
},
id: {
intro: 'Lihat apa yang dilaporkan browser Anda tentang dirinya — user agent, browser & OS, ukuran layar dan viewport, zona waktu, dan lainnya. Tidak ada yang diunggah; ini hanya membaca yang diekspos browser Anda ke halaman web mana pun.',
copyAll: 'Salin semua sebagai JSON',
groups: { browser: 'Browser', display: 'Layar & viewport', device: 'Perangkat', locale: 'Bahasa & jaringan' },
},
};

type Row = { label: string; value: string };
type Info = { browser: Row[]; display: Row[]; device: Row[]; locale: Row[]; all: Record<string, string> };

// navigator.deviceMemory is non-standard and not in the TS lib types.
interface ExtraNavigator extends Navigator {
deviceMemory?: number;
}

function collect(): Info {
const nav = navigator as ExtraNavigator;
const ua = nav.userAgent;
const parsed = parseUserAgent(ua);
const dpr = window.devicePixelRatio || 1;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '—';
const langs = nav.languages && nav.languages.length ? nav.languages.join(', ') : nav.language || '—';

const browser: Row[] = [
{ label: 'User agent', value: ua },
{ label: 'Browser', value: `${parsed.browser}${parsed.browserVersion ? ' ' + parsed.browserVersion : ''}` },
{ label: 'Engine', value: parsed.engine },
{ label: 'Operating system', value: parsed.os },
];
const display: Row[] = [
{ label: 'Screen resolution', value: `${screen.width} × ${screen.height}` },
{ label: 'Available screen', value: `${screen.availWidth} × ${screen.availHeight}` },
{ label: 'Viewport (window)', value: `${window.innerWidth} × ${window.innerHeight}` },
{ label: 'Device pixel ratio', value: String(dpr) },
{ label: 'Color depth', value: `${screen.colorDepth}-bit` },
{ label: 'Orientation', value: screen.orientation?.type ?? '—' },
];
const device: Row[] = [
{ label: 'CPU cores (logical)', value: nav.hardwareConcurrency ? String(nav.hardwareConcurrency) : '—' },
{ label: 'Device memory', value: nav.deviceMemory ? `${nav.deviceMemory} GB (approx.)` : '—' },
{ label: 'Touch points', value: String(nav.maxTouchPoints ?? 0) },
{ label: 'Platform', value: nav.platform || '—' },
];
const locale: Row[] = [
{ label: 'Languages', value: langs },
{ label: 'Timezone', value: tz },
{ label: 'Cookies enabled', value: nav.cookieEnabled ? 'Yes' : 'No' },
{ label: 'Online', value: nav.onLine ? 'Yes' : 'No' },
{ label: 'Do Not Track', value: nav.doNotTrack === '1' ? 'Enabled' : 'Not enabled' },
];

const all: Record<string, string> = {};
for (const r of [...browser, ...display, ...device, ...locale]) all[r.label] = r.value;
return { browser, display, device, locale, all };
}

function Section({ title, rows }: { title: string; rows: Row[] }) {
return (
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border">
{rows.map(r => (
<li key={r.label} className="flex items-start gap-3 bg-muted/40 px-3 py-2">
<span className="w-40 shrink-0 text-sm text-muted-foreground">{r.label}</span>
<span className="min-w-0 flex-1 break-all font-mono text-sm">{r.value}</span>
<CopyButton value={r.value} label="" />
</li>
))}
</ul>
</div>
);
}

export default function BrowserInfo({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [info, setInfo] = useState<Info | null>(null);

useEffect(() => {
const update = () => setInfo(collect());
update();
window.addEventListener('resize', update);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);

if (!info) return <p className="text-sm text-muted-foreground">{t.intro}</p>;

return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-muted-foreground">{t.intro}</p>
<CopyButton value={JSON.stringify(info.all, null, 2)} label={t.copyAll} />
</div>
<Section title={t.groups.browser} rows={info.browser} />
<Section title={t.groups.display} rows={info.display} />
<Section title={t.groups.device} rows={info.device} />
<Section title={t.groups.locale} rows={info.locale} />
</div>
);
}
63 changes: 63 additions & 0 deletions src/islands/dev/HttpStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useMemo, useState } from 'react';
import { searchStatuses, CLASS_LABELS, type StatusClass } from '@/tools/dev/http-status.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, { intro: string; placeholder: string; empty: string }> = {
en: {
intro: 'Look up any HTTP status code — search by number (e.g. 422), class (e.g. 4xx) or name (e.g. "gateway"). Everything runs in your browser.',
placeholder: 'Search a code, class or name — e.g. 404, 5xx, timeout',
empty: 'No status codes match your search.',
},
id: {
intro: 'Cari kode status HTTP mana pun — berdasarkan nomor (mis. 422), kelas (mis. 4xx), atau nama (mis. "gateway"). Semuanya berjalan di browser Anda.',
placeholder: 'Cari kode, kelas, atau nama — mis. 404, 5xx, timeout',
empty: 'Tidak ada kode status yang cocok.',
},
};

const CLASS_STYLE: Record<StatusClass, string> = {
'1xx': 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
'2xx': 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
'3xx': 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
'4xx': 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
'5xx': 'bg-red-500/15 text-red-600 dark:text-red-400',
};

export default function HttpStatus({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [query, setQuery] = useState('');
const results = useMemo(() => searchStatuses(query), [query]);

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

<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t.placeholder}
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:border-accent"
/>

{results.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t.empty}</p>
) : (
<ul className="space-y-2">
{results.map(s => (
<li key={s.code} className="rounded-lg border border-border bg-muted/40 p-3">
<div className="flex items-center gap-2">
<code className="text-base font-bold">{s.code}</code>
<span className="font-semibold">{s.name}</span>
<span className={`ml-auto rounded-full px-2 py-0.5 text-xs font-medium ${CLASS_STYLE[s.category]}`}>
{CLASS_LABELS[s.category]}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{s.description}</p>
</li>
))}
</ul>
)}
</div>
);
}
93 changes: 93 additions & 0 deletions src/islands/dev/LoremIpsum.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { generateLorem, type LoremUnit } from '@/tools/dev/lorem.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string; amount: string; unit: string; startWith: string; generate: string; output: string;
units: Record<LoremUnit, string>;
}> = {
en: {
intro: 'Generate placeholder Lorem Ipsum text — by paragraphs, sentences or words — for mockups and layouts. Runs entirely in your browser.',
amount: 'Amount',
unit: 'Type',
startWith: 'Start with "Lorem ipsum…"',
generate: 'Generate',
output: 'Result',
units: { paragraphs: 'Paragraphs', sentences: 'Sentences', words: 'Words' },
},
id: {
intro: 'Buat teks placeholder Lorem Ipsum — per paragraf, kalimat, atau kata — untuk mockup dan tata letak. Berjalan sepenuhnya di browser Anda.',
amount: 'Jumlah',
unit: 'Tipe',
startWith: 'Mulai dengan "Lorem ipsum…"',
generate: 'Buat',
output: 'Hasil',
units: { paragraphs: 'Paragraf', sentences: 'Kalimat', words: 'Kata' },
},
};

const UNITS: LoremUnit[] = ['paragraphs', 'sentences', 'words'];

export default function LoremIpsum({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [unit, setUnit] = useState<LoremUnit>('paragraphs');
const [count, setCount] = useState(3);
const [startWithLorem, setStartWithLorem] = useState(true);
const [seed, setSeed] = useState(1);
const [output, setOutput] = useState('');

const generate = () => {
const nextSeed = seed + 1;
setSeed(nextSeed);
const safe = Math.min(Math.max(count, 1), 1000);
setOutput(generateLorem({ unit, count: safe, startWithLorem, seed: nextSeed }));
};

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

<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground">{t.amount}</span>
<input
type="number"
min={1}
max={1000}
value={count}
onChange={e => setCount(Number(e.target.value))}
className="w-24 rounded-lg border border-border bg-muted/40 px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground">{t.unit}</span>
<select
value={unit}
onChange={e => setUnit(e.target.value as LoremUnit)}
className="rounded-lg border border-border bg-muted/40 px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{UNITS.map(u => <option key={u} value={u}>{t.units[u]}</option>)}
</select>
</label>
<label className="flex items-center gap-2 pb-1.5 text-sm">
<input type="checkbox" checked={startWithLorem} onChange={e => setStartWithLorem(e.target.checked)} className="h-4 w-4 accent-accent" />
{t.startWith}
</label>
<Button onClick={generate}>{t.generate}</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={10} monospace={false} />
</div>
)}
</div>
);
}
58 changes: 58 additions & 0 deletions src/islands/dev/MimeLookup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useMemo, useState } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { searchMime } from '@/tools/dev/mime.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, { intro: string; placeholder: string; empty: string; ext: string; type: string }> = {
en: {
intro: 'Look up the MIME (content) type for a file extension, or find the extensions for a MIME type. Search by extension (jpg), type (image/png) or name (word). Runs in your browser.',
placeholder: 'Search an extension, MIME type or name — e.g. svg, application/json, excel',
empty: 'No MIME types match your search.',
ext: 'Extension',
type: 'MIME type',
},
id: {
intro: 'Cari tipe MIME (content type) untuk sebuah ekstensi file, atau temukan ekstensi untuk sebuah tipe MIME. Cari berdasarkan ekstensi (jpg), tipe (image/png), atau nama (word). Berjalan di browser Anda.',
placeholder: 'Cari ekstensi, tipe MIME, atau nama — mis. svg, application/json, excel',
empty: 'Tidak ada tipe MIME yang cocok.',
ext: 'Ekstensi',
type: 'Tipe MIME',
},
};

export default function MimeLookup({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [query, setQuery] = useState('');
const results = useMemo(() => searchMime(query), [query]);

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

<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t.placeholder}
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:border-accent"
/>

{results.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t.empty}</p>
) : (
<ul className="space-y-2">
{results.map((m, i) => (
<li key={`${m.ext}-${m.mime}-${i}`} className="flex items-center gap-3 rounded-lg border border-border bg-muted/40 p-3">
<code className="rounded bg-accent/10 px-2 py-0.5 text-sm font-bold text-accent">.{m.ext}</code>
<div className="min-w-0 flex-1">
<code className="block truncate text-sm font-semibold">{m.mime}</code>
<span className="text-xs text-muted-foreground">{m.name}</span>
</div>
<CopyButton value={m.mime} label="" />
</li>
))}
</ul>
)}
</div>
);
}
Loading
Loading