From a99150d60d6422eef347163796ef1fd6d0e7e7f3 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 2 Aug 2026 21:56:37 +0700 Subject: [PATCH] feat(documents): Word (DOCX) Viewer + new Documents category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a client-side DOCX viewer that renders Word documents in the browser via docx-preview (full layout, tables, images) — nothing is uploaded. Introduces a new teal 'Documents' category for the coming family of document viewers/converters. Bilingual (EN + ID) UI, SEO content and OG. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- package-lock.json | 91 ++++++++++++++++++++++++++ package.json | 1 + scripts/generate-og.mjs | 2 +- src/islands/documents/DocxViewer.tsx | 95 ++++++++++++++++++++++++++++ src/registry/categories.ts | 4 ++ src/registry/tool-seo.ts | 34 ++++++++++ src/registry/tools.ts | 11 ++++ src/types/tool.ts | 2 +- 8 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/islands/documents/DocxViewer.tsx diff --git a/package-lock.json b/package-lock.json index 797208c..adff87b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "astro": "^4.16.19", "cmdk": "^0.2.1", "comlink": "^4.4.2", + "docx-preview": "^0.4.0", "dompurify": "^3.4.12", "fast-xml-parser": "^5.10.0", "fflate": "^0.8.3", @@ -9839,6 +9840,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -10847,6 +10854,15 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/docx-preview": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.0.tgz", + "integrity": "sha512-OdKtE/uj3M4RfGarLkGjahUzRg8/kBp0Sraj1r1NAY1tp/sTpHOBqDrzVf9onMBt9vxP6SdQ6bpLCUCsFwjgcA==", + "license": "Apache-2.0", + "dependencies": { + "jszip": ">=3.0.0" + } + }, "node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -13022,6 +13038,12 @@ "pica": "^7.1.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "4.3.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", @@ -13954,6 +13976,18 @@ "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", "license": "Apache-2.0" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -14073,6 +14107,15 @@ "comlink": "^4.4.1" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -17203,6 +17246,12 @@ "node": ">=6" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -17479,6 +17528,42 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -18392,6 +18477,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", diff --git a/package.json b/package.json index 1200c58..a3deeb2 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "astro": "^4.16.19", "cmdk": "^0.2.1", "comlink": "^4.4.2", + "docx-preview": "^0.4.0", "dompurify": "^3.4.12", "fast-xml-parser": "^5.10.0", "fflate": "^0.8.3", diff --git a/scripts/generate-og.mjs b/scripts/generate-og.mjs index 8965134..06cb9c3 100644 --- a/scripts/generate-og.mjs +++ b/scripts/generate-og.mjs @@ -19,7 +19,7 @@ const fonts = [ ]; const CAT_COLOR = { - Dev: '#3b82f6', PDF: '#ef4444', Image: '#22c55e', Files: '#eab308', Draw: '#a855f7', + Dev: '#3b82f6', PDF: '#ef4444', Image: '#22c55e', Files: '#eab308', Documents: '#14b8a6', Draw: '#a855f7', Media: '#ec4899', Network: '#06b6d4', Maps: '#10b981', Legacy: '#6366f1', Playground: '#f97316', }; diff --git a/src/islands/documents/DocxViewer.tsx b/src/islands/documents/DocxViewer.tsx new file mode 100644 index 0000000..baa205e --- /dev/null +++ b/src/islands/documents/DocxViewer.tsx @@ -0,0 +1,95 @@ +import { useRef, useState } from 'react'; +import { FileText, Printer } from 'lucide-react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { + intro: 'Open and read a Word document (.docx) right here — full layout, tables and images. It is rendered on your device; nothing is uploaded.', + drop: 'Drop a Word document (.docx)', dropSub: 'Rendered on your device — no upload.', + how: 'Older .doc (binary) files aren’t supported — save as .docx in Word/Google Docs first.', + opening: 'Rendering…', another: 'Open another', print: 'Print / Save as PDF', + errRead: 'Could not open this document — is it a valid .docx file?', + }, + id: { + intro: 'Buka dan baca dokumen Word (.docx) langsung di sini — tata letak, tabel, dan gambar lengkap. Ditampilkan di perangkat Anda; tidak ada yang diunggah.', + drop: 'Letakkan dokumen Word (.docx)', dropSub: 'Ditampilkan di perangkat Anda — tanpa unggahan.', + how: 'Berkas .doc lama (biner) tidak didukung — simpan sebagai .docx di Word/Google Docs terlebih dahulu.', + opening: 'Menampilkan…', another: 'Buka yang lain', print: 'Cetak / Simpan PDF', + errRead: 'Tidak dapat membuka dokumen ini — apakah berkas .docx yang valid?', + }, +}; + +export default function DocxViewer({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const containerRef = useRef(null); + const [hasDoc, setHasDoc] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const onDrop = async (files: File[]) => { + const f = files[0]; + if (!f) return; + setError(''); + setBusy(true); + try { + const buf = await f.arrayBuffer(); + const { renderAsync } = await import('docx-preview'); + const container = containerRef.current!; + container.innerHTML = ''; + await renderAsync(buf, container, undefined, { + className: 'docx', inWrapper: true, breakPages: true, ignoreLastRenderedPageBreak: false, + }); + setHasDoc(true); + } catch { + setError(t.errRead); + setHasDoc(false); + } finally { + setBusy(false); + } + }; + + const reset = () => { + if (containerRef.current) containerRef.current.innerHTML = ''; + setHasDoc(false); + setError(''); + }; + + return ( +
+

{t.intro}

+ + {!hasDoc && ( +
+ +
+

{busy ? t.opening : t.drop}

+

{t.dropSub}

+
+
+

{t.how}

+
+ )} + + {error && {error}} + + {hasDoc && ( +
+ + +
+ )} + + {/* docx-preview renders the document (and its styles) into this container. */} +
+
+ ); +} diff --git a/src/registry/categories.ts b/src/registry/categories.ts index b1b6673..bb10235 100644 --- a/src/registry/categories.ts +++ b/src/registry/categories.ts @@ -6,6 +6,7 @@ export const categories: Category[] = [ 'PDF', 'Image', 'Files', + 'Documents', 'Draw', 'Media', 'Network', @@ -19,6 +20,7 @@ export const categoryColors: Record = { PDF: 'bg-red-500', Image: 'bg-green-500', Files: 'bg-yellow-500', + Documents: 'bg-teal-500', Draw: 'bg-purple-500', Media: 'bg-pink-500', Network: 'bg-cyan-500', @@ -54,6 +56,7 @@ export const categoryDescriptionsId: Record = { PDF: 'Kelola PDF secara privat di browser Anda — gabung, pisah, perkecil, konversi, perbaiki, lindungi, dan edit. Dokumen Anda tidak pernah meninggalkan perangkat, jadi berkas rahasia pun tetap aman.', Image: 'Edit dan konversi gambar di perangkat Anda — ubah ukuran, potong, perkecil, konversi format, hapus latar belakang, tingkatkan resolusi, buramkan wajah, ekstrak teks, dan lainnya. Tanpa unggahan, tanpa tanda air.', Files: 'Utilitas berkas sehari-hari yang menjaga data Anda tetap lokal — arsipkan, ekstrak, enkripsi, dan periksa berkas langsung di browser tanpa mengirim apa pun ke server.', + Documents: 'Lihat dan konversi dokumen di browser Anda — buka berkas Word, OpenDocument, spreadsheet, dan e-book tanpa aplikasi kantor atau unggahan. Dokumen Anda tidak pernah meninggalkan perangkat.', Draw: 'Tool menggambar dan membuat diagram sederhana yang berjalan di browser Anda — membuat sketsa, anotasi, dan diagram tanpa akun atau unggahan apa pun.', Media: 'Utilitas audio dan video privat — konversi, pangkas, rekam, dan transkripsi media sepenuhnya di perangkat Anda. Rekaman Anda tidak pernah meninggalkan browser.', Network: 'Tool peer-to-peer yang menghubungkan dua perangkat secara langsung untuk mentransfer berkas atau berkomunikasi — data Anda mengalir antar perangkat, bukan melalui server.', @@ -68,6 +71,7 @@ export const categoryDescriptions: Record = { PDF: 'Work with PDFs privately in your browser — merge, split, compress, convert, repair, protect and edit. Your documents never leave your device, so even confidential files stay safe.', Image: 'Edit and convert images on your device — resize, crop, compress, convert formats, remove backgrounds, upscale, blur faces, extract text and more. No uploads, no watermarks.', Files: 'Everyday file utilities that keep your data local — archive, extract, encrypt and inspect files right in the browser with nothing sent to a server.', + Documents: 'View and convert documents in your browser — open Word, OpenDocument, spreadsheet and e-book files with no office app and no upload. Your documents never leave your device.', Draw: 'Simple drawing and diagramming tools that run in your browser — sketch, annotate and create diagrams without an account or any upload.', Media: 'Private audio and video utilities — convert, trim, record and transcribe media entirely on your device using on-device processing. Your recordings never leave your browser.', Network: 'Peer-to-peer tools that connect two devices directly to transfer files or communicate — your data flows device to device, not through a server.', diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 5c54b39..bd2db89 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -7,6 +7,23 @@ import type { Lang } from '@/i18n/config'; * a locale entry is missing. Feeds on-page copy + HowTo/FAQPage structured data. */ const en: Record = { + 'docx-viewer': { + title: 'Free DOCX Viewer — Open Word Files Online', + description: 'A free DOCX viewer to open and read Microsoft Word documents in your browser — full layout, tables and images. 100% private; nothing is uploaded.', + intro: 'This free DOCX viewer tool opens Microsoft Word documents right in your browser, rendering the full layout — headings, tables, lists and images — without Word, Google Docs or any account. The file is read on your device and never uploaded.', + howTo: [ + 'Drop a .docx file (or click to browse) — it is read entirely in your browser.', + 'The document renders with its real layout, tables and images.', + 'Scroll to read, or use Print / Save as PDF to keep a copy.', + 'Nothing is uploaded — the file stays on your device.', + ], + faqs: [ + { q: 'Is my document uploaded anywhere?', a: 'No. The .docx is parsed and rendered entirely in your browser with JavaScript. It never leaves your device, so it is safe for confidential documents.' }, + { q: 'Does it support old .doc files?', a: 'No — only the modern .docx format. Open an old .doc in Word or Google Docs and save or export it as .docx first.' }, + { q: 'Can I convert the document to PDF?', a: 'Yes, indirectly: open it here and use Print / Save as PDF in your browser to produce a PDF copy.' }, + { q: 'Will complex formatting look right?', a: 'Most documents — headings, tables, lists, images and basic styling — render faithfully. Very complex layouts or unusual fonts may differ slightly, since it renders with the fonts available in your browser.' }, + ], + }, 'ghost-backup': { title: 'Free Ghost Blog Backup Tool — Export to Markdown', description: 'A free tool to convert a Ghost blog export into Markdown or standalone HTML files — one per post, with frontmatter and tags. Runs in your browser; nothing is uploaded.', @@ -1308,6 +1325,23 @@ const en: Record = { }; const id: Record = { + 'docx-viewer': { + title: 'Penampil DOCX Gratis — Buka Berkas Word Online', + description: 'Penampil DOCX gratis untuk membuka dan membaca dokumen Microsoft Word di browser — tata letak, tabel, dan gambar lengkap. 100% privat; tidak ada yang diunggah.', + intro: 'Tool penampil DOCX gratis ini membuka dokumen Microsoft Word langsung di browser Anda, menampilkan tata letak lengkap — judul, tabel, daftar, dan gambar — tanpa Word, Google Docs, atau akun apa pun. Berkas dibaca di perangkat Anda dan tidak pernah diunggah.', + howTo: [ + 'Letakkan berkas .docx (atau klik untuk menelusuri) — dibaca sepenuhnya di browser Anda.', + 'Dokumen ditampilkan dengan tata letak, tabel, dan gambar aslinya.', + 'Gulir untuk membaca, atau gunakan Cetak / Simpan PDF untuk menyimpan salinan.', + 'Tidak ada yang diunggah — berkas tetap di perangkat Anda.', + ], + faqs: [ + { q: 'Apakah dokumen saya diunggah ke suatu tempat?', a: 'Tidak. Berkas .docx diurai dan ditampilkan sepenuhnya di browser Anda dengan JavaScript. Berkas tidak pernah meninggalkan perangkat, jadi aman untuk dokumen rahasia.' }, + { q: 'Apakah mendukung berkas .doc lama?', a: 'Tidak — hanya format .docx modern. Buka .doc lama di Word atau Google Docs lalu simpan atau ekspor sebagai .docx terlebih dahulu.' }, + { q: 'Bisakah saya mengonversi dokumen ke PDF?', a: 'Ya, secara tidak langsung: buka di sini lalu gunakan Cetak / Simpan PDF di browser untuk membuat salinan PDF.' }, + { q: 'Apakah pemformatan rumit akan tampil dengan benar?', a: 'Sebagian besar dokumen — judul, tabel, daftar, gambar, dan gaya dasar — tampil dengan setia. Tata letak yang sangat rumit atau font tidak biasa mungkin sedikit berbeda, karena ditampilkan dengan font yang tersedia di browser Anda.' }, + ], + }, 'ghost-backup': { title: 'Tool Cadangan Blog Ghost Gratis — Ekspor ke Markdown', description: 'Tool gratis untuk mengubah ekspor blog Ghost menjadi berkas Markdown atau HTML mandiri — satu per pos, dengan frontmatter dan tag. Berjalan di browser; tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 7500248..eed9ea0 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -157,6 +157,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/dev/GhostBackup'), status: 'beta' }, + { + id: 'docx-viewer', + name: 'Word (DOCX) Viewer', + category: 'Documents', + route: '/tools/docx-viewer', + keywords: ['docx', 'word', 'viewer', 'open', 'read', 'document', 'office', 'preview', 'doc'], + icon: FileText, + summary: 'Open and read Word .docx files in your browser', + load: () => import('@/islands/documents/DocxViewer'), + status: 'beta' + }, { id: 'markdown', name: 'Markdown Preview', diff --git a/src/types/tool.ts b/src/types/tool.ts index 705faf2..286445f 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -1,6 +1,6 @@ import type { LucideIcon } from 'lucide-react'; -export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Draw' | 'Media' | 'Network' | 'Maps' | 'Legacy' | 'Playground'; +export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Documents' | 'Draw' | 'Media' | 'Network' | 'Maps' | 'Legacy' | 'Playground'; export interface AssetRef { url: string;