From 5a4068842f04c33dd653ac955def61fed0d85eb8 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sun, 2 Aug 2026 00:37:10 +0700 Subject: [PATCH] feat(share): simple Share button on every tool page + homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X, Facebook, WhatsApp, Telegram, Email, Copy-link — plain share-intent URLs, no third-party SDKs/trackers (on-brand: nothing loaded from Meta/X). - Native share sheet (navigator.share) as the primary action on supported devices; the explicit channel row is the desktop/fallback path. - Pure share.lib.ts (intent-URL builder + native-share detection), unit-tested; thin island with accurate monochrome brand glyphs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ubfx4XocHcECaL8twp9zsr --- src/islands/share/ShareButton.tsx | 118 ++++++++++++++++++++++++++++++ src/pages/index.astro | 6 +- src/pages/tools/[tool].astro | 18 +++-- src/tools/share/share.lib.test.ts | 61 +++++++++++++++ src/tools/share/share.lib.ts | 50 +++++++++++++ 5 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 src/islands/share/ShareButton.tsx create mode 100644 src/tools/share/share.lib.test.ts create mode 100644 src/tools/share/share.lib.ts diff --git a/src/islands/share/ShareButton.tsx b/src/islands/share/ShareButton.tsx new file mode 100644 index 0000000..02dcd59 --- /dev/null +++ b/src/islands/share/ShareButton.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react'; +import { Share2, Mail, Link2, Check, Smartphone } from 'lucide-react'; +import { shareIntentUrl, canNativeShare, type ShareChannel } from '@/tools/share/share.lib'; + +interface ShareButtonProps { + url: string; + title: string; + text?: string; + /** Optional extra classes for the wrapper. */ + className?: string; +} + +// Accurate brand glyphs (monochrome, currentColor) — lucide's socials are the old +// marks, so we inline simple-icons paths for X/Facebook/WhatsApp/Telegram. +function BrandIcon({ channel }: { channel: ShareChannel }) { + const common = { className: 'h-4 w-4', viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true } as const; + switch (channel) { + case 'x': + return ( + + ); + case 'facebook': + return ( + + ); + case 'whatsapp': + return ( + + ); + case 'telegram': + return ( + + ); + default: + return null; + } +} + +const ICON_BTN = + 'inline-flex h-9 w-9 items-center justify-center border-2 border-border bg-muted text-foreground shadow-brutal-sm press-brutal focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent'; + +export default function ShareButton({ url, title, text, className = '' }: ShareButtonProps) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const [native, setNative] = useState(false); + const rootRef = useRef(null); + + useEffect(() => setNative(canNativeShare()), []); + + // Close the row on outside click or Escape. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; + }, [open]); + + const openIntent = (channel: Exclude) => { + const href = shareIntentUrl(channel, { url, title, text }); + if (channel === 'email') { window.location.href = href; return; } + window.open(href, '_blank', 'noopener,noreferrer,width=600,height=520'); + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { /* clipboard blocked — ignore */ } + }; + + const nativeShare = async () => { + try { await navigator.share({ title, text: text ?? title, url }); setOpen(false); } + catch { /* user cancelled — ignore */ } + }; + + const onMain = () => { if (native) void nativeShare(); else setOpen(o => !o); }; + + return ( +
+ + + {/* Native devices get the main button; a caret still exposes explicit channels. */} + {native && ( + + )} + + {open && ( +
+ {native && ( + + )} + + + + + + +
+ )} +
+ ); +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 70681ad..a1bd6ba 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,8 +1,9 @@ --- import Base from '@/layouts/Base.astro'; import { ToolGrid } from '@/components/ToolGrid'; +import ShareButton from '@/islands/share/ShareButton'; import { tools } from '@/registry/tools'; -import { SITE_URL, SITE_NAME } from '@/config'; +import { SITE_URL, SITE_NAME, SITE_TAGLINE } from '@/config'; const homeTitle = `${SITE_NAME} — Free Privacy-First Browser Tools`; const homeDescription = `${tools.length} free online tools that run entirely in your browser — PDF, image, file, developer, and media utilities. No uploads, no sign-up, works offline. Your files never leave your device.`; @@ -51,6 +52,9 @@ const toolList = { ⌘K to search tools +
+ +
diff --git a/src/pages/tools/[tool].astro b/src/pages/tools/[tool].astro index e36f1fa..0b05376 100644 --- a/src/pages/tools/[tool].astro +++ b/src/pages/tools/[tool].astro @@ -2,6 +2,7 @@ import { ChevronRight } from 'lucide-react'; import Base from '@/layouts/Base.astro'; import ToolHost from '@/islands/ToolHost'; +import ShareButton from '@/islands/share/ShareButton'; import { tools, getToolById } from '@/registry/tools'; import { SITE_URL, SITE_NAME } from '@/config'; @@ -63,13 +64,16 @@ const breadcrumbJsonLd = {
-
-

{tool.name}

- {tool.status !== 'stable' && ( - - {tool.status} - - )} +
+
+

{tool.name}

+ {tool.status !== 'stable' && ( + + {tool.status} + + )} +
+

{tool.summary}

diff --git a/src/tools/share/share.lib.test.ts b/src/tools/share/share.lib.test.ts new file mode 100644 index 0000000..91794db --- /dev/null +++ b/src/tools/share/share.lib.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { shareIntentUrl, canNativeShare, SHARE_CHANNELS } from './share.lib'; + +const target = { url: 'https://goodwebtools.com/tools/json-format', title: 'JSON Formatter', text: 'Format & validate JSON' }; +const encUrl = encodeURIComponent(target.url); +const encText = encodeURIComponent(target.text); + +describe('shareIntentUrl', () => { + it('builds an X (Twitter) intent with url + text', () => { + expect(shareIntentUrl('x', target)).toBe(`https://x.com/intent/tweet?url=${encUrl}&text=${encText}`); + }); + + it('builds a Facebook sharer with the url', () => { + expect(shareIntentUrl('facebook', target)).toBe(`https://www.facebook.com/sharer/sharer.php?u=${encUrl}`); + }); + + it('builds a WhatsApp intent joining text and url', () => { + expect(shareIntentUrl('whatsapp', target)).toBe(`https://api.whatsapp.com/send?text=${encText}%20${encUrl}`); + }); + + it('builds a Telegram share url', () => { + expect(shareIntentUrl('telegram', target)).toBe(`https://t.me/share/url?url=${encUrl}&text=${encText}`); + }); + + it('builds a mailto with subject and body', () => { + expect(shareIntentUrl('email', target)).toBe( + `mailto:?subject=${encodeURIComponent(target.title)}&body=${encText}%20${encUrl}`, + ); + }); + + it('falls back to the title when no text is given', () => { + const t = { url: 'https://x.test/', title: 'Hi there' }; + expect(shareIntentUrl('x', t)).toContain(`text=${encodeURIComponent('Hi there')}`); + }); + + it('percent-encodes special characters so the URL is safe', () => { + const t = { url: 'https://x.test/?a=1&b=2', title: 'A & B?' }; + const out = shareIntentUrl('telegram', t); + expect(out).toContain(encodeURIComponent('https://x.test/?a=1&b=2')); + expect(out).not.toContain('A & B?'); + }); +}); + +describe('canNativeShare', () => { + it('is true when navigator.share is a function', () => { + expect(canNativeShare({ share: () => Promise.resolve() } as unknown as Navigator)).toBe(true); + }); + it('is false when share is absent', () => { + expect(canNativeShare({} as Navigator)).toBe(false); + }); + it('is false when navigator is undefined (SSR)', () => { + expect(canNativeShare(undefined)).toBe(false); + }); +}); + +describe('SHARE_CHANNELS', () => { + it('includes the requested channels and copy', () => { + const ids = SHARE_CHANNELS.map(c => c.id); + expect(ids).toEqual(['x', 'facebook', 'whatsapp', 'telegram', 'email', 'copy']); + }); +}); diff --git a/src/tools/share/share.lib.ts b/src/tools/share/share.lib.ts new file mode 100644 index 0000000..f3318a2 --- /dev/null +++ b/src/tools/share/share.lib.ts @@ -0,0 +1,50 @@ +/** + * Share-intent helpers for the Share button. Pure and framework-free so the + * island stays thin. No third-party SDKs — every channel is a plain intent URL + * (nothing is loaded from Meta/X/etc.), matching the site's privacy stance. + */ + +export type ShareChannel = 'x' | 'facebook' | 'whatsapp' | 'telegram' | 'email' | 'copy'; + +export interface ShareTarget { + /** Canonical URL being shared. */ + url: string; + /** Human title (page/tool name). */ + title: string; + /** Optional longer blurb; falls back to the title. */ + text?: string; +} + +/** Channels shown in the UI, in order. `copy` is handled specially (no intent URL). */ +export const SHARE_CHANNELS: { id: ShareChannel; label: string }[] = [ + { id: 'x', label: 'X' }, + { id: 'facebook', label: 'Facebook' }, + { id: 'whatsapp', label: 'WhatsApp' }, + { id: 'telegram', label: 'Telegram' }, + { id: 'email', label: 'Email' }, + { id: 'copy', label: 'Copy link' }, +]; + +/** The share-intent URL for a channel. `copy` has none — copy the URL directly instead. */ +export function shareIntentUrl(channel: Exclude, target: ShareTarget): string { + const url = encodeURIComponent(target.url); + const title = encodeURIComponent(target.title); + const text = encodeURIComponent(target.text ?? target.title); + switch (channel) { + case 'x': + return `https://x.com/intent/tweet?url=${url}&text=${text}`; + case 'facebook': + return `https://www.facebook.com/sharer/sharer.php?u=${url}`; + case 'whatsapp': + return `https://api.whatsapp.com/send?text=${text}%20${url}`; + case 'telegram': + return `https://t.me/share/url?url=${url}&text=${text}`; + case 'email': + return `mailto:?subject=${title}&body=${text}%20${url}`; + } +} + +/** Whether the browser exposes the native share sheet (mobile, some desktop). */ +export function canNativeShare(nav: Navigator | undefined = typeof navigator !== 'undefined' ? navigator : undefined): boolean { + return !!nav && typeof nav.share === 'function'; +}