diff --git a/src/components/ToolGrid.tsx b/src/components/ToolGrid.tsx index 55cd38f..2b5edaa 100644 --- a/src/components/ToolGrid.tsx +++ b/src/components/ToolGrid.tsx @@ -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'; /** @@ -18,15 +19,19 @@ export function ToolGrid({ lang = DEFAULT_LOCALE }: { lang?: Lang }) { const categoryTools = tools.filter(tool => tool.category === category); return (
-
+ -

{category}

+

{category}

({categoryTools.filter(tool => !tool.desktopOnly).length}) -
+ + {categoryNotes[category] && (

{categoryNotes[category]}

)} diff --git a/src/components/shell/LangSwitcher.tsx b/src/components/shell/LangSwitcher.tsx index f289703..0124c6f 100644 --- a/src/components/shell/LangSwitcher.tsx +++ b/src/components/shell/LangSwitcher.tsx @@ -1,23 +1,40 @@ -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('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>({ 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 ( @@ -25,8 +42,8 @@ export function LangSwitcher() { {LOCALES.map(l => ( remember(l)} + href={hrefs[l]} + onClick={pick(l)} aria-current={current === l ? 'true' : undefined} aria-label={LOCALE_NAME[l]} title={LOCALE_NAME[l]} diff --git a/src/components/shell/ShellIsland.tsx b/src/components/shell/ShellIsland.tsx index a24ccd3..ca51219 100644 --- a/src/components/shell/ShellIsland.tsx +++ b/src/components/shell/ShellIsland.tsx @@ -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'; @@ -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); - const [isMac, setIsMac] = useState(true); + const [modal, setModal] = useState(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(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 ( <> @@ -47,7 +63,7 @@ export function ShellIsland() { - {isDesktop && ( - - + + {/* Secondary actions — inline on ≥sm, in an overflow menu on mobile. */} +
+ {isDesktop && ( + + + + )} + + - )} - - - - - + +
+ +
+ + {menuOpen && ( +
+ {isDesktop && ( + Settings + )} + About + +
+ )} +
+ @@ -103,22 +138,6 @@ export function ShellIsland() { )} - - {modal === 'bookmark' && ( - setModal(null)}> -
-

Keep GoodWebTools one click away — add it to your bookmarks:

-

- Press - {bookmarkKey} -

-

- 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. -

-
-
- )} ); } diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 41ec540..0c40782 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -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')); + }); diff --git a/src/pages/[...locale]/index.astro b/src/pages/[...locale]/index.astro index a9c7798..9a3cf0c 100644 --- a/src/pages/[...locale]/index.astro +++ b/src/pages/[...locale]/index.astro @@ -68,11 +68,4 @@ const toolList = { - -