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
2 changes: 1 addition & 1 deletion src/components/shell/LangSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { LOCALES, LOCALE_LABEL, LOCALE_NAME, localizePath, stripLocale, type Lan

// Public sections that exist in every locale. Other paths (about, settings…) fall
// back to the locale home when switching, so the switcher never lands on a 404.
const LOCALIZED_PREFIXES = ['/tools/', '/category/'];
const LOCALIZED_PREFIXES = ['/tools/', '/category/', '/about'];

/** The URL for the current page in `lang` — computed fresh from the live location. */
function targetFor(lang: Lang): string {
Expand Down
73 changes: 52 additions & 21 deletions src/components/shell/ShellIsland.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Search, Github, Info, ExternalLink, Settings, MoreVertical } from 'lucide-react';
import { ThemeToggle } from './ThemeToggle';
import { LangSwitcher } from './LangSwitcher';
Expand All @@ -7,6 +7,7 @@ import { Modal } from '@/components/ui/Modal';
import { initTheme } from '@/stores/theme.store';
import { isTauri } from '@/services/platform';
import { REPO_URL } from '@/config';
import type { Lang } from '@/i18n/config';

/** Opens the command palette from a click (works without a keyboard). */
export function openSearch() {
Expand All @@ -18,18 +19,56 @@ const iconBtn =
const menuItem =
'flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm font-bold hover:bg-accent hover:text-accent-foreground';

// Header UI strings, per language (the header persists across navigations, so the
// language is detected from the URL client-side rather than passed as a prop).
const S: Record<Lang, {
searchAria: string; searchPre: string; searchPost: string;
about: string; contribute: string; contribMenu: string; more: string; settings: string;
modalTitle: string; modalBody: ReactNode;
}> = {
en: {
searchAria: 'Search tools', searchPre: 'Press', searchPost: 'to search',
about: 'About', contribute: 'Contribute on GitHub', contribMenu: 'Contribute', more: 'More', settings: 'Settings',
modalTitle: 'Contribute',
modalBody: (
<>
<p><span className="font-bold">GoodWebTools is open source.</span> Every tool runs entirely in your browser using modern web APIs and WebAssembly — nothing is uploaded.</p>
<p>Got an idea for a tool that could work fully client-side? Or found a bug? Please <span className="font-bold">open an issue</span>, or <span className="font-bold">fork the repo and send a pull request</span> — contributions and requests are very welcome.</p>
</>
),
},
id: {
searchAria: 'Cari tool', searchPre: 'Tekan', searchPost: 'untuk mencari',
about: 'Tentang', contribute: 'Kontribusi di GitHub', contribMenu: 'Kontribusi', more: 'Lainnya', settings: 'Pengaturan',
modalTitle: 'Kontribusi',
modalBody: (
<>
<p><span className="font-bold">GoodWebTools bersifat sumber terbuka.</span> Setiap tool berjalan sepenuhnya di browser Anda menggunakan API web modern dan WebAssembly — tidak ada yang diunggah.</p>
<p>Punya ide tool yang bisa berjalan sepenuhnya di sisi klien? Atau menemukan bug? Silakan <span className="font-bold">buka issue</span>, atau <span className="font-bold">fork repo dan kirim pull request</span> — kontribusi dan permintaan sangat kami hargai.</p>
</>
),
},
};

export function ShellIsland() {
const [modal, setModal] = useState<null | 'github'>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [lang, setLang] = useState<Lang>('en');
// Settings only apply to the desktop app; hide the nav link on the web.
// Starts false so SSR and the first client render match, then reveals on
// desktop after mount (avoids a hydration mismatch).
const [isDesktop, setIsDesktop] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const s = S[lang];

useEffect(() => {
initTheme();
setIsDesktop(isTauri());
// Detect language from the URL, and keep it current across view transitions.
const syncLang = () => setLang(/^\/id(\/|$)/.test(location.pathname) ? 'id' : 'en');
syncLang();
document.addEventListener('astro:page-load', syncLang);
return () => document.removeEventListener('astro:page-load', syncLang);
}, []);

// Close the mobile overflow menu on outside click, Escape, or navigation.
Expand Down Expand Up @@ -62,43 +101,43 @@ export function ShellIsland() {
<div className="flex items-center gap-2 sm:gap-3">
<button
onClick={openSearch}
aria-label="Search tools"
aria-label={s.searchAria}
className="flex h-9 items-center gap-1.5 border-2 border-border bg-muted px-2.5 text-sm font-bold uppercase tracking-wide text-muted-foreground shadow-brutal-sm press-brutal"
>
<Search className="h-4 w-4" />
<span className="hidden md:inline">
Press{' '}
<kbd className="border-2 border-border bg-background px-1.5 py-0.5 text-xs">⌘K</kbd> to search
{s.searchPre}{' '}
<kbd className="border-2 border-border bg-background px-1.5 py-0.5 text-xs">⌘K</kbd> {s.searchPost}
</span>
</button>

{/* Secondary actions — inline on ≥sm, in an overflow menu on mobile. */}
<div className="hidden items-center gap-2 sm:flex sm:gap-3">
{isDesktop && (
<a href="/settings" aria-label="Settings" title="Settings" className={iconBtn}>
<a href="/settings" aria-label={s.settings} title={s.settings} className={iconBtn}>
<Settings className="h-4 w-4" />
</a>
)}
<a href="/about" aria-label="About" title="About" className={iconBtn}>
<a href="/about" aria-label={s.about} title={s.about} className={iconBtn}>
<Info className="h-4 w-4" />
</a>
<button onClick={() => setModal('github')} aria-label="Contribute on GitHub" title="Contribute on GitHub" className={iconBtn}>
<button onClick={() => setModal('github')} aria-label={s.contribute} title={s.contribute} className={iconBtn}>
<Github className="h-4 w-4" />
</button>
</div>

<div className="relative sm:hidden" ref={menuRef}>
<button onClick={() => setMenuOpen(o => !o)} aria-label="More" aria-haspopup="menu" aria-expanded={menuOpen} className={iconBtn}>
<button onClick={() => setMenuOpen(o => !o)} aria-label={s.more} aria-haspopup="menu" aria-expanded={menuOpen} className={iconBtn}>
<MoreVertical className="h-4 w-4" />
</button>
{menuOpen && (
<div role="menu" className="absolute right-0 top-full z-50 mt-2 w-48 border-2 border-border bg-background shadow-brutal">
{isDesktop && (
<a href="/settings" role="menuitem" className={menuItem}><Settings className="h-4 w-4" /> Settings</a>
<a href="/settings" role="menuitem" className={menuItem}><Settings className="h-4 w-4" /> {s.settings}</a>
)}
<a href="/about" role="menuitem" className={menuItem}><Info className="h-4 w-4" /> About</a>
<a href="/about" role="menuitem" className={menuItem}><Info className="h-4 w-4" /> {s.about}</a>
<button role="menuitem" onClick={() => { setMenuOpen(false); setModal('github'); }} className={menuItem}>
<Github className="h-4 w-4" /> Contribute
<Github className="h-4 w-4" /> {s.contribMenu}
</button>
</div>
)}
Expand All @@ -114,17 +153,9 @@ export function ShellIsland() {
<CommandPalette />

{modal === 'github' && (
<Modal title="Contribute" onClose={() => setModal(null)}>
<Modal title={s.modalTitle} onClose={() => setModal(null)}>
<div className="space-y-3 text-sm">
<p>
<span className="font-bold">GoodWebTools is open source.</span> Every tool runs entirely
in your browser using modern web APIs and WebAssembly — nothing is uploaded.
</p>
<p>
Got an idea for a tool that could work fully client-side? Or found a bug? Please{' '}
<span className="font-bold">open an issue</span>, or <span className="font-bold">fork the
repo and send a pull request</span> — contributions and requests are very welcome.
</p>
{s.modalBody}
<a
href={REPO_URL}
target="_blank"
Expand Down
14 changes: 9 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ export const SITE_NAME = 'GoodWebTools';
export const SITE_TAGLINE = 'Privacy-first client-side utilities that run entirely in your browser';

/**
* Google Analytics measurement ID (e.g. "G-XXXXXXXXXX").
* Set it via the PUBLIC_GA_ID environment variable (see .env.example).
* Empty = analytics disabled. When set, GA still only loads after the visitor
* accepts cookies in the consent banner (GDPR).
* Google Analytics 4 measurement ID. A GA4 ID is not secret — it's visible in
* every visitor's page source — so it lives in the repo for reliability (a
* Cloudflare build var kept getting dropped). Forks/staging/localhost never report
* to it: the loader in Base.astro only runs GA on the production host(s) below, and
* only after cookie consent (GDPR). PUBLIC_GA_ID (build var) still overrides if set.
*/
export const GA_ID = import.meta.env.PUBLIC_GA_ID ?? '';
export const GA_ID = import.meta.env.PUBLIC_GA_ID || 'G-4Q9F8CL7FW';

/** Hostnames on which analytics may run (keeps forks/staging/localhost out). */
export const GA_ALLOWED_HOSTS = ['goodwebtools.com', 'www.goodwebtools.com'];

/**
* When PUBLIC_NOINDEX is "1"/"true", every page emits robots noindex. Set it on
Expand Down
21 changes: 19 additions & 2 deletions src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ViewTransitions } from 'astro:transitions';
import { ShellIsland } from '@/components/shell/ShellIsland';
import { GlobalHotkeyInit } from '@/components/shell/GlobalHotkeyInit';
import { ConsentRibbon } from '@/components/shell/ConsentRibbon';
import { SITE_NAME, SITE_TAGLINE, SITE_URL, GA_ID, NOINDEX } from '@/config';
import { SITE_NAME, SITE_TAGLINE, SITE_URL, GA_ID, GA_ALLOWED_HOSTS, NOINDEX } from '@/config';
import { LOCALES, DEFAULT_LOCALE, localizePath, stripLocale, type Lang } from '@/i18n/config';
import '../styles/global.css';

Expand Down Expand Up @@ -141,12 +141,29 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ?
var el = e.target && e.target.closest && e.target.closest('#home-search');
if (el) window.dispatchEvent(new CustomEvent('gwt:open-search'));
});

// Assemble contact emails from parts so the address never appears in the
// static HTML — deters scrapers/spammers. Real address shown only to JS.
function revealEmails() {
document.querySelectorAll('a[data-email-user]').forEach(function (el) {
if (el.dataset.emailDone) return;
var addr = el.getAttribute('data-email-user') + '@' + el.getAttribute('data-email-domain');
el.setAttribute('href', 'mailto:' + addr);
el.textContent = addr;
el.dataset.emailDone = '1';
});
}
revealEmails();
document.addEventListener('astro:page-load', revealEmails);
</script>

<!-- Google Analytics (GDPR): loads only when configured AND consent granted. -->
<script is:inline define:vars={{ gaId: GA_ID }}>
<script is:inline define:vars={{ gaId: GA_ID, gaHosts: GA_ALLOWED_HOSTS }}>
(function () {
if (!gaId) return;
// Only report from the production host(s) — forks, staging, previews and
// localhost carry the same ID in their HTML but must never pollute the data.
if (gaHosts.indexOf(location.hostname) === -1) return;
function loadGA() {
if (window.__gaLoaded) return;
window.__gaLoaded = true;
Expand Down
123 changes: 123 additions & 0 deletions src/pages/[...locale]/about.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
import { ChevronRight, Github, ExternalLink, Bookmark, Mail } from 'lucide-react';
import Base from '@/layouts/Base.astro';
import { REPO_URL } from '@/config';
import { LOCALES, localeParam, localizePath, type Lang } from '@/i18n/config';

export function getStaticPaths() {
return LOCALES.map(lang => ({ params: { locale: localeParam(lang) }, props: { lang } }));
}

const { lang } = Astro.props as { lang: Lang };
const homePath = localizePath('/', lang);

const A = {
en: {
title: 'About', metaDesc: 'Why GoodWebTools exists — modern web capability, privacy-first, and your files never leave your browser.',
crumb: 'About', home: 'Home', h1: 'About GoodWebTools',
whyH: 'Why this exists',
whyP: "Modern browsers are incredibly capable. With WebAssembly and today's web APIs, things that used to need a desktop app or a server — parsing PDFs, encrypting files, hashing gigabytes, extracting archives, editing images — can run right inside a browser tab. I built GoodWebTools to put that power behind simple, focused pages: open a tool, get your result, done.",
privH: 'Privacy first',
privP1: "Everything runs on your device. Your files, text, and passwords never leave your browser — there's no upload and no account. The only data collected is anonymous, consent-based usage analytics (which loads only if you accept the cookie banner) so I can see which tools are worth improving. Your files are never part of it.",
privP2: "To help cover the costs of running the site, I may show a few unobtrusive ads. That's the extent of the monetization — your data is never part of the deal.",
contactH: 'Contact & tool requests',
contactPre: 'Questions, feedback, or a bug to report? Email us at',
contactReq: "Want a tool that doesn't exist yet? Email the same address and tell us what you need — if it can run fully in the browser, it's a great candidate to build.",
foundH: 'Found it useful?',
foundPre: "If a tool saved you time, bookmark GoodWebTools so it's here next time you need it — press",
foundMac: 'on a Mac or', foundEnd: 'elsewhere.',
osH: 'Open source',
osP: 'GoodWebTools is open source. If you have an idea for a tool that could work fully in the browser, or you want to fix or improve something, open an issue or send a pull request — contributions and requests are very welcome.',
},
id: {
title: 'Tentang', metaDesc: 'Mengapa GoodWebTools ada — kemampuan web modern, mengutamakan privasi, dan berkas Anda tidak pernah meninggalkan browser.',
crumb: 'Tentang', home: 'Beranda', h1: 'Tentang GoodWebTools',
whyH: 'Mengapa ini ada',
whyP: 'Browser modern sangat mumpuni. Dengan WebAssembly dan API web masa kini, hal-hal yang dulu memerlukan aplikasi desktop atau server — mengurai PDF, mengenkripsi berkas, meng-hash gigabita data, mengekstrak arsip, mengedit gambar — kini bisa berjalan langsung di dalam tab browser. Saya membuat GoodWebTools untuk menghadirkan kemampuan itu lewat halaman yang sederhana dan fokus: buka tool, dapatkan hasilnya, selesai.',
privH: 'Privasi yang utama',
privP1: 'Semuanya berjalan di perangkat Anda. Berkas, teks, dan kata sandi Anda tidak pernah meninggalkan browser — tidak ada unggahan dan tidak ada akun. Satu-satunya data yang dikumpulkan adalah analitik penggunaan anonim berbasis persetujuan (yang hanya dimuat jika Anda menerima banner cookie) agar saya tahu tool mana yang layak ditingkatkan. Berkas Anda tidak pernah menjadi bagiannya.',
privP2: 'Untuk membantu menutup biaya menjalankan situs, saya mungkin menampilkan sedikit iklan yang tidak mengganggu. Hanya sebatas itu monetisasinya — data Anda tidak pernah menjadi bagian dari kesepakatan.',
contactH: 'Kontak & permintaan tool',
contactPre: 'Ada pertanyaan, masukan, atau bug untuk dilaporkan? Kirim email ke kami di',
contactReq: 'Ingin tool yang belum ada? Kirim email ke alamat yang sama dan beri tahu kami kebutuhan Anda — jika bisa berjalan sepenuhnya di browser, itu kandidat yang bagus untuk dibuat.',
foundH: 'Merasa terbantu?',
foundPre: 'Jika sebuah tool menghemat waktu Anda, tandai GoodWebTools (bookmark) agar mudah ditemukan lagi — tekan',
foundMac: 'di Mac atau', foundEnd: 'di tempat lain.',
osH: 'Sumber terbuka',
osP: 'GoodWebTools bersifat sumber terbuka. Jika Anda punya ide tool yang bisa berjalan sepenuhnya di browser, atau ingin memperbaiki atau meningkatkan sesuatu, buka issue atau kirim pull request — kontribusi dan permintaan sangat kami hargai.',
},
}[lang];
---

<Base title={A.title} description={A.metaDesc} lang={lang} localized>
<main class="page-container py-8">
<nav aria-label="Breadcrumb" class="mb-4">
<ol class="flex items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<li>
<a href={homePath} class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">{A.home}</a>
</li>
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
<li aria-current="page" class="text-foreground">{A.crumb}</li>
</ol>
</nav>

<h1 class="mb-6 text-3xl font-bold uppercase tracking-tight">{A.h1}</h1>

<div class="max-w-2xl space-y-6 text-base leading-relaxed">
<section class="space-y-3">
<h2 class="text-xl font-bold uppercase tracking-tight">{A.whyH}</h2>
<p>{A.whyP}</p>
</section>

<section class="space-y-3 border-2 border-border bg-muted p-4 shadow-brutal-sm">
<h2 class="text-xl font-bold uppercase tracking-tight">{A.privH}</h2>
<p>{A.privP1}</p>
<p class="text-muted-foreground">{A.privP2}</p>
</section>

<section class="space-y-3 border-2 border-border bg-accent/10 p-4 shadow-brutal-sm">
<h2 class="flex items-center gap-2 text-xl font-bold uppercase tracking-tight">
<Mail className="h-5 w-5" /> {A.contactH}
</h2>
<p>
{A.contactPre}{' '}
<a
data-email-user="contact"
data-email-domain="goodwebtools.com"
href="#"
class="font-bold underline decoration-2 underline-offset-2"
>contact [at] goodwebtools [dot] com</a>.
</p>
<p>{A.contactReq}</p>
</section>

<section class="space-y-3">
<h2 class="flex items-center gap-2 text-xl font-bold uppercase tracking-tight">
<Bookmark className="h-5 w-5" /> {A.foundH}
</h2>
<p>
{A.foundPre}
<kbd class="mx-0.5 border-2 border-border bg-muted px-1.5 py-0.5 font-mono text-sm">⌘ D</kbd>
{A.foundMac}
<kbd class="mx-0.5 border-2 border-border bg-muted px-1.5 py-0.5 font-mono text-sm">Ctrl + D</kbd>
{A.foundEnd}
</p>
</section>

<section class="space-y-3">
<h2 class="text-xl font-bold uppercase tracking-tight">{A.osH}</h2>
<p>{A.osP}</p>
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 border-2 border-border bg-accent px-4 py-2 font-bold uppercase tracking-wide text-accent-foreground shadow-brutal press-brutal"
>
<Github className="h-4 w-4" />
slaveofcode/goodwebtools
<ExternalLink className="h-3.5 w-3.5" />
</a>
</section>
</div>
</main>
</Base>
Loading
Loading