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
13 changes: 9 additions & 4 deletions src/components/ToolGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ArrowRight } from 'lucide-react';
import { tools } from '@/registry/tools';
import { categories, categoryColors, categoryNotes } from '@/registry/categories';
import { categories, categoryColors, categoryNotes, categorySlug } from '@/registry/categories';
import { localizePath, DEFAULT_LOCALE, type Lang } from '@/i18n/config';

/**
Expand All @@ -18,15 +19,19 @@ export function ToolGrid({ lang = DEFAULT_LOCALE }: { lang?: Lang }) {
const categoryTools = tools.filter(tool => tool.category === category);
return (
<section key={category}>
<div className="mb-4 flex items-center gap-2">
<a
href={localizePath(`/category/${categorySlug(category)}`, lang)}
className="group mb-4 inline-flex items-center gap-2"
>
<span
className={`inline-block h-4 w-4 border-2 border-border ${categoryColors[category]}`}
/>
<h2 className="text-xl font-bold uppercase tracking-tight">{category}</h2>
<h2 className="text-xl font-bold uppercase tracking-tight group-hover:underline">{category}</h2>
<span className="text-sm font-bold text-muted-foreground">
({categoryTools.filter(tool => !tool.desktopOnly).length})
</span>
</div>
<ArrowRight className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100" />
</a>
{categoryNotes[category] && (
<p className="mb-4 max-w-3xl text-sm text-muted-foreground">{categoryNotes[category]}</p>
)}
Expand Down
35 changes: 26 additions & 9 deletions src/components/shell/LangSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,49 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, type MouseEvent } from 'react';
import { LOCALES, LOCALE_LABEL, LOCALE_NAME, localizePath, stripLocale, type Lang } from '@/i18n/config';

// 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/'];

/** The URL for the current page in `lang` — computed fresh from the live location. */
function targetFor(lang: Lang): string {
const base = stripLocale(location.pathname);
const usable = base === '/' || LOCALIZED_PREFIXES.some(p => base.startsWith(p)) ? base : '/';
return localizePath(usable, lang);
}

export function LangSwitcher() {
const [current, setCurrent] = useState<Lang>('en');
const [base, setBase] = useState('/');
// Real hrefs (for right-click / open-in-new-tab / no-JS). Recomputed on every
// navigation — the header persists across view transitions, so a one-time
// computation would go stale and send you to a previously-visited page.
const [hrefs, setHrefs] = useState<Record<Lang, string>>({ en: '/', id: '/id/' });

useEffect(() => {
const path = location.pathname;
setCurrent(/^\/id(\/|$)/.test(path) ? 'id' : 'en');
const b = stripLocale(path);
setBase(b === '/' || LOCALIZED_PREFIXES.some(p => b.startsWith(p)) ? b : '/');
const update = () => {
setCurrent(/^\/id(\/|$)/.test(location.pathname) ? 'id' : 'en');
setHrefs({ en: targetFor('en'), id: targetFor('id') });
};
update();
document.addEventListener('astro:page-load', update);
return () => document.removeEventListener('astro:page-load', update);
}, []);

const remember = (l: Lang) => {
const pick = (l: Lang) => (e: MouseEvent) => {
// Remember the choice, then navigate to the freshly-computed target for the
// page the user is actually on (belt-and-suspenders against any stale href).
document.cookie = `gwt.lang=${l};path=/;max-age=31536000;samesite=lax`;
e.preventDefault();
location.href = targetFor(l);
};

return (
<div className="flex items-center border-2 border-border shadow-brutal-sm" role="group" aria-label="Language">
{LOCALES.map(l => (
<a
key={l}
href={localizePath(base, l)}
onClick={() => remember(l)}
href={hrefs[l]}
onClick={pick(l)}
aria-current={current === l ? 'true' : undefined}
aria-label={LOCALE_NAME[l]}
title={LOCALE_NAME[l]}
Expand Down
93 changes: 56 additions & 37 deletions src/components/shell/ShellIsland.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Search, Github, Bookmark, Info, ExternalLink, Settings } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Search, Github, Info, ExternalLink, Settings, MoreVertical } from 'lucide-react';
import { ThemeToggle } from './ThemeToggle';
import { LangSwitcher } from './LangSwitcher';
import { CommandPalette } from './CommandPalette';
Expand All @@ -14,23 +14,39 @@ export function openSearch() {
}

const iconBtn =
'border-2 border-border bg-muted p-2 shadow-brutal-sm press-brutal text-muted-foreground';
'flex h-9 w-9 items-center justify-center border-2 border-border bg-muted shadow-brutal-sm press-brutal text-muted-foreground';
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';

export function ShellIsland() {
const [modal, setModal] = useState<null | 'github' | 'bookmark'>(null);
const [isMac, setIsMac] = useState(true);
const [modal, setModal] = useState<null | 'github'>(null);
const [menuOpen, setMenuOpen] = useState(false);
// 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);

useEffect(() => {
initTheme();
setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent));
setIsDesktop(isTauri());
}, []);

const bookmarkKey = isMac ? '⌘ D' : 'Ctrl + D';
// Close the mobile overflow menu on outside click, Escape, or navigation.
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenuOpen(false); };
const onNav = () => setMenuOpen(false);
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
document.addEventListener('astro:page-load', onNav);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
document.removeEventListener('astro:page-load', onNav);
};
}, [menuOpen]);

return (
<>
Expand All @@ -47,28 +63,47 @@ export function ShellIsland() {
<button
onClick={openSearch}
aria-label="Search tools"
className="flex items-center gap-1.5 border-2 border-border bg-muted px-2 py-1.5 text-sm font-bold uppercase tracking-wide text-muted-foreground shadow-brutal-sm press-brutal"
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
</span>
</button>
{isDesktop && (
<a href="/settings" aria-label="Settings" title="Settings" className={iconBtn}>
<Settings className="h-4 w-4" />

{/* 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}>
<Settings className="h-4 w-4" />
</a>
)}
<a href="/about" aria-label="About" title="About" className={iconBtn}>
<Info className="h-4 w-4" />
</a>
)}
<a href="/about" aria-label="About" title="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}>
<Github className="h-4 w-4" />
</button>
<button onClick={() => setModal('bookmark')} aria-label="Bookmark this site" title="Bookmark this site" className={iconBtn}>
<Bookmark className="h-4 w-4" />
</button>
<button onClick={() => setModal('github')} aria-label="Contribute on GitHub" title="Contribute on GitHub" 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}>
<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="/about" role="menuitem" className={menuItem}><Info className="h-4 w-4" /> About</a>
<button role="menuitem" onClick={() => { setMenuOpen(false); setModal('github'); }} className={menuItem}>
<Github className="h-4 w-4" /> Contribute
</button>
</div>
)}
</div>

<LangSwitcher />
<ThemeToggle />
</div>
Expand Down Expand Up @@ -103,22 +138,6 @@ export function ShellIsland() {
</div>
</Modal>
)}

{modal === 'bookmark' && (
<Modal title="Bookmark this site" onClose={() => setModal(null)}>
<div className="space-y-3 text-sm">
<p>Keep GoodWebTools one click away — add it to your bookmarks:</p>
<p className="flex items-center justify-center gap-2 border-2 border-border bg-muted px-3 py-4 text-center">
<span>Press</span>
<kbd className="border-2 border-border bg-background px-2 py-1 font-bold">{bookmarkKey}</kbd>
</p>
<p className="text-muted-foreground">
Browsers don't allow a button to add bookmarks (for your security), so the keyboard
shortcut is the quickest way. You can also drag the address bar into your bookmarks.
</p>
</div>
</Modal>
)}
</>
);
}
7 changes: 7 additions & 0 deletions src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ const jsonLdBlocks = [siteJsonLd, ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ?
}
applyEnvClasses();
document.addEventListener('astro:after-swap', applyEnvClasses);

// Delegated on document (persists across view transitions) so the in-page
// "search tools" hint keeps working after client-side navigations.
document.addEventListener('click', function (e) {
var el = e.target && e.target.closest && e.target.closest('#home-search');
if (el) window.dispatchEvent(new CustomEvent('gwt:open-search'));
});
</script>

<!-- Google Analytics (GDPR): loads only when configured AND consent granted. -->
Expand Down
7 changes: 0 additions & 7 deletions src/pages/[...locale]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,4 @@ const toolList = {

<ToolGrid lang={lang} />
</main>

<script>
// Let non-keyboard users open the command palette by clicking the hint.
document.getElementById('home-search')?.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('gwt:open-search'));
});
</script>
</Base>
Loading