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
72 changes: 41 additions & 31 deletions src/islands/dev/CidrCalculator.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,73 @@
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 { parseCidrAny, type CidrInfo, type Ipv6Info } 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;
labels: Record<string, 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',
},
intro: 'Enter an IPv4 or IPv6 address with a CIDR prefix (e.g. 192.168.1.0/24 or 2001:db8::/32) to see the network, range, mask and address count. Everything is computed in your browser.',
label: 'IPv4 / IPv6 address / CIDR',
invalid: 'Enter a valid IPv4 or IPv6 address and prefix, e.g. 10.0.0.0/24 or 2001:db8::/48.',
hosts: 'hosts',
labels: {
address: 'Address', fullAddress: 'Expanded address', netmask: 'Netmask', wildcard: 'Wildcard mask',
network: 'Network address', broadcast: 'Broadcast address', firstHost: 'First usable host',
lastHost: 'Last usable host', firstAddress: 'First address', lastAddress: 'Last address',
totalHosts: 'Total addresses', usableHosts: 'Usable hosts', totalAddresses: 'Total addresses',
ipClass: 'Class', prefix: 'Prefix',
},
},
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',
},
intro: 'Masukkan alamat IPv4 atau IPv6 dengan prefix CIDR (mis. 192.168.1.0/24 atau 2001:db8::/32) untuk melihat network, rentang, mask, dan jumlah alamat. Semua dihitung di browser Anda.',
label: 'Alamat IPv4 / IPv6 / CIDR',
invalid: 'Masukkan alamat IPv4 atau IPv6 dan prefix yang valid, mis. 10.0.0.0/24 atau 2001:db8::/48.',
hosts: 'host',
labels: {
address: 'Alamat', fullAddress: 'Alamat lengkap', netmask: 'Netmask', wildcard: 'Wildcard mask',
network: 'Alamat network', broadcast: 'Alamat broadcast', firstHost: 'Host pertama',
lastHost: 'Host terakhir', firstAddress: 'Alamat pertama', lastAddress: 'Alamat terakhir',
totalHosts: 'Total alamat', usableHosts: 'Host tersedia', totalAddresses: 'Total alamat',
ipClass: 'Kelas', prefix: 'Prefix',
},
},
};

const ORDER: (keyof Omit<CidrInfo, 'prefix'>)[] = [
const V4_ORDER: (keyof CidrInfo)[] = [
'address', 'network', 'broadcast', 'netmask', 'wildcard',
'firstHost', 'lastHost', 'usableHosts', 'totalHosts', 'ipClass',
];
const V6_ORDER: (keyof Ipv6Info)[] = [
'address', 'fullAddress', 'network', 'firstAddress', 'lastAddress', 'totalAddresses',
];

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) };
return { info: parseCidrAny(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')
: '';
const order = info ? (info.version === 6 ? V6_ORDER : V4_ORDER) : [];
const rows = order.map(k => ({
key: k as string,
label: t.labels[k as string] ?? (k as string),
value: String((info as unknown as Record<string, unknown>)[k as string]),
suffix: (k === 'totalHosts' || k === 'usableHosts' || k === 'totalAddresses') ? ` ${t.hosts}` : '',
}));
const copyText = rows.map(r => `${r.label}: ${r.value}${r.suffix}`).join('\n');

return (
<div className="space-y-4">
Expand All @@ -68,7 +80,7 @@ export default function CidrCalculator({ lang = 'en' }: { lang?: Lang }) {
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"
placeholder="192.168.1.0/24 or 2001:db8::/32"
/>
</label>

Expand All @@ -77,16 +89,14 @@ export default function CidrCalculator({ lang = 'en' }: { lang?: Lang }) {
{info && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">/{info.prefix}</span>
<span className="text-sm font-semibold">IPv{info.version} · /{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>
{rows.map(r => (
<div key={r.key} 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">{r.label}</span>
<span className="break-all font-mono">{r.value}{r.suffix}</span>
</div>
))}
</div>
Expand Down
93 changes: 69 additions & 24 deletions src/islands/documents/PptxViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,92 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Alert } from '@/components/ui/Alert';
import type { PptxDoc } from '@/tools/documents/pptx.lib';
import type { PptxDoc, Shape } from '@/tools/documents/pptx.lib';
import type { Lang } from '@/i18n/config';

const PT_TO_PX = 1.3333;

const TR: Record<Lang, {
intro: string;
drop: string;
dropSub: string;
loading: string;
failed: string;
slide: string;
empty: string;
note: string;
}> = {
en: {
intro: 'Open and read PowerPoint (.pptx) slides in your browser — text and images, slide by slide. The file is never uploaded.',
intro: 'Open and view PowerPoint (.pptx) slides in your browser, laid out with their real positions, text and images. The file is never uploaded.',
drop: 'Drop a .pptx file or click to browse',
dropSub: 'Opened on your device',
loading: 'Opening presentation…',
failed: 'Could not open this file. Make sure it is a .pptx presentation.',
slide: 'Slide',
empty: '(no text on this slide)',
note: 'This is a lightweight text-and-image viewer, not a pixel-perfect renderer.',
note: 'Lightweight viewer: positions, text and images are rendered; charts, SmartArt and effects are not.',
},
id: {
intro: 'Buka dan baca slide PowerPoint (.pptx) di browser Andateks dan gambar, slide demi slide. File tidak pernah diunggah.',
intro: 'Buka dan lihat slide PowerPoint (.pptx) di browser Anda, ditata sesuai posisi, teks, dan gambar aslinya. File tidak pernah diunggah.',
drop: 'Letakkan file .pptx atau klik untuk memilih',
dropSub: 'Dibuka di perangkat Anda',
loading: 'Membuka presentasi…',
failed: 'Tidak dapat membuka file ini. Pastikan berupa presentasi .pptx.',
slide: 'Slide',
empty: '(tidak ada teks pada slide ini)',
note: 'Ini penampil teks-dan-gambar ringan, bukan renderer yang presisi piksel.',
note: 'Penampil ringan: posisi, teks, dan gambar dirender; chart, SmartArt, dan efek tidak.',
},
};

function ShapeView({ shape, url }: { shape: Shape; url?: string }) {
const box: React.CSSProperties = {
position: 'absolute',
left: shape.x,
top: shape.y,
width: shape.w,
height: shape.h,
overflow: 'hidden',
};
if (shape.kind === 'image') {
return url ? <img src={url} alt="" style={{ ...box, objectFit: 'contain' }} /> : null;
}
return (
<div style={{ ...box, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{shape.paragraphs!.map((p, pi) => (
<p key={pi} style={{ textAlign: p.align, margin: 0, lineHeight: 1.2 }}>
{p.runs.map((r, ri) => (
<span key={ri} style={{
fontWeight: r.bold ? 700 : 400,
fontStyle: r.italic ? 'italic' : 'normal',
fontSize: (r.sizePt ?? 18) * PT_TO_PX,
color: r.color ?? '#000',
}}>{r.text}</span>
))}
</p>
))}
</div>
);
}

export default function PptxViewer({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [doc, setDoc] = useState<PptxDoc | null>(null);
const [mediaUrls, setMediaUrls] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [scale, setScale] = useState(1);
const wrapRef = useRef<HTMLDivElement>(null);

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

// Scale the native-size slides to fit the container width.
useEffect(() => {
const el = wrapRef.current;
if (!el || !doc || doc.widthPx === 0) return;
const update = () => setScale(el.clientWidth / doc.widthPx);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [doc]);

const onDrop = async (files: File[]) => {
const file = files.find(f => f.name.toLowerCase().endsWith('.pptx'));
if (!file) return;
Expand Down Expand Up @@ -84,25 +126,28 @@ export default function PptxViewer({ lang = 'en' }: { lang?: Lang }) {
{error && <Alert variant="error">{error}</Alert>}

{doc && (
<div className="space-y-4">
<div ref={wrapRef} className="space-y-4">
<p className="text-xs text-muted-foreground">{t.note}</p>
{doc.slides.map((slide, i) => (
<div key={i} className="space-y-3 border-2 border-border bg-white p-6 text-black shadow-sm dark:bg-neutral-100">
<div className="text-xs font-bold uppercase tracking-wide text-neutral-500">{t.slide} {i + 1}</div>
{slide.paragraphs.length > 0 ? (
slide.paragraphs.map((p, pi) => (
<p key={pi} className={pi === 0 ? 'text-lg font-bold' : 'text-sm'}>{p}</p>
))
) : (
<p className="text-sm italic text-neutral-400">{t.empty}</p>
)}
{slide.images.length > 0 && (
<div className="flex flex-wrap gap-2">
{slide.images.map((key, ii) => (
mediaUrls[key] ? <img key={ii} src={mediaUrls[key]} alt="" className="max-h-48 border border-neutral-300" /> : null
<div key={i} className="space-y-1">
<div className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{t.slide} {i + 1}</div>
<div
className="w-full overflow-hidden border-2 border-border"
style={{ height: doc.heightPx * scale }}
>
<div style={{
width: doc.widthPx,
height: doc.heightPx,
position: 'relative',
background: '#fff',
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}>
{slide.shapes.map((shape, si) => (
<ShapeView key={si} shape={shape} url={shape.imageKey ? mediaUrls[shape.imageKey] : undefined} />
))}
</div>
)}
</div>
</div>
))}
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ const en: Record<string, ToolSeoContent> = {
'pptx-viewer': {
title: 'Free PPTX Viewer — Open PowerPoint Slides Online',
description: 'Open and read PowerPoint (.pptx) slides in your browser — text and images, slide by slide. The file is never uploaded.',
intro: 'This free PPTX viewer opens PowerPoint presentations and shows each slide’s text and images, slide by slide, without PowerPoint or an account. It is a lightweight text-and-image viewer (not a pixel-perfect renderer) and runs entirely in your browser, so your file is never uploaded.',
intro: 'This free PPTX viewer opens PowerPoint presentations and renders each slide with its real layout — shapes, text and images in their actual positions — without PowerPoint or an account. It is a lightweight viewer (charts, SmartArt and effects are not rendered) and runs entirely in your browser, so your file is never uploaded.',
howTo: [
'Drop a .pptx file, or click to browse.',
'The presentation opens on your device.',
Expand Down Expand Up @@ -1879,7 +1879,7 @@ const id: Record<string, ToolSeoContent> = {
'pptx-viewer': {
title: 'Penampil PPTX Gratis — Buka Slide PowerPoint Online',
description: 'Buka dan baca slide PowerPoint (.pptx) di browser Anda — teks dan gambar, slide demi slide. File tidak pernah diunggah.',
intro: 'Tool penampil PPTX gratis ini membuka presentasi PowerPoint dan menampilkan teks serta gambar tiap slide, slide demi slide, tanpa PowerPoint atau akun. Ini penampil teks-dan-gambar ringan (bukan renderer presisi piksel) dan berjalan sepenuhnya di browser Anda, jadi file Anda tidak pernah diunggah.',
intro: 'Tool penampil PPTX gratis ini membuka presentasi PowerPoint dan merender tiap slide dengan tata letak aslinya — bentuk, teks, dan gambar pada posisi sebenarnya — tanpa PowerPoint atau akun. Ini penampil ringan (chart, SmartArt, dan efek tidak dirender) dan berjalan sepenuhnya di browser Anda, jadi file Anda tidak pernah diunggah.',
howTo: [
'Letakkan berkas .pptx, atau klik untuk memilih.',
'Presentasi terbuka di perangkat Anda.',
Expand Down
50 changes: 49 additions & 1 deletion src/tools/dev/cidr.lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseCidr } from './cidr.lib';
import { parseCidr, parseIpv6Cidr, parseCidrAny } from './cidr.lib';

describe('parseCidr', () => {
it('computes a /24 network', () => {
Expand Down Expand Up @@ -41,4 +41,52 @@ describe('parseCidr', () => {
expect(() => parseCidr('1.2.3.4/33')).toThrow();
expect(() => parseCidr('not-an-ip')).toThrow();
});

it('tags the version', () => {
expect(parseCidr('10.0.0.0/8').version).toBe(4);
});
});

describe('parseIpv6Cidr', () => {
it('computes a /32 network', () => {
const r = parseIpv6Cidr('2001:db8::/32');
expect(r.version).toBe(6);
expect(r.network).toBe('2001:db8::');
expect(r.firstAddress).toBe('2001:db8::');
expect(r.lastAddress).toBe('2001:db8:ffff:ffff:ffff:ffff:ffff:ffff');
expect(r.totalAddresses).toBe((2n ** 96n).toString());
expect(r.prefix).toBe(32);
});

it('handles a single host /128 and :: compression', () => {
const r = parseIpv6Cidr('::1/128');
expect(r.address).toBe('::1');
expect(r.network).toBe('::1');
expect(r.totalAddresses).toBe('1');
});

it('masks a /64 correctly', () => {
expect(parseIpv6Cidr('fe80::abcd:1234/64').network).toBe('fe80::');
});

it('expands the full address form', () => {
expect(parseIpv6Cidr('2001:db8::1/64').fullAddress).toBe('2001:0db8:0000:0000:0000:0000:0000:0001');
});

it('accepts an embedded IPv4 tail', () => {
expect(() => parseIpv6Cidr('::ffff:192.168.1.1/128')).not.toThrow();
});

it('rejects invalid IPv6', () => {
expect(() => parseIpv6Cidr('gggg::/16')).toThrow();
expect(() => parseIpv6Cidr('2001:db8::/129')).toThrow();
expect(() => parseIpv6Cidr('1::2::3/64')).toThrow();
});
});

describe('parseCidrAny', () => {
it('dispatches by address family', () => {
expect(parseCidrAny('192.168.0.0/24').version).toBe(4);
expect(parseCidrAny('2001:db8::/48').version).toBe(6);
});
});
Loading
Loading