From 30516a6989489e5aa35fd1d7cfc9d3c68c266a8d Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 2 Aug 2026 10:18:46 +0700 Subject: [PATCH] feat(legacy): illustrated 'How it works' explainer + Bahasa version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Custom neo-brutalist SVG scene illustrations (write / lock-on-device+no-cloud / key split into 5 shares / opened-later) — theme-aware via currentColor + accent fill tokens, aria-hidden decorative. - Shares diagram labels the threshold clearly: 3 solid = 'any 3 unlock it', 2 dashed = 'spare, can be lost', with an 'it doesn't matter which 3' caption. - Bilingual: ToolHost now forwards lang to the island; the landing intro, buttons, and the whole explainer render in Bahasa on /id/ (no 'alat'). Interactive Create/Open forms stay English for now. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- src/islands/ToolHost.tsx | 12 +- src/islands/legacy/LegacyLetter.tsx | 192 +++++++++++++++++++++-- src/pages/[...locale]/tools/[tool].astro | 2 +- 3 files changed, 191 insertions(+), 15 deletions(-) diff --git a/src/islands/ToolHost.tsx b/src/islands/ToolHost.tsx index 33f0208..5437812 100644 --- a/src/islands/ToolHost.tsx +++ b/src/islands/ToolHost.tsx @@ -1,8 +1,11 @@ -import { Component, lazy, Suspense, useMemo, type ReactNode } from 'react'; +import { Component, lazy, Suspense, useMemo, type ComponentType, type ReactNode } from 'react'; import { getToolById } from '@/registry/tools'; +import type { Lang } from '@/i18n/config'; interface ToolHostProps { toolId: string; + /** Current locale — forwarded to tools that localize their own UI. */ + lang?: Lang; } interface BoundaryProps { @@ -45,14 +48,15 @@ class ToolErrorBoundary extends Component { * registry and lazily imports its component via the registry `load()` fn, so * one route file serves every tool without hardcoding imports. */ -export default function ToolHost({ toolId }: ToolHostProps) { +export default function ToolHost({ toolId, lang }: ToolHostProps) { const tool = getToolById(toolId); // `tool` is a stable registry reference derived from `toolId`, so keying on // both is equivalent to keying on `toolId` alone — and satisfies the linter. const LazyTool = useMemo(() => { if (!tool) return null; - return lazy(tool.load); + // Tools take no required props; a few read an optional `lang`. + return lazy(tool.load) as unknown as ComponentType<{ lang?: Lang }>; }, [toolId, tool]); if (!tool || !LazyTool) { @@ -64,7 +68,7 @@ export default function ToolHost({ toolId }: ToolHostProps) { Loading tool…} > - + ); diff --git a/src/islands/legacy/LegacyLetter.tsx b/src/islands/legacy/LegacyLetter.tsx index 97dffe0..282a4e7 100644 --- a/src/islands/legacy/LegacyLetter.tsx +++ b/src/islands/legacy/LegacyLetter.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; -import { Plus, Trash2, Lock, Users, Upload, Download, Eye, EyeOff, ShieldAlert, Printer, KeyRound } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import { Plus, Trash2, Lock, Users, Upload, Download, Eye, EyeOff, ShieldAlert, Printer, KeyRound, ShieldCheck, Clock, ArrowDown } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { Dropzone } from '@/components/ui/Dropzone'; import { CopyButton } from '@/components/ui/CopyButton'; import { downloadService } from '@/services/download'; +import type { Lang } from '@/i18n/config'; import { createVault, openWithPassword, openWithShares, vaultCapabilities, VAULT_EXT, type Account, type LegacyContent, @@ -14,20 +15,191 @@ type Mode = 'create' | 'open' | null; const input = 'w-full border-2 border-border bg-muted px-3 py-2 text-sm outline-none focus:shadow-brutal-sm'; const emptyAccount = (): Account => ({ service: '', username: '', password: '', url: '', notes: '' }); -export default function LegacyLetter() { +// Custom neo-brutalist SVG scenes: currentColor strokes (theme-aware) + accent/muted/ +// background fill tokens so they read in both light and dark mode. Decorative → aria-hidden. +const svgProps = { + viewBox: '0 0 96 64', role: 'img', 'aria-hidden': true, + className: 'h-16 w-full text-foreground', fill: 'none', stroke: 'currentColor', + strokeWidth: 2.5, strokeLinejoin: 'round' as const, strokeLinecap: 'round' as const, +}; + +function SceneWrite() { + return ( + + + + + + + ); +} +function SceneLock() { + return ( + + + + + + + ); +} +function SceneShares() { + return ( + + + + + + + + ); +} +function SceneOpen() { + return ( + + + + + + + + ); +} + +const SCENES = [SceneWrite, SceneLock, SceneShares, SceneOpen]; + +// Landing + illustration copy, per language. The interactive Create/Open forms +// stay English for now (a later phase); this is the explainer surface. +const T: Record = { + en: { + intro: 'Write a private letter — passwords, accounts, and final words — that your family can open only when the time comes. It is encrypted on your device; nothing is ever uploaded.', + write: 'Write a new letter', open: 'Open a letter', how: 'How it works', + steps: [ + ['Write', 'Your final message + account logins and passwords.'], + ['Lock on your device', 'Encrypted with AES-256 right here. Nothing is uploaded to any server.'], + ['Choose the keys', 'Protect it with a password, family shares, or both.'], + ['Opened later', 'When the time comes, family unlocks it with the password or by combining shares.'], + ], + sharesTitle: 'The clever part: family shares', + yourLetter: 'Your encrypted letter', + splitLabel: 'split into 5 shares — give one to each relative', + any3: 'any 3 unlock it →', spare: '2 spare — can be lost', + sharesBody: <>All 5 shares go to 5 people. Any 3 of them — it doesn’t matter which 3 — together open the letter, so no single person can open it alone and up to 2 shares can be lost. The solid vs. dashed boxes above just show one example trio; you pick the numbers (2-of-3, 3-of-5, 4-of-7…)., + privacyTitle: 'Private by design.', + privacyBody: 'Everything is encrypted and decrypted in your browser. Your passwords are never sent anywhere — not even to us.', + handoffTitle: 'You arrange the handoff.', + handoffBody: 'A private tool can’t detect when you’ve died, so it can’t auto-release. Share the password or the shares so they only come together when the time comes — tip: give one share to your lawyer or executor.', + }, + id: { + intro: 'Tulis surat pribadi — kata sandi, akun, dan pesan terakhir — yang hanya bisa dibuka keluarga saat waktunya tiba. Dienkripsi di perangkat Anda; tidak ada yang pernah diunggah.', + write: 'Tulis surat baru', open: 'Buka surat', how: 'Cara kerjanya', + steps: [ + ['Tulis', 'Pesan terakhir Anda + login dan kata sandi akun.'], + ['Kunci di perangkat Anda', 'Dienkripsi dengan AES-256 langsung di sini. Tidak ada yang diunggah ke server.'], + ['Pilih kuncinya', 'Lindungi dengan kata sandi, bagian keluarga, atau keduanya.'], + ['Dibuka nanti', 'Saat waktunya tiba, keluarga membukanya dengan kata sandi atau dengan menggabungkan bagian.'], + ], + sharesTitle: 'Bagian cerdasnya: bagian keluarga', + yourLetter: 'Surat terenkripsi Anda', + splitLabel: 'dibagi menjadi 5 bagian — berikan satu ke tiap kerabat', + any3: '3 mana pun membukanya →', spare: '2 cadangan — boleh hilang', + sharesBody: <>Kelima bagian diberikan kepada 5 orang. 3 bagian mana pun — tidak masalah yang mana — bersama-sama membuka surat, jadi tidak ada satu orang pun yang bisa membukanya sendirian dan hingga 2 bagian boleh hilang. Kotak solid vs. garis putus-putus di atas hanya menunjukkan satu contoh trio; Anda memilih angkanya (2-dari-3, 3-dari-5, 4-dari-7…)., + privacyTitle: 'Privat secara desain.', + privacyBody: 'Semuanya dienkripsi dan didekripsi di browser Anda. Kata sandi Anda tidak pernah dikirim ke mana pun — bahkan tidak ke kami.', + handoffTitle: 'Anda mengatur penyerahannya.', + handoffBody: 'Tool privat tidak bisa mengetahui kapan Anda meninggal, jadi tidak bisa merilis otomatis. Bagikan kata sandi atau bagian-bagiannya agar hanya dapat disatukan saat waktunya tiba — tips: berikan satu bagian kepada notaris atau pelaksana wasiat Anda.', + }, +}; + +/** Illustrated explainer shown on the landing screen. */ +function HowItWorks({ lang }: { lang: Lang }) { + const tr = T[lang] ?? T.en; + return ( +
+

{tr.how}

+ + {/* Four-step flow */} +
+ {SCENES.map((Scene, i) => ( +
+ {i + 1} +
+ +
+

{tr.steps[i][0]}

+

{tr.steps[i][1]}

+
+ ))} +
+ + {/* The "family shares" concept, illustrated */} +
+

{tr.sharesTitle}

+
+
+ {tr.yourLetter} +
+ +

{tr.splitLabel}

+
+
+
+ {[1, 2, 3].map(n => ( +
+ {n} +
+ ))} +
+

{tr.any3}

+
+
+
+ {[4, 5].map(n => ( +
+ {n} +
+ ))} +
+

{tr.spare}

+
+
+

{tr.sharesBody}

+
+
+ + {/* Privacy + the honest caveat */} +
+
+ +

{tr.privacyTitle} {tr.privacyBody}

+
+
+ +

{tr.handoffTitle} {tr.handoffBody}

+
+
+
+ ); +} + +export default function LegacyLetter({ lang = 'en' }: { lang?: Lang }) { const [mode, setMode] = useState(null); + const tr = T[lang] ?? T.en; return (
{mode === null && ( -
-

- Write a private letter — passwords, accounts, and final words — that your family can open only - when the time comes. It is encrypted on your device; nothing is ever uploaded. -

+
+

{tr.intro}

- - + +
+
)} {mode === 'create' && setMode(null)} />} diff --git a/src/pages/[...locale]/tools/[tool].astro b/src/pages/[...locale]/tools/[tool].astro index dce76dd..c88bf2f 100644 --- a/src/pages/[...locale]/tools/[tool].astro +++ b/src/pages/[...locale]/tools/[tool].astro @@ -121,7 +121,7 @@ if (seo?.faqs && seo.faqs.length > 0) {

{lead}

- + {(seo?.howTo?.length || seo?.faqs?.length || relatedTools.length > 0) && (