diff --git a/site/src/app/[locale]/page.tsx b/site/src/app/[locale]/page.tsx index 24c4b2f8..07487cd3 100644 --- a/site/src/app/[locale]/page.tsx +++ b/site/src/app/[locale]/page.tsx @@ -4,6 +4,8 @@ import { Link } from "@/lib/i18n/navigation"; import { listTools } from "@/lib/tools/registry"; import { listPrompts } from "@/lib/prompts/registry"; import { ToolCard } from "@/components/tools/ToolCard"; +import { FavoritesSection } from "@/components/tools/FavoritesSection"; +import { RecentSection } from "@/components/tools/RecentSection"; import { PromptCard } from "@/components/prompts/PromptCard"; import { AdBanner, AdInFeed } from "@/components/ads/AdBanner"; import { RelatedServices } from "@/components/affiliates/RelatedServices"; @@ -219,6 +221,10 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s + {/* ── Favorites / Recently used (localStorage; 初回訪問では何も出ない) ── */} + + + {/* ── Popular tools ─────────────────────────────────────────────── */}
diff --git a/site/src/app/[locale]/search-index.json/route.ts b/site/src/app/[locale]/search-index.json/route.ts new file mode 100644 index 00000000..1c611fea --- /dev/null +++ b/site/src/app/[locale]/search-index.json/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { LOCALES } from "@/lib/i18n/locales"; +import { loadMessages } from "@/lib/i18n/loader"; +import { listTools } from "@/lib/tools/registry"; + +export const dynamic = "force-static"; + +export function generateStaticParams() { + return LOCALES.map((locale) => ({ locale })); +} + +/** + * ロケール別のクライアント検索インデックス(/{locale}/search-index.json)。 + * + * ヘッダー検索とトップページの「お気に入り / 最近使ったツール」が使う。 + * レイアウトはクライアントへ common メッセージしか渡さないため、ローカライズ済み + * タイトルはこの静的 JSON から遅延取得する(初回フォーカス時に1回だけ)。 + * middleware の matcher は `.` を含むパスを除外しているので、ロケール判定に + * 巻き込まれず素通りする。 + */ +export async function GET(_req: Request, { params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + if (!(LOCALES as readonly string[]).includes(locale)) { + return NextResponse.json({ error: "unknown locale" }, { status: 404 }); + } + const messages = await loadMessages(locale); + const toolMsgs = (messages.tools ?? {}) as Record; + const tools = listTools().map((t) => ({ + slug: t.slug, + category: t.category, + title: toolMsgs[t.slug]?.title ?? t.primaryKeyword[locale] ?? t.primaryKeyword.en ?? t.slug, + keyword: t.primaryKeyword[locale] ?? t.primaryKeyword.en, + description: toolMsgs[t.slug]?.shortDescription ?? "", + })); + return NextResponse.json( + { version: 1, locale, count: tools.length, tools }, + { headers: { "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400" } }, + ); +} diff --git a/site/src/app/[locale]/tools/page.tsx b/site/src/app/[locale]/tools/page.tsx index 96a977d9..82f3ba51 100644 --- a/site/src/app/[locale]/tools/page.tsx +++ b/site/src/app/[locale]/tools/page.tsx @@ -4,6 +4,7 @@ import { listTools } from "@/lib/tools/registry"; import { Link } from "@/lib/i18n/navigation"; import { ToolCard } from "@/components/tools/ToolCard"; import { FavoritesSection } from "@/components/tools/FavoritesSection"; +import { RecentSection } from "@/components/tools/RecentSection"; import { ToolSearch } from "@/components/tools/ToolSearch"; import { buildMetadata } from "@/lib/seo/metadata"; import { siteConfig } from "@/lib/config"; @@ -39,8 +40,10 @@ export default async function ToolsIndex({ params }: { params: Promise<{ locale: } // 検索とお気に入りの両方が同じ「翻訳済みカード情報」を必要とするため一度だけ作る。 + // クライアントへ渡すので ToolMeta 全体ではなく slug/category だけに絞る。 const items = tools.map((m) => ({ - meta: m, + slug: m.slug, + category: m.category, title: t(`tools.${m.slug}.title`), description: t(`tools.${m.slug}.shortDescription`), })); @@ -70,6 +73,8 @@ export default async function ToolsIndex({ params }: { params: Promise<{ locale: + + {Array.from(byCategory.entries()).map(([cat, list]) => { const cfg = CATEGORY_CONFIG[cat as ToolCategory]; return ( diff --git a/site/src/components/layout/Header.tsx b/site/src/components/layout/Header.tsx index c502df3b..83832593 100644 --- a/site/src/components/layout/Header.tsx +++ b/site/src/components/layout/Header.tsx @@ -1,35 +1,48 @@ import { useTranslations, useLocale } from "next-intl"; import { Link } from "@/lib/i18n/navigation"; import { LanguageSwitcher } from "./LanguageSwitcher"; +import { HeaderSearch } from "./HeaderSearch"; +import { MobileMenu } from "./MobileMenu"; import { isPromptLocale } from "@/lib/i18n/locales"; +/** + * デスクトップ(sm以上): ロゴ / 検索 / ツール / プロンプト / About / 言語 — 従来の並び。 + * モバイル( -
- +
+ 🔧 {t("site.name")} - + + + +
); diff --git a/site/src/components/layout/HeaderSearch.tsx b/site/src/components/layout/HeaderSearch.tsx new file mode 100644 index 00000000..a2976564 --- /dev/null +++ b/site/src/components/layout/HeaderSearch.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Link, usePathname, useRouter } from "@/lib/i18n/navigation"; +import { CATEGORY_CONFIG } from "@/lib/tools/categories"; +import { searchTools } from "@/lib/search/matcher"; +import { useSearchIndex } from "@/lib/search/useSearchIndex"; +import { useCategoryKeywords } from "@/lib/search/useCategoryKeywords"; +import { useSearchShortcut } from "@/lib/search/useSearchShortcut"; +import { useDebouncedSearchEvent } from "@/lib/search/useDebouncedSearchEvent"; + +const LIMIT = 10; + +/** + * 全ページ共通のヘッダー検索。 + * + * - デスクトップ(sm以上): 常時表示の入力欄 + 下に候補ドロップダウン(上位10件)。 + * - モバイル: 🔍 アイコンだけ置き、タップでヘッダー直下に入力バーを展開する。 + * - インデックス(/{locale}/search-index.json)はフォーカス時に初めて取得する。 + * 全ページに 200 本分の翻訳済みタイトルを props で載せると毎ページ ~15KB の + * RSC ペイロード増になるため、使う人だけが 1 回だけ払う形にした。 + * - `/` でフォーカス(/tools ではページ内検索が優先)、↑↓ で選択、Enter で開く、Esc で閉じる。 + */ +export function HeaderSearch() { + const t = useTranslations("tool"); + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + const categoryKeywords = useCategoryKeywords(); + const listId = useId(); + + const [open, setOpen] = useState(false); // モバイルのバー展開 + const [focused, setFocused] = useState(false); + const [q, setQ] = useState(""); + const [active, setActive] = useState(0); + const inputRef = useRef(null); + const rootRef = useRef(null); + + const query = q.trim(); + const index = useSearchIndex(locale, focused || open || query.length > 0); + const hits = useMemo( + () => (query && index ? searchTools(index, query, { limit: LIMIT, categoryKeywords }) : []), + [index, query, categoryKeywords], + ); + const showList = focused && query.length > 0; + + useDebouncedSearchEvent(query, hits.length, locale, "header"); + + const activate = useCallback(() => { + setOpen(true); + // モバイルでは展開後に input がマウントされるので次フレームでフォーカスする。 + requestAnimationFrame(() => inputRef.current?.focus()); + }, []); + useSearchShortcut(activate, 0); + + const close = useCallback(() => { + setOpen(false); + setFocused(false); + setQ(""); + setActive(0); + }, []); + + // ページ遷移で閉じる + useEffect(() => { + close(); + }, [pathname, close]); + + // 外側クリックで閉じる + useEffect(() => { + if (!focused && !open) return; + function onDown(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setFocused(false); + setOpen(false); + } + } + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [focused, open]); + + useEffect(() => setActive(0), [query]); + + function go(slug: string) { + close(); + inputRef.current?.blur(); + router.push(`/tools/${slug}`); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + close(); + inputRef.current?.blur(); + } else if (e.key === "ArrowDown" && hits.length) { + e.preventDefault(); + setActive((i) => (i + 1) % hits.length); + } else if (e.key === "ArrowUp" && hits.length) { + e.preventDefault(); + setActive((i) => (i - 1 + hits.length) % hits.length); + } else if (e.key === "Enter" && hits.length) { + e.preventDefault(); + const target = hits[Math.min(active, hits.length - 1)]; + if (target) go(target.slug); + } + } + + const list = showList && ( +
    + {index === null ? ( +
  • {t("searchLoading")}
  • + ) : hits.length === 0 ? ( +
  • {t("searchNoResults")}
  • + ) : ( + hits.map((h, i) => ( +
  • + setActive(i)} + onMouseDown={(e) => e.preventDefault()} + onClick={() => close()} + className={`flex items-center gap-2 px-3 py-2 ${i === active ? "bg-brand-50 text-brand-700 dark:bg-slate-800 dark:text-brand-400" : "text-slate-700 dark:text-slate-200"}`} + > + + {CATEGORY_CONFIG[h.category].emoji} + + {h.title} + +
  • + )) + )} +
+ ); + + return ( +
+ {/* モバイル: アイコンのみ */} + + +
+
+ + + + setQ(e.target.value)} + onFocus={() => setFocused(true)} + onKeyDown={onKeyDown} + placeholder={t("searchPlaceholder")} + aria-label={t("searchOpen")} + aria-controls={listId} + aria-expanded={showList} + role="combobox" + autoComplete="off" + className="w-full rounded-lg border border-slate-300 bg-white py-1.5 pl-8 pr-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 dark:border-slate-700 dark:bg-slate-900 sm:w-44 md:w-60" + /> +
+
{list}
+
+
+ ); +} + +function SearchIcon({ className = "h-5 w-5" }: { className?: string }) { + return ( + + + + + ); +} diff --git a/site/src/components/layout/MobileMenu.tsx b/site/src/components/layout/MobileMenu.tsx new file mode 100644 index 00000000..98d89a2c --- /dev/null +++ b/site/src/components/layout/MobileMenu.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { Link, usePathname } from "@/lib/i18n/navigation"; + +/** + * setOpen(false), [pathname]); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open]); + + const linkCls = + "block rounded-lg px-3 py-2.5 text-base font-medium text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"; + + return ( +
+ + {open && ( + + )} +
+ ); +} diff --git a/site/src/components/tools/FavoriteButton.tsx b/site/src/components/tools/FavoriteButton.tsx index 9071daf2..c983f627 100644 --- a/site/src/components/tools/FavoriteButton.tsx +++ b/site/src/components/tools/FavoriteButton.tsx @@ -1,11 +1,12 @@ "use client"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useFavorites } from "@/lib/favorites"; export function FavoriteButton({ slug }: { slug: string; title?: string }) { const t = useTranslations("tool"); - const { isFavorite, toggle } = useFavorites(); + const locale = useLocale(); + const { isFavorite, toggle } = useFavorites(locale); const active = isFavorite(slug); const label = active ? t("removeFavorite") : t("addFavorite"); diff --git a/site/src/components/tools/FavoritesSection.tsx b/site/src/components/tools/FavoritesSection.tsx index a2f5abed..d543b4fc 100644 --- a/site/src/components/tools/FavoritesSection.tsx +++ b/site/src/components/tools/FavoritesSection.tsx @@ -1,50 +1,10 @@ "use client"; -import { useTranslations } from "next-intl"; -import { ToolCard } from "@/components/tools/ToolCard"; -import { useFavorites } from "@/lib/favorites"; -import type { ToolMeta } from "@/lib/tools/types"; +import { SavedToolsSection, type FavItem } from "./SavedToolsSection"; -export interface FavItem { - meta: ToolMeta; - title: string; - description: string; -} - -/** - * Renders the user's favorited tools at the top of the index. - * Hydrates from localStorage; renders nothing until there is at least one favorite, - * so it never produces layout shift for first-time visitors. - */ -export function FavoritesSection({ items }: { items: FavItem[] }) { - const t = useTranslations("tool"); - const { favorites } = useFavorites(); - - if (favorites.length === 0) return null; - - const order = new Map(favorites.map((slug, i) => [slug, i])); - const picked = items - .filter((it) => order.has(it.meta.slug)) - .sort((a, b) => (order.get(a.meta.slug)! - order.get(b.meta.slug)!)); - - if (picked.length === 0) return null; +export type { FavItem }; - return ( -
-
- - ⭐ - -

{t("favorites")}

- - {picked.length} - -
-
- {picked.map((it) => ( - - ))} -
-
- ); +/** お気に入りセクション。items 省略時は検索インデックスを遅延取得する。 */ +export function FavoritesSection({ items, className }: { items?: FavItem[]; className?: string }) { + return ; } diff --git a/site/src/components/tools/RecentSection.tsx b/site/src/components/tools/RecentSection.tsx new file mode 100644 index 00000000..839d28f9 --- /dev/null +++ b/site/src/components/tools/RecentSection.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { SavedToolsSection, type FavItem } from "./SavedToolsSection"; + +/** 最近使ったツール(最大8件・新しい順)。items 省略時は検索インデックスを遅延取得する。 */ +export function RecentSection({ items, className }: { items?: FavItem[]; className?: string }) { + return ; +} diff --git a/site/src/components/tools/RecentTracker.tsx b/site/src/components/tools/RecentTracker.tsx new file mode 100644 index 00000000..c3bf13aa --- /dev/null +++ b/site/src/components/tools/RecentTracker.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useEffect } from "react"; +import { pushRecent } from "@/lib/recent"; + +/** ツールページのマウント時に slug を「最近使ったツール」へ積む。描画はしない(ハイドレーション後のみ)。 */ +export function RecentTracker({ slug }: { slug: string }) { + useEffect(() => { + pushRecent(slug); + }, [slug]); + return null; +} diff --git a/site/src/components/tools/SavedToolsSection.tsx b/site/src/components/tools/SavedToolsSection.tsx new file mode 100644 index 00000000..dbba55b9 --- /dev/null +++ b/site/src/components/tools/SavedToolsSection.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { ToolCard } from "@/components/tools/ToolCard"; +import { useFavorites } from "@/lib/favorites"; +import { useRecent } from "@/lib/recent"; +import type { SearchItem } from "@/lib/search/matcher"; +import { useSearchIndex } from "@/lib/search/useSearchIndex"; + +/** カード描画に必要な最小情報。/tools ページはサーバーで作って渡す。 */ +export type FavItem = Pick; + +/** + * 「お気に入り」「最近使ったツール」の共通セクション。 + * + * - localStorage から slug を読む(useSyncExternalStore、サーバースナップショットは空)。 + * よって初回訪問者には何も描画せず、レイアウトシフトも起こさない。 + * - `items` が渡されなければ(トップページ)、slug が1件以上ある場合だけ + * /{locale}/search-index.json を遅延取得してタイトルを解決する。 + */ +export function SavedToolsSection({ + kind, + items, + className = "mt-8", +}: { + kind: "favorites" | "recent"; + items?: FavItem[]; + className?: string; +}) { + const t = useTranslations("tool"); + const locale = useLocale(); + const { favorites } = useFavorites(locale); + const recent = useRecent(); + const slugs = kind === "favorites" ? favorites : recent; + const fetched = useSearchIndex(locale, !items && slugs.length > 0); + const source: FavItem[] | null = items ?? fetched; + + if (slugs.length === 0 || !source) return null; + + const bySlug = new Map(source.map((it) => [it.slug, it])); + const picked = slugs.map((s) => bySlug.get(s)).filter((x): x is FavItem => !!x); + if (picked.length === 0) return null; + + const isFav = kind === "favorites"; + return ( +
+
+ + {isFav ? "⭐" : "🕘"} + +

{t(isFav ? "favorites" : "recent")}

+ + {picked.length} + +
+
+ {picked.map((it) => ( + + ))} +
+
+ ); +} diff --git a/site/src/components/tools/ToolCard.tsx b/site/src/components/tools/ToolCard.tsx index 60d031a4..7d899da2 100644 --- a/site/src/components/tools/ToolCard.tsx +++ b/site/src/components/tools/ToolCard.tsx @@ -2,7 +2,10 @@ import { Link } from "@/lib/i18n/navigation"; import type { ToolMeta } from "@/lib/tools/types"; import { CATEGORY_CONFIG } from "@/lib/tools/categories"; -export function ToolCard({ meta, title, description }: { meta: ToolMeta; title: string; description: string }) { +/** カード描画に要るのは slug と category だけ。クライアントへ渡す props を小さく保つため Pick にする。 */ +export type ToolCardMeta = Pick; + +export function ToolCard({ meta, title, description }: { meta: ToolCardMeta; title: string; description: string }) { const cfg = CATEGORY_CONFIG[meta.category]; return ( +

{title}

diff --git a/site/src/components/tools/ToolInteractionTracker.tsx b/site/src/components/tools/ToolInteractionTracker.tsx index 14aab9e1..3d8e8ff8 100644 --- a/site/src/components/tools/ToolInteractionTracker.tsx +++ b/site/src/components/tools/ToolInteractionTracker.tsx @@ -44,7 +44,17 @@ export function ToolInteractionTracker({ function onInput() { if (sent.current.calculate) return; sent.current.calculate = true; - trackCalculate({ tool: slug, locale, category }); + const root = el as HTMLElement; + // 結果描画はツール側の再レンダー後なので、1 フレーム待ってから DOM を見る。 + window.setTimeout(() => { + trackCalculate({ + tool: slug, + locale, + category, + input_count: countFilledInputs(root), + has_result: hasVisibleResult(root), + }); + }, 50); } function onClick(e: Event) { @@ -84,3 +94,34 @@ export function ToolInteractionTracker({ return
{children}
; } + +/** 値が入っている入力欄の数(空文字/未チェックは数えない)。 */ +function countFilledInputs(root: HTMLElement): number { + let n = 0; + root.querySelectorAll("input, textarea, select").forEach((f) => { + if (f instanceof HTMLInputElement) { + const type = f.type; + if (type === "hidden" || type === "button" || type === "submit" || type === "reset") return; + if (type === "checkbox" || type === "radio") { + if (f.checked) n++; + return; + } + } + if (f.value.trim() !== "") n++; + }); + return n; +} + +/** + * 結果らしきものが描画されているか。 + * 共通の ResultCard が無いので、aria-live / output / role=status / data-result / + * class に "result" を含む要素のいずれかに空でないテキストがあれば true とする + * (223 本中 154 本が aria-live、186 本が result クラスを持つ)。 + */ +function hasVisibleResult(root: HTMLElement): boolean { + const nodes = root.querySelectorAll('[aria-live], output, [role="status"], [data-result], [class*="result"]'); + for (const node of nodes) { + if ((node.textContent ?? "").trim() !== "") return true; + } + return false; +} diff --git a/site/src/components/tools/ToolSearch.tsx b/site/src/components/tools/ToolSearch.tsx index 15d601f4..d3b4fcd1 100644 --- a/site/src/components/tools/ToolSearch.tsx +++ b/site/src/components/tools/ToolSearch.tsx @@ -1,39 +1,62 @@ "use client"; -import { useMemo, useState, useId } from "react"; -import { useTranslations } from "next-intl"; +import { useCallback, useMemo, useRef, useState, useId } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { useRouter } from "@/lib/i18n/navigation"; import { ToolCard } from "@/components/tools/ToolCard"; import type { FavItem } from "@/components/tools/FavoritesSection"; +import { searchTools } from "@/lib/search/matcher"; +import { useCategoryKeywords } from "@/lib/search/useCategoryKeywords"; +import { useSearchShortcut } from "@/lib/search/useSearchShortcut"; +import { useDebouncedSearchEvent } from "@/lib/search/useDebouncedSearchEvent"; + +const LIMIT = 10; /** * /tools のサイト内検索。 * * 背景: 200本超のツールがカテゴリ別にベタ並びしているだけで、絞り込む手段が - * 一切なかった(2026-08-22 時点)。目的のツールに辿り着けないことが回遊ゼロの - * 構造要因のひとつ。外部検索エンジンに出す(fxea365 の Google 外部検索と同じ轍) - * のではなく、サイト内で完結させる。 + * 一切なかった(2026-08-22 時点)。外部検索エンジンに出すのではなく、サイト内で + * 完結させる。 * - * 実装: 全件が既にサーバー側で描画済みなので、クライアントで絞り込むだけで済む。 - * インデックスも API も不要。入力が空のときは何も描画せず、既存の + * 2026-09-08: 照合をヘッダー検索と共通の `searchTools`(タイトル/slug の前方一致 > + * 部分一致 > 主キーワード > カテゴリ語 > 説明文)に統一し、上位10件に絞った。 + * `/` でフォーカス、Enter で先頭候補を開く、Esc でクリア。GA4 `tool_search` は + * 入力が落ち着いてから1回だけ送る。入力が空のときは何も描画せず、既存の * カテゴリ別一覧をそのまま見せる(レイアウトシフトを起こさない)。 - * - * 照合対象は「ローカライズ済みタイトル + 説明文 + slug」。slug を含めるのは - * 非英語ロケールでも "bmi" のような英語表記で打つ利用者が多いため。 */ export function ToolSearch({ items }: { items: FavItem[] }) { const t = useTranslations("tool"); + const locale = useLocale(); + const router = useRouter(); + const categoryKeywords = useCategoryKeywords(); const [q, setQ] = useState(""); const inputId = useId(); + const inputRef = useRef(null); - const query = q.trim().toLowerCase(); + const query = q.trim(); const hits = useMemo(() => { if (!query) return null; - return items.filter((it) => { - const hay = `${it.title} ${it.description} ${it.meta.slug}`.toLowerCase(); - return hay.includes(query); - }); - }, [items, query]); + return searchTools(items, query, { limit: LIMIT, categoryKeywords }); + }, [items, query, categoryKeywords]); + + useDebouncedSearchEvent(query, hits?.length ?? 0, locale, "tools_page"); + + // /tools ではページ内検索がヘッダー検索より優先(priority 10 > 0)。 + const focus = useCallback(() => inputRef.current?.focus(), []); + useSearchShortcut(focus, 10); + + function onKeyDown(e: React.KeyboardEvent) { + const first = hits?.[0]; + if (e.key === "Enter" && first) { + e.preventDefault(); + router.push(`/tools/${first.slug}`); + } else if (e.key === "Escape") { + setQ(""); + inputRef.current?.blur(); + } + } return (
@@ -48,15 +71,25 @@ export function ToolSearch({ items }: { items: FavItem[] }) { 🔍 setQ(e.target.value)} + onKeyDown={onKeyDown} placeholder={t("search", { n: items.length })} autoComplete="off" + data-tool-search-primary className="w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 dark:border-slate-700 dark:bg-slate-900" /> + + / +
+

{t("searchHint")}

{hits !== null && (
@@ -71,7 +104,7 @@ export function ToolSearch({ items }: { items: FavItem[] }) {
{hits.map((it) => ( - + ))}
diff --git a/site/src/lib/analytics/events.ts b/site/src/lib/analytics/events.ts index 61ce0389..2e10e060 100644 --- a/site/src/lib/analytics/events.ts +++ b/site/src/lib/analytics/events.ts @@ -30,6 +30,17 @@ export type ToolEventParams = { label?: string; }; +/** + * calculate 専用の追加パラメータ(2026-09-08)。 + * ToolInteractionTracker が DOM から算出する。個々のツールは触らない。 + */ +export type CalculateParams = ToolEventParams & { + /** 値の入っている input/textarea/select の数(checkbox/radio は checked のみ)。 */ + input_count?: number; + /** 結果領域(aria-live / output / role=status / *result*)にテキストがあるか。 */ + has_result?: boolean; +}; + function emit(name: string, params: Record): void { if (!siteConfig.analytics.gaId) return; if (typeof window === "undefined" || typeof window.gtag !== "function") return; @@ -42,14 +53,36 @@ function toParams(p: ToolEventParams): Record { tool: p.tool, tool_slug: p.tool, ...(p.locale ? { locale: p.locale } : {}), - ...(p.category ? { category: p.category } : {}), + ...(p.category ? { category: p.category, tool_category: p.category } : {}), ...(p.label ? { label: p.label } : {}), }; } /** ツールが有効な入力で結果を算出したとき。ツール1着地につき最大1回に間引くこと。 */ -export function trackCalculate(p: ToolEventParams): void { - emit("calculate", toParams(p)); +export function trackCalculate(p: CalculateParams): void { + emit("calculate", { + ...toParams(p), + ...(typeof p.input_count === "number" ? { input_count: p.input_count } : {}), + ...(typeof p.has_result === "boolean" ? { has_result: p.has_result } : {}), + }); +} + +/** お気に入りの ON/OFF。state は "on" | "off"。 */ +export function trackFavoriteToggle(p: { tool: string; locale?: string; state: "on" | "off" }): void { + emit("favorite_toggle", { tool_slug: p.tool, state: p.state, ...(p.locale ? { locale: p.locale } : {}) }); +} + +/** + * ツール検索。クエリ文字列そのものは送らない(PII 回避・カーディナリティ抑制)。 + * 呼び出し側でデバウンスすること(ToolSearchBox は 600ms)。 + */ +export function trackToolSearch(p: { query_length: number; results_count: number; locale?: string; source?: string }): void { + emit("tool_search", { + query_length: p.query_length, + results_count: p.results_count, + ...(p.locale ? { locale: p.locale } : {}), + ...(p.source ? { source: p.source } : {}), + }); } /** 結果(またはコード/URL)をクリップボードへコピーしたとき。 */ diff --git a/site/src/lib/favorites.ts b/site/src/lib/favorites.ts index 01ab8827..b74835db 100644 --- a/site/src/lib/favorites.ts +++ b/site/src/lib/favorites.ts @@ -1,61 +1,30 @@ "use client"; -import { useCallback, useSyncExternalStore } from "react"; - -const KEY = "toolify:favorites"; - -/** localStorage-backed favorite tool slugs, synced across components and tabs. */ -const listeners = new Set<() => void>(); -let cache: string[] | null = null; - -function read(): string[] { - if (cache) return cache; - if (typeof window === "undefined") return (cache = []); - try { - const raw = window.localStorage.getItem(KEY); - cache = raw ? (JSON.parse(raw) as string[]) : []; - } catch { - cache = []; - } - return cache; -} - -function write(next: string[]) { - cache = next; - if (typeof window !== "undefined") { - try { - window.localStorage.setItem(KEY, JSON.stringify(next)); - } catch { - /* storage full / unavailable — keep in-memory only */ - } - } - listeners.forEach((l) => l()); -} - -function subscribe(cb: () => void) { - listeners.add(cb); - const onStorage = (e: StorageEvent) => { - if (e.key === KEY) { - cache = null; // force re-read from the other tab's write - cb(); - } - }; - if (typeof window !== "undefined") window.addEventListener("storage", onStorage); - return () => { - listeners.delete(cb); - if (typeof window !== "undefined") window.removeEventListener("storage", onStorage); - }; -} - -const EMPTY: string[] = []; - -export function useFavorites() { - const favorites = useSyncExternalStore(subscribe, read, () => EMPTY); - - const toggle = useCallback((slug: string) => { - const current = read(); - write(current.includes(slug) ? current.filter((s) => s !== slug) : [slug, ...current]); - }, []); +import { useCallback } from "react"; +import { createSlugStore } from "./slugStore"; +import { trackFavoriteToggle } from "./analytics/events"; + +/** + * お気に入りツール slug の localStorage ストア。 + * キーは `toolify_favorites_v1`(2026-09-08 に `toolify:favorites` から改名)。 + * 旧キーのデータは初回読み取り時に新キーへ写す(旧キーは残す)。 + */ +export const FAVORITES_KEY = "toolify_favorites_v1"; + +const store = createSlugStore(FAVORITES_KEY, { legacyKey: "toolify:favorites" }); + +export function useFavorites(locale?: string) { + const favorites = store.useSlugs(); + + const toggle = useCallback( + (slug: string) => { + const current = store.read(); + const on = !current.includes(slug); + store.write(on ? [slug, ...current] : current.filter((s) => s !== slug)); + trackFavoriteToggle({ tool: slug, locale, state: on ? "on" : "off" }); + }, + [locale], + ); const isFavorite = useCallback((slug: string) => favorites.includes(slug), [favorites]); diff --git a/site/src/lib/recent.ts b/site/src/lib/recent.ts new file mode 100644 index 00000000..46de016b --- /dev/null +++ b/site/src/lib/recent.ts @@ -0,0 +1,22 @@ +"use client"; + +import { createSlugStore } from "./slugStore"; + +/** + * 最近使ったツール slug(最新が先頭、最大 8 件、重複なし)。 + * ツールページのマウント時に `pushRecent` を呼ぶ(RecentTracker)。 + */ +export const RECENT_KEY = "toolify_recent_v1"; +export const RECENT_MAX = 8; + +const store = createSlugStore(RECENT_KEY, { max: RECENT_MAX }); + +export function pushRecent(slug: string): void { + const current = store.read(); + if (current[0] === slug) return; + store.write([slug, ...current.filter((s) => s !== slug)]); +} + +export function useRecent(): string[] { + return store.useSlugs(); +} diff --git a/site/src/lib/search/matcher.test.ts b/site/src/lib/search/matcher.test.ts new file mode 100644 index 00000000..cb2313e8 --- /dev/null +++ b/site/src/lib/search/matcher.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { searchTools, type SearchItem } from "./matcher"; + +const items: SearchItem[] = [ + { slug: "bmi-calculator", category: "health", title: "BMI Calculator", keyword: "bmi", description: "Body mass index" }, + { slug: "loan-calculator", category: "finance", title: "Loan Calculator", keyword: "loan payment" }, + { slug: "age-calculator", category: "datetime", title: "Age Calculator", keyword: "age" }, + { slug: "word-counter", category: "text", title: "Word Counter", keyword: "word count", description: "Count characters" }, + { slug: "hex-to-rgb", category: "color", title: "HEX to RGB", keyword: "hex rgb" }, +]; + +describe("searchTools", () => { + it("returns nothing for an empty query", () => { + expect(searchTools(items, " ")).toEqual([]); + }); + + it("is case-insensitive and prefers title/slug prefix matches", () => { + const r = searchTools(items, "BMI"); + expect(r[0]?.slug).toBe("bmi-calculator"); + }); + + it("matches slug words and substrings", () => { + expect(searchTools(items, "rgb").map((x) => x.slug)).toEqual(["hex-to-rgb"]); + expect(searchTools(items, "calc").map((x) => x.slug)).toContain("loan-calculator"); + }); + + it("matches localized category keywords passed in", () => { + const r = searchTools(items, "お金", { categoryKeywords: { finance: "金融 お金 ローン" } }); + expect(r.map((x) => x.slug)).toEqual(["loan-calculator"]); + }); + + it("matches built-in category words and descriptions", () => { + expect(searchTools(items, "colour").map((x) => x.slug)).toEqual(["hex-to-rgb"]); + expect(searchTools(items, "characters").map((x) => x.slug)).toEqual(["word-counter"]); + }); + + it("respects the limit", () => { + expect(searchTools(items, "c", { limit: 2 })).toHaveLength(2); + }); +}); diff --git a/site/src/lib/search/matcher.ts b/site/src/lib/search/matcher.ts new file mode 100644 index 00000000..68ec8021 --- /dev/null +++ b/site/src/lib/search/matcher.ts @@ -0,0 +1,71 @@ +import type { ToolCategory } from "@/lib/tools/types"; + +/** + * クライアント検索の共通マッチャ。ヘッダー検索と /tools ページ検索の両方が使う。 + * + * 対象: ローカライズ済みタイトル / slug / 主キーワード / カテゴリ語(id・英語ラベル・ + * メッセージで与える翻訳語)。大小無視、前方一致を部分一致より優先する。 + */ +export type SearchItem = { + slug: string; + category: ToolCategory; + title: string; + /** ToolMeta.primaryKeyword[locale] */ + keyword?: string; + description?: string; +}; + +export type CategoryKeywords = Partial>; + +const CATEGORY_BASE: Record = { + health: "health fitness body", + math: "math calculator numbers", + converter: "converter unit conversion", + datetime: "date time calendar", + text: "text string", + color: "color colour palette", + finance: "finance money loan", + image: "image photo picture", +}; + +export function normalize(s: string): string { + return s.normalize("NFKC").toLowerCase().trim(); +} + +function score(item: SearchItem, q: string, catWords: string): number { + const title = normalize(item.title); + const slug = item.slug.toLowerCase(); + const slugWords = slug.replace(/-/g, " "); + if (title === q || slug === q) return 100; + if (title.startsWith(q) || slug.startsWith(q)) return 90; + if (title.split(/\s+/).some((w) => w.startsWith(q)) || slugWords.split(" ").some((w) => w.startsWith(q))) return 80; + if (title.includes(q) || slugWords.includes(q)) return 70; + const kw = item.keyword ? normalize(item.keyword) : ""; + if (kw && (kw.startsWith(q) || kw.includes(q))) return 60; + if (catWords.split(/\s+/).some((w) => w.startsWith(q))) return 40; + if (item.description && normalize(item.description).includes(q)) return 30; + return 0; +} + +export function searchTools( + items: readonly SearchItem[], + query: string, + opts: { limit?: number; categoryKeywords?: CategoryKeywords } = {}, +): SearchItem[] { + const q = normalize(query); + if (!q) return []; + const limit = opts.limit ?? 10; + const catCache = new Map(); + const scored: { item: SearchItem; s: number }[] = []; + for (const item of items) { + let cw = catCache.get(item.category); + if (cw === undefined) { + cw = normalize(`${item.category} ${CATEGORY_BASE[item.category] ?? ""} ${opts.categoryKeywords?.[item.category] ?? ""}`); + catCache.set(item.category, cw); + } + const s = score(item, q, cw); + if (s > 0) scored.push({ item, s }); + } + scored.sort((a, b) => b.s - a.s || a.item.title.localeCompare(b.item.title)); + return scored.slice(0, limit).map((x) => x.item); +} diff --git a/site/src/lib/search/useCategoryKeywords.ts b/site/src/lib/search/useCategoryKeywords.ts new file mode 100644 index 00000000..c36f285b --- /dev/null +++ b/site/src/lib/search/useCategoryKeywords.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useMemo } from "react"; +import { useMessages } from "next-intl"; +import type { CategoryKeywords } from "./matcher"; + +/** messages.tool.categoryKeywords(翻訳済みカテゴリ語)を取り出す。無ければ空。 */ +export function useCategoryKeywords(): CategoryKeywords { + const messages = useMessages() as { tool?: { categoryKeywords?: Record } }; + return useMemo(() => (messages.tool?.categoryKeywords ?? {}) as CategoryKeywords, [messages]); +} diff --git a/site/src/lib/search/useDebouncedSearchEvent.ts b/site/src/lib/search/useDebouncedSearchEvent.ts new file mode 100644 index 00000000..54fcd48b --- /dev/null +++ b/site/src/lib/search/useDebouncedSearchEvent.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useEffect } from "react"; +import { trackToolSearch } from "@/lib/analytics/events"; + +/** クエリが落ち着いてから 600ms 後に1回だけ tool_search を送る(キー入力ごとには送らない)。 */ +export function useDebouncedSearchEvent(query: string, resultsCount: number, locale: string, source: string) { + useEffect(() => { + const q = query.trim(); + if (!q) return; + const id = window.setTimeout(() => { + trackToolSearch({ query_length: q.length, results_count: resultsCount, locale, source }); + }, 600); + return () => window.clearTimeout(id); + }, [query, resultsCount, locale, source]); +} diff --git a/site/src/lib/search/useSearchIndex.ts b/site/src/lib/search/useSearchIndex.ts new file mode 100644 index 00000000..9fa0de4b --- /dev/null +++ b/site/src/lib/search/useSearchIndex.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { SearchItem } from "./matcher"; + +/** + * ロケール別の検索インデックス(/{locale}/search-index.json)を遅延取得する。 + * + * なぜ fetch か: レイアウトがクライアントへ渡すメッセージは common のみで、 + * 223 ツールのローカライズ済みタイトルはクライアントバンドルに無い。 + * 全ページのヘッダーに 223 件分を props で埋め込むと毎ページ十数 KB 増えるため、 + * 検索欄にフォーカスした時(またはお気に入り/最近が存在する時)に1回だけ取得し、 + * モジュールスコープでキャッシュする。 + */ +const cache = new Map>(); + +export function loadSearchIndex(locale: string): Promise { + let p = cache.get(locale); + if (!p) { + p = fetch(`/${locale}/search-index.json`) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then((j: { tools?: SearchItem[] }) => j.tools ?? []) + .catch(() => { + cache.delete(locale); + return [] as SearchItem[]; + }); + cache.set(locale, p); + } + return p; +} + +export function useSearchIndex(locale: string, enabled: boolean): SearchItem[] | null { + const [items, setItems] = useState(null); + useEffect(() => { + if (!enabled) return; + let alive = true; + loadSearchIndex(locale).then((list) => { + if (alive) setItems(list); + }); + return () => { + alive = false; + }; + }, [locale, enabled]); + return items; +} diff --git a/site/src/lib/search/useSearchShortcut.ts b/site/src/lib/search/useSearchShortcut.ts new file mode 100644 index 00000000..a932ee4f --- /dev/null +++ b/site/src/lib/search/useSearchShortcut.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * `/` キーでサイト内検索を開く共通ショートカット。 + * + * ヘッダー検索(全ページ)と /tools のページ内検索(そのページのみ)が同時に + * 存在するため、両者が個別に keydown を拾うと二重に反応する。登録制にして + * 優先度の一番高いもの1つだけを起動する(/tools ではページ内検索が勝つ)。 + */ +type Entry = { priority: number; activate: () => void }; +const entries = new Set(); +let bound = false; + +function isEditable(el: EventTarget | null): boolean { + const node = el as HTMLElement | null; + if (!node) return false; + const tag = node.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || node.isContentEditable; +} + +function onKeyDown(e: KeyboardEvent) { + if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return; + if (isEditable(e.target)) return; + let best: Entry | null = null; + for (const en of entries) if (!best || en.priority > best.priority) best = en; + if (!best) return; + e.preventDefault(); + best.activate(); +} + +export function useSearchShortcut(activate: () => void, priority = 0) { + useEffect(() => { + const entry: Entry = { priority, activate }; + entries.add(entry); + if (!bound) { + document.addEventListener("keydown", onKeyDown); + bound = true; + } + return () => { + entries.delete(entry); + if (entries.size === 0 && bound) { + document.removeEventListener("keydown", onKeyDown); + bound = false; + } + }; + }, [activate, priority]); +} diff --git a/site/src/lib/slugStore.ts b/site/src/lib/slugStore.ts new file mode 100644 index 00000000..070813b4 --- /dev/null +++ b/site/src/lib/slugStore.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +/** + * localStorage に slug 配列を保持する小さなストアの共通実装。 + * favorites / recent の2用途で同じ「キャッシュ + リスナー + 他タブ同期 + + * useSyncExternalStore」が要るため、ここに一本化する。 + * + * - SSR / 初回ハイドレーション時は必ず EMPTY を返す(サーバースナップショット)。 + * localStorage の中身はハイドレーション後に反映されるので不一致は起きない。 + * - `legacyKey` を渡すと、新キーが空のときだけ旧キーから1回だけ読み取り、 + * 新キーへ書き写す(キー名変更時の移行用。旧キーは消さない)。 + */ +export type SlugStore = { + key: string; + read: () => string[]; + write: (next: string[]) => void; + subscribe: (cb: () => void) => () => void; + useSlugs: () => string[]; +}; + +const EMPTY: string[] = []; + +function parse(raw: string | null): string[] | null { + if (!raw) return null; + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : null; + } catch { + return null; + } +} + +export function createSlugStore(key: string, opts: { legacyKey?: string; max?: number } = {}): SlugStore { + const listeners = new Set<() => void>(); + let cache: string[] | null = null; + + function read(): string[] { + if (cache) return cache; + if (typeof window === "undefined") return EMPTY; + try { + let list = parse(window.localStorage.getItem(key)); + if (list === null && opts.legacyKey) { + list = parse(window.localStorage.getItem(opts.legacyKey)); + if (list && list.length) window.localStorage.setItem(key, JSON.stringify(list)); + } + cache = list ?? []; + } catch { + cache = []; + } + return cache; + } + + function write(next: string[]) { + const trimmed = opts.max ? next.slice(0, opts.max) : next; + cache = trimmed; + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(key, JSON.stringify(trimmed)); + } catch { + /* storage full / unavailable — keep in-memory only */ + } + } + listeners.forEach((l) => l()); + } + + function subscribe(cb: () => void) { + listeners.add(cb); + const onStorage = (e: StorageEvent) => { + if (e.key === key) { + cache = null; // force re-read from the other tab's write + cb(); + } + }; + if (typeof window !== "undefined") window.addEventListener("storage", onStorage); + return () => { + listeners.delete(cb); + if (typeof window !== "undefined") window.removeEventListener("storage", onStorage); + }; + } + + function useSlugs() { + return useSyncExternalStore(subscribe, read, () => EMPTY); + } + + return { key, read, write, subscribe, useSlugs }; +} diff --git a/site/src/messages/ar.json b/site/src/messages/ar.json index 016d0da6..9f00251d 100644 --- a/site/src/messages/ar.json +++ b/site/src/messages/ar.json @@ -13,7 +13,9 @@ "terms": "الشروط", "contact": "تواصل", "prompts": "مطالبات الذكاء الاصطناعي", - "pregnancy": "أدوات الحمل" + "pregnancy": "أدوات الحمل", + "menu": "القائمة", + "closeMenu": "إغلاق القائمة" }, "home": { "heading": "أدوات إنترنت مجانية تعمل ببساطة", @@ -68,6 +70,22 @@ "sources": "المصادر", "noSources": "تستند إلى معادلات قياسية شائعة الاستخدام. راجع صفحة المنهجية لمعرفة كيفية بناء الأدوات ومراجعتها.", "lastReviewed": "آخر مراجعة" + }, + "recent": "المستخدمة مؤخرًا", + "searchPlaceholder": "ابحث عن أداة…", + "searchOpen": "بحث", + "searchClose": "إغلاق البحث", + "searchHint": "اضغط / للبحث و Enter لفتح النتيجة الأولى", + "searchLoading": "جارٍ التحميل…", + "categoryKeywords": { + "health": "صحة وزن حمية طبي health weight diet medical body", + "math": "رياضيات حساب نسبة رقم math calculation percentage number", + "converter": "تحويل وحدة convert unit conversion", + "datetime": "تاريخ وقت أيام تقويم عمر date time days calendar age", + "text": "نص أحرف كلمات عد text string character word count", + "color": "لون لوحة hex color colour palette hex", + "finance": "مالية مال قرض راتب ضريبة فائدة finance money loan salary tax interest", + "image": "صورة image photo picture" } }, "consent": { diff --git a/site/src/messages/de.json b/site/src/messages/de.json index 63020c59..968ab1e3 100644 --- a/site/src/messages/de.json +++ b/site/src/messages/de.json @@ -13,7 +13,9 @@ "terms": "AGB", "contact": "Kontakt", "prompts": "KI-Prompts", - "pregnancy": "Schwangerschafts-Tools" + "pregnancy": "Schwangerschafts-Tools", + "menu": "Menü", + "closeMenu": "Menü schließen" }, "home": { "heading": "Kostenlose Online-Tools, die einfach funktionieren", @@ -68,6 +70,22 @@ "sources": "Quellen", "noSources": "Basiert auf weit verbreiteten Standardformeln. Wie unsere Tools erstellt und geprüft werden, erfahren Sie auf der Methodik-Seite.", "lastReviewed": "Zuletzt geprüft" + }, + "recent": "Zuletzt verwendet", + "searchPlaceholder": "Tools suchen…", + "searchOpen": "Suchen", + "searchClose": "Suche schließen", + "searchHint": "/ zum Suchen, Enter öffnet das erste Ergebnis", + "searchLoading": "Wird geladen…", + "categoryKeywords": { + "health": "gesundheit gewicht diät medizin health weight diet medical body", + "math": "mathe rechnen prozent zahl math calculation percentage number", + "converter": "umrechnen einheit umrechnung convert unit conversion", + "datetime": "datum zeit tage kalender alter date time days calendar age", + "text": "text zeichen wörter zählen text string character word count", + "color": "farbe palette hex color colour palette hex", + "finance": "finanzen geld kredit gehalt steuer zinsen finance money loan salary tax interest", + "image": "bild foto image photo picture" } }, "consent": { diff --git a/site/src/messages/en.json b/site/src/messages/en.json index 939131f5..6e5f348f 100644 --- a/site/src/messages/en.json +++ b/site/src/messages/en.json @@ -13,7 +13,9 @@ "terms": "Terms", "contact": "Contact", "prompts": "AI prompts", - "pregnancy": "Pregnancy tools" + "pregnancy": "Pregnancy tools", + "menu": "Menu", + "closeMenu": "Close menu" }, "home": { "heading": "Free online tools that just work", @@ -68,6 +70,22 @@ "sources": "Sources", "noSources": "Based on widely used standard formulas. See our methodology page for how tools are built and reviewed.", "lastReviewed": "Last reviewed" + }, + "recent": "Recently used", + "searchPlaceholder": "Search tools…", + "searchOpen": "Search", + "searchClose": "Close search", + "searchHint": "Press / to search, Enter to open the first result", + "searchLoading": "Loading…", + "categoryKeywords": { + "health": "health weight diet medical body", + "math": "math calculation percentage number", + "converter": "convert unit conversion", + "datetime": "date time days calendar age", + "text": "text string character word count", + "color": "color colour palette hex", + "finance": "finance money loan salary tax interest", + "image": "image photo picture" } }, "consent": { diff --git a/site/src/messages/es.json b/site/src/messages/es.json index ce9be589..9716f973 100644 --- a/site/src/messages/es.json +++ b/site/src/messages/es.json @@ -13,7 +13,9 @@ "terms": "Términos", "contact": "Contacto", "prompts": "Prompts AI", - "pregnancy": "Herramientas de embarazo" + "pregnancy": "Herramientas de embarazo", + "menu": "Menú", + "closeMenu": "Cerrar menú" }, "home": { "heading": "Herramientas online gratuitas que sí funcionan", @@ -68,6 +70,22 @@ "sources": "Fuentes", "noSources": "Basado en fórmulas estándar de uso generalizado. Consulta nuestra página de metodología para saber cómo se crean y revisan las herramientas.", "lastReviewed": "Última revisión" + }, + "recent": "Usados recientemente", + "searchPlaceholder": "Buscar herramientas…", + "searchOpen": "Buscar", + "searchClose": "Cerrar búsqueda", + "searchHint": "Pulsa / para buscar, Enter abre el primer resultado", + "searchLoading": "Cargando…", + "categoryKeywords": { + "health": "salud peso dieta médico health weight diet medical body", + "math": "matemáticas cálculo porcentaje número math calculation percentage number", + "converter": "convertir unidad conversión convert unit conversion", + "datetime": "fecha hora días calendario edad date time days calendar age", + "text": "texto caracteres palabras contar text string character word count", + "color": "color paleta hex color colour palette hex", + "finance": "finanzas dinero préstamo salario impuesto interés finance money loan salary tax interest", + "image": "imagen foto image photo picture" } }, "consent": { diff --git a/site/src/messages/fr.json b/site/src/messages/fr.json index 81498ab5..ef340a6c 100644 --- a/site/src/messages/fr.json +++ b/site/src/messages/fr.json @@ -13,7 +13,9 @@ "terms": "Conditions", "contact": "Contact", "prompts": "Prompts IA", - "pregnancy": "Outils de grossesse" + "pregnancy": "Outils de grossesse", + "menu": "Menu", + "closeMenu": "Fermer le menu" }, "home": { "heading": "Des outils en ligne gratuits qui fonctionnent", @@ -68,6 +70,22 @@ "sources": "Sources", "noSources": "Basé sur des formules standard largement utilisées. Consultez notre page méthodologie pour savoir comment les outils sont conçus et vérifiés.", "lastReviewed": "Dernière vérification" + }, + "recent": "Utilisés récemment", + "searchPlaceholder": "Rechercher un outil…", + "searchOpen": "Rechercher", + "searchClose": "Fermer la recherche", + "searchHint": "Appuyez sur / pour rechercher, Entrée ouvre le premier résultat", + "searchLoading": "Chargement…", + "categoryKeywords": { + "health": "santé poids régime médical health weight diet medical body", + "math": "maths calcul pourcentage nombre math calculation percentage number", + "converter": "convertir unité conversion convert unit conversion", + "datetime": "date heure jours calendrier âge date time days calendar age", + "text": "texte caractères mots compter text string character word count", + "color": "couleur palette hex color colour palette hex", + "finance": "finance argent prêt salaire impôt intérêt finance money loan salary tax interest", + "image": "image photo image photo picture" } }, "consent": { diff --git a/site/src/messages/hi.json b/site/src/messages/hi.json index 68a6969b..30d93331 100644 --- a/site/src/messages/hi.json +++ b/site/src/messages/hi.json @@ -13,7 +13,9 @@ "terms": "शर्तें", "contact": "संपर्क", "prompts": "AI प्रॉम्प्ट", - "pregnancy": "गर्भावस्था टूल" + "pregnancy": "गर्भावस्था टूल", + "menu": "मेनू", + "closeMenu": "मेनू बंद करें" }, "home": { "heading": "मुफ़्त ऑनलाइन टूल्स जो काम करते हैं", @@ -68,6 +70,22 @@ "sources": "स्रोत", "noSources": "व्यापक रूप से उपयोग किए जाने वाले मानक सूत्रों पर आधारित। टूल कैसे बनाए और समीक्षा किए जाते हैं, यह जानने के लिए हमारा कार्यप्रणाली पृष्ठ देखें।", "lastReviewed": "अंतिम समीक्षा" + }, + "recent": "हाल ही में उपयोग किए गए", + "searchPlaceholder": "टूल खोजें…", + "searchOpen": "खोजें", + "searchClose": "खोज बंद करें", + "searchHint": "खोजने के लिए / दबाएं, पहला परिणाम खोलने के लिए Enter", + "searchLoading": "लोड हो रहा है…", + "categoryKeywords": { + "health": "स्वास्थ्य वजन डाइट चिकित्सा health weight diet medical body", + "math": "गणित गणना प्रतिशत संख्या math calculation percentage number", + "converter": "रूपांतरण इकाई convert unit conversion", + "datetime": "तारीख समय दिन कैलेंडर उम्र date time days calendar age", + "text": "टेक्स्ट अक्षर शब्द गिनती text string character word count", + "color": "रंग पैलेट hex color colour palette hex", + "finance": "वित्त पैसा लोन वेतन टैक्स ब्याज finance money loan salary tax interest", + "image": "छवि फोटो image photo picture" } }, "consent": { diff --git a/site/src/messages/id.json b/site/src/messages/id.json index a81240ef..5f5618e9 100644 --- a/site/src/messages/id.json +++ b/site/src/messages/id.json @@ -13,7 +13,9 @@ "terms": "Syarat", "contact": "Kontak", "prompts": "Prompt AI", - "pregnancy": "Alat kehamilan" + "pregnancy": "Alat kehamilan", + "menu": "Menu", + "closeMenu": "Tutup menu" }, "home": { "heading": "Alat online gratis yang benar-benar bekerja", @@ -68,6 +70,22 @@ "sources": "Sumber", "noSources": "Berdasarkan rumus standar yang umum digunakan. Lihat halaman metodologi kami untuk mengetahui cara alat dibuat dan ditinjau.", "lastReviewed": "Terakhir ditinjau" + }, + "recent": "Baru digunakan", + "searchPlaceholder": "Cari alat…", + "searchOpen": "Cari", + "searchClose": "Tutup pencarian", + "searchHint": "Tekan / untuk mencari, Enter membuka hasil pertama", + "searchLoading": "Memuat…", + "categoryKeywords": { + "health": "kesehatan berat diet medis health weight diet medical body", + "math": "matematika hitung persen angka math calculation percentage number", + "converter": "konversi satuan convert unit conversion", + "datetime": "tanggal waktu hari kalender usia date time days calendar age", + "text": "teks karakter kata hitung text string character word count", + "color": "warna palet hex color colour palette hex", + "finance": "keuangan uang pinjaman gaji pajak bunga finance money loan salary tax interest", + "image": "gambar foto image photo picture" } }, "consent": { diff --git a/site/src/messages/it.json b/site/src/messages/it.json index e4073d65..c98efac8 100644 --- a/site/src/messages/it.json +++ b/site/src/messages/it.json @@ -13,7 +13,9 @@ "terms": "Termini", "contact": "Contatti", "prompts": "Prompt IA", - "pregnancy": "Strumenti gravidanza" + "pregnancy": "Strumenti gravidanza", + "menu": "Menu", + "closeMenu": "Chiudi menu" }, "home": { "heading": "Strumenti online gratuiti che funzionano davvero", @@ -68,6 +70,22 @@ "sources": "Fonti", "noSources": "Basato su formule standard ampiamente utilizzate. Consulta la pagina sulla metodologia per sapere come vengono creati e verificati gli strumenti.", "lastReviewed": "Ultima revisione" + }, + "recent": "Usati di recente", + "searchPlaceholder": "Cerca strumenti…", + "searchOpen": "Cerca", + "searchClose": "Chiudi ricerca", + "searchHint": "Premi / per cercare, Invio apre il primo risultato", + "searchLoading": "Caricamento…", + "categoryKeywords": { + "health": "salute peso dieta medico health weight diet medical body", + "math": "matematica calcolo percentuale numero math calculation percentage number", + "converter": "convertire unità conversione convert unit conversion", + "datetime": "data ora giorni calendario età date time days calendar age", + "text": "testo caratteri parole contare text string character word count", + "color": "colore palette hex color colour palette hex", + "finance": "finanza soldi prestito stipendio tasse interesse finance money loan salary tax interest", + "image": "immagine foto image photo picture" } }, "consent": { diff --git a/site/src/messages/ja.json b/site/src/messages/ja.json index 326d36fa..e48341f9 100644 --- a/site/src/messages/ja.json +++ b/site/src/messages/ja.json @@ -13,7 +13,9 @@ "terms": "利用規約", "contact": "お問い合わせ", "prompts": "AIプロンプト", - "pregnancy": "妊娠・妊活ツール" + "pregnancy": "妊娠・妊活ツール", + "menu": "メニュー", + "closeMenu": "メニューを閉じる" }, "home": { "heading": "毎日使える、無料オンラインツール", @@ -68,6 +70,22 @@ "sources": "出典", "noSources": "広く用いられている標準的な計算式に基づいています。ツールの作成・レビュー方法はメソドロジーページをご覧ください。", "lastReviewed": "最終レビュー日" + }, + "recent": "最近使ったツール", + "searchPlaceholder": "ツールを検索…", + "searchOpen": "検索", + "searchClose": "検索を閉じる", + "searchHint": "「/」で検索、Enterで先頭の結果を開く", + "searchLoading": "読み込み中…", + "categoryKeywords": { + "health": "健康 体重 ダイエット 医療 BMI health weight diet medical body", + "math": "数学 計算 割合 パーセント math calculation percentage number", + "converter": "変換 単位 換算 convert unit conversion", + "datetime": "日付 時間 日数 カレンダー 年齢 date time days calendar age", + "text": "テキスト 文字 文字数 文章 text string character word count", + "color": "色 カラー color colour palette hex", + "finance": "金融 お金 ローン 給料 税 利息 finance money loan salary tax interest", + "image": "画像 写真 image photo picture" } }, "consent": { diff --git a/site/src/messages/ko.json b/site/src/messages/ko.json index 50ed7627..98b6dbf5 100644 --- a/site/src/messages/ko.json +++ b/site/src/messages/ko.json @@ -13,7 +13,9 @@ "terms": "이용약관", "contact": "문의", "prompts": "AI 프롬프트", - "pregnancy": "임신 도구" + "pregnancy": "임신 도구", + "menu": "메뉴", + "closeMenu": "메뉴 닫기" }, "home": { "heading": "그냥 잘 작동하는 무료 온라인 도구", @@ -68,6 +70,22 @@ "sources": "출처", "noSources": "널리 사용되는 표준 공식을 기반으로 합니다. 도구의 제작 및 검토 방법은 방법론 페이지를 참고하세요.", "lastReviewed": "최종 검토일" + }, + "recent": "최근 사용한 도구", + "searchPlaceholder": "도구 검색…", + "searchOpen": "검색", + "searchClose": "검색 닫기", + "searchHint": "/ 키로 검색, Enter로 첫 결과 열기", + "searchLoading": "불러오는 중…", + "categoryKeywords": { + "health": "건강 체중 다이어트 의료 health weight diet medical body", + "math": "수학 계산 퍼센트 숫자 math calculation percentage number", + "converter": "변환 단위 환산 convert unit conversion", + "datetime": "날짜 시간 일수 달력 나이 date time days calendar age", + "text": "텍스트 문자 글자수 단어 text string character word count", + "color": "색상 컬러 팔레트 color colour palette hex", + "finance": "금융 돈 대출 급여 세금 이자 finance money loan salary tax interest", + "image": "이미지 사진 image photo picture" } }, "consent": { diff --git a/site/src/messages/pt-BR.json b/site/src/messages/pt-BR.json index e22ee8e4..6eae7a9a 100644 --- a/site/src/messages/pt-BR.json +++ b/site/src/messages/pt-BR.json @@ -13,7 +13,9 @@ "terms": "Termos", "contact": "Contato", "prompts": "Prompts IA", - "pregnancy": "Ferramentas de gravidez" + "pregnancy": "Ferramentas de gravidez", + "menu": "Menu", + "closeMenu": "Fechar menu" }, "home": { "heading": "Ferramentas online grátis que funcionam", @@ -68,6 +70,22 @@ "sources": "Fontes", "noSources": "Baseado em fórmulas padrão amplamente utilizadas. Veja nossa página de metodologia para saber como as ferramentas são criadas e revisadas.", "lastReviewed": "Última revisão" + }, + "recent": "Usados recentemente", + "searchPlaceholder": "Buscar ferramentas…", + "searchOpen": "Buscar", + "searchClose": "Fechar busca", + "searchHint": "Pressione / para buscar, Enter abre o primeiro resultado", + "searchLoading": "Carregando…", + "categoryKeywords": { + "health": "saúde peso dieta médico health weight diet medical body", + "math": "matemática cálculo porcentagem número math calculation percentage number", + "converter": "converter unidade conversão convert unit conversion", + "datetime": "data hora dias calendário idade date time days calendar age", + "text": "texto caracteres palavras contar text string character word count", + "color": "cor paleta hex color colour palette hex", + "finance": "finanças dinheiro empréstimo salário imposto juros finance money loan salary tax interest", + "image": "imagem foto image photo picture" } }, "consent": { diff --git a/site/src/messages/ru.json b/site/src/messages/ru.json index 3d91354f..508ad549 100644 --- a/site/src/messages/ru.json +++ b/site/src/messages/ru.json @@ -13,7 +13,9 @@ "terms": "Условия", "contact": "Контакты", "prompts": "ИИ-промпты", - "pregnancy": "Инструменты беременности" + "pregnancy": "Инструменты беременности", + "menu": "Меню", + "closeMenu": "Закрыть меню" }, "home": { "heading": "Бесплатные онлайн-инструменты, которые работают", @@ -68,6 +70,22 @@ "sources": "Источники", "noSources": "Основано на общепринятых стандартных формулах. О том, как создаются и проверяются инструменты, читайте на странице методологии.", "lastReviewed": "Последняя проверка" + }, + "recent": "Недавно использованные", + "searchPlaceholder": "Поиск инструментов…", + "searchOpen": "Поиск", + "searchClose": "Закрыть поиск", + "searchHint": "Нажмите / для поиска, Enter откроет первый результат", + "searchLoading": "Загрузка…", + "categoryKeywords": { + "health": "здоровье вес диета медицина health weight diet medical body", + "math": "математика расчёт процент число math calculation percentage number", + "converter": "конвертер единицы перевод convert unit conversion", + "datetime": "дата время дни календарь возраст date time days calendar age", + "text": "текст символы слова подсчёт text string character word count", + "color": "цвет палитра hex color colour palette hex", + "finance": "финансы деньги кредит зарплата налог процент finance money loan salary tax interest", + "image": "изображение фото image photo picture" } }, "consent": { diff --git a/site/src/messages/th.json b/site/src/messages/th.json index d65d4dfe..9782dc67 100644 --- a/site/src/messages/th.json +++ b/site/src/messages/th.json @@ -13,7 +13,9 @@ "terms": "ข้อกำหนด", "contact": "ติดต่อ", "prompts": "พรอมต์ AI", - "pregnancy": "เครื่องมือตั้งครรภ์" + "pregnancy": "เครื่องมือตั้งครรภ์", + "menu": "เมนู", + "closeMenu": "ปิดเมนู" }, "home": { "heading": "เครื่องมือออนไลน์ฟรีที่ใช้งานได้จริง", @@ -68,6 +70,22 @@ "sources": "แหล่งข้อมูล", "noSources": "อ้างอิงจากสูตรมาตรฐานที่ใช้กันอย่างแพร่หลาย ดูวิธีสร้างและตรวจสอบเครื่องมือได้ที่หน้าระเบียบวิธี", "lastReviewed": "ตรวจสอบล่าสุด" + }, + "recent": "ใช้ล่าสุด", + "searchPlaceholder": "ค้นหาเครื่องมือ…", + "searchOpen": "ค้นหา", + "searchClose": "ปิดการค้นหา", + "searchHint": "กด / เพื่อค้นหา, Enter เพื่อเปิดผลลัพธ์แรก", + "searchLoading": "กำลังโหลด…", + "categoryKeywords": { + "health": "สุขภาพ น้ำหนัก ลดน้ำหนัก การแพทย์ health weight diet medical body", + "math": "คณิตศาสตร์ คำนวณ เปอร์เซ็นต์ ตัวเลข math calculation percentage number", + "converter": "แปลง หน่วย convert unit conversion", + "datetime": "วันที่ เวลา จำนวนวัน ปฏิทิน อายุ date time days calendar age", + "text": "ข้อความ ตัวอักษร คำ นับ text string character word count", + "color": "สี พาเลต hex color colour palette hex", + "finance": "การเงิน เงิน สินเชื่อ เงินเดือน ภาษี ดอกเบี้ย finance money loan salary tax interest", + "image": "รูปภาพ ภาพถ่าย image photo picture" } }, "consent": { diff --git a/site/src/messages/tr.json b/site/src/messages/tr.json index 13a82633..15ae9a39 100644 --- a/site/src/messages/tr.json +++ b/site/src/messages/tr.json @@ -13,7 +13,9 @@ "terms": "Şartlar", "contact": "İletişim", "prompts": "AI Promptları", - "pregnancy": "Gebelik araçları" + "pregnancy": "Gebelik araçları", + "menu": "Menü", + "closeMenu": "Menüyü kapat" }, "home": { "heading": "Gerçekten çalışan ücretsiz çevrimiçi araçlar", @@ -68,6 +70,22 @@ "sources": "Kaynaklar", "noSources": "Yaygın olarak kullanılan standart formüllere dayanır. Araçların nasıl oluşturulup incelendiğini metodoloji sayfamızda bulabilirsiniz.", "lastReviewed": "Son inceleme" + }, + "recent": "Son kullanılanlar", + "searchPlaceholder": "Araç ara…", + "searchOpen": "Ara", + "searchClose": "Aramayı kapat", + "searchHint": "Aramak için /, ilk sonucu açmak için Enter", + "searchLoading": "Yükleniyor…", + "categoryKeywords": { + "health": "sağlık kilo diyet tıbbi health weight diet medical body", + "math": "matematik hesaplama yüzde sayı math calculation percentage number", + "converter": "dönüştür birim çevirme convert unit conversion", + "datetime": "tarih saat gün takvim yaş date time days calendar age", + "text": "metin karakter kelime sayma text string character word count", + "color": "renk palet hex color colour palette hex", + "finance": "finans para kredi maaş vergi faiz finance money loan salary tax interest", + "image": "görsel fotoğraf image photo picture" } }, "consent": { diff --git a/site/src/messages/vi.json b/site/src/messages/vi.json index 7c5ae238..fe21983c 100644 --- a/site/src/messages/vi.json +++ b/site/src/messages/vi.json @@ -13,7 +13,9 @@ "terms": "Điều khoản", "contact": "Liên hệ", "prompts": "AI Prompts", - "pregnancy": "Công cụ thai kỳ" + "pregnancy": "Công cụ thai kỳ", + "menu": "Menu", + "closeMenu": "Đóng menu" }, "home": { "heading": "Công cụ trực tuyến miễn phí thực sự hoạt động", @@ -68,6 +70,22 @@ "sources": "Nguồn", "noSources": "Dựa trên các công thức tiêu chuẩn được sử dụng rộng rãi. Xem trang phương pháp của chúng tôi để biết cách công cụ được xây dựng và kiểm tra.", "lastReviewed": "Kiểm tra lần cuối" + }, + "recent": "Dùng gần đây", + "searchPlaceholder": "Tìm công cụ…", + "searchOpen": "Tìm kiếm", + "searchClose": "Đóng tìm kiếm", + "searchHint": "Nhấn / để tìm, Enter mở kết quả đầu tiên", + "searchLoading": "Đang tải…", + "categoryKeywords": { + "health": "sức khỏe cân nặng ăn kiêng y tế health weight diet medical body", + "math": "toán tính phần trăm số math calculation percentage number", + "converter": "chuyển đổi đơn vị convert unit conversion", + "datetime": "ngày giờ số ngày lịch tuổi date time days calendar age", + "text": "văn bản ký tự từ đếm text string character word count", + "color": "màu bảng màu hex color colour palette hex", + "finance": "tài chính tiền vay lương thuế lãi finance money loan salary tax interest", + "image": "hình ảnh ảnh image photo picture" } }, "consent": { diff --git a/site/src/messages/zh-CN.json b/site/src/messages/zh-CN.json index 855f9f1c..61bfbb9c 100644 --- a/site/src/messages/zh-CN.json +++ b/site/src/messages/zh-CN.json @@ -13,7 +13,9 @@ "terms": "条款", "contact": "联系", "prompts": "AI 提示词", - "pregnancy": "怀孕工具" + "pregnancy": "怀孕工具", + "menu": "菜单", + "closeMenu": "关闭菜单" }, "home": { "heading": "好用的免费在线工具", @@ -68,6 +70,22 @@ "sources": "来源", "noSources": "基于广泛使用的标准公式。有关工具的构建与审核方式,请参阅方法说明页面。", "lastReviewed": "最近审核" + }, + "recent": "最近使用", + "searchPlaceholder": "搜索工具…", + "searchOpen": "搜索", + "searchClose": "关闭搜索", + "searchHint": "按 / 搜索,Enter 打开第一个结果", + "searchLoading": "加载中…", + "categoryKeywords": { + "health": "健康 体重 减肥 医疗 health weight diet medical body", + "math": "数学 计算 百分比 数字 math calculation percentage number", + "converter": "转换 单位 换算 convert unit conversion", + "datetime": "日期 时间 天数 日历 年龄 date time days calendar age", + "text": "文本 文字 字数 单词 text string character word count", + "color": "颜色 色彩 调色板 color colour palette hex", + "finance": "金融 金钱 贷款 工资 税 利息 finance money loan salary tax interest", + "image": "图片 照片 image photo picture" } }, "consent": { diff --git a/site/src/messages/zh-TW.json b/site/src/messages/zh-TW.json index c960a265..90a13628 100644 --- a/site/src/messages/zh-TW.json +++ b/site/src/messages/zh-TW.json @@ -13,7 +13,9 @@ "terms": "條款", "contact": "聯絡", "prompts": "AI 提示詞", - "pregnancy": "懷孕工具" + "pregnancy": "懷孕工具", + "menu": "選單", + "closeMenu": "關閉選單" }, "home": { "heading": "好用的免費線上工具", @@ -68,6 +70,22 @@ "sources": "來源", "noSources": "基於廣泛使用的標準公式。有關工具的建置與審核方式,請參閱方法說明頁面。", "lastReviewed": "最近審核" + }, + "recent": "最近使用", + "searchPlaceholder": "搜尋工具…", + "searchOpen": "搜尋", + "searchClose": "關閉搜尋", + "searchHint": "按 / 搜尋,Enter 開啟第一個結果", + "searchLoading": "載入中…", + "categoryKeywords": { + "health": "健康 體重 減肥 醫療 health weight diet medical body", + "math": "數學 計算 百分比 數字 math calculation percentage number", + "converter": "轉換 單位 換算 convert unit conversion", + "datetime": "日期 時間 天數 日曆 年齡 date time days calendar age", + "text": "文字 字數 單字 text string character word count", + "color": "顏色 色彩 調色盤 color colour palette hex", + "finance": "金融 金錢 貸款 薪資 稅 利息 finance money loan salary tax interest", + "image": "圖片 照片 image photo picture" } }, "consent": {