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
6 changes: 6 additions & 0 deletions site/src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -219,6 +221,10 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
</div>
</section>

{/* ── Favorites / Recently used (localStorage; 初回訪問では何も出ない) ── */}
<FavoritesSection className="mt-12" />
<RecentSection className="mt-12" />

{/* ── Popular tools ─────────────────────────────────────────────── */}
<section className="mt-12">
<div className="flex items-baseline justify-between">
Expand Down
39 changes: 39 additions & 0 deletions site/src/app/[locale]/search-index.json/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, { title?: string; shortDescription?: string }>;
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" } },
);
}
7 changes: 6 additions & 1 deletion site/src/app/[locale]/tools/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`),
}));
Expand Down Expand Up @@ -70,6 +73,8 @@ export default async function ToolsIndex({ params }: { params: Promise<{ locale:

<FavoritesSection items={items} />

<RecentSection items={items} />

{Array.from(byCategory.entries()).map(([cat, list]) => {
const cfg = CATEGORY_CONFIG[cat as ToolCategory];
return (
Expand Down
43 changes: 28 additions & 15 deletions site/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -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 / 言語 — 従来の並び。
* モバイル(<sm): ロゴ / 🔍 / ☰ の 3 点のみ。ナビと言語切替は MobileMenu に収容。
*/
export function Header() {
const t = useTranslations();
const locale = useLocale();
const showPrompts = isPromptLocale(locale);
const navCls = "text-slate-600 hover:text-brand-600 dark:text-slate-400 dark:hover:text-brand-500";
return (
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white/90 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/90">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<Link href="/" className="flex items-center gap-2 text-lg font-bold tracking-tight">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 sm:gap-4">
<Link href="/" className="flex shrink-0 items-center gap-2 text-lg font-bold tracking-tight">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-base leading-none text-white">
🔧
</span>
{t("site.name")}
</Link>
<nav className="flex items-center gap-5 text-sm font-medium">
<Link href="/tools" className="text-slate-600 hover:text-brand-600 dark:text-slate-400 dark:hover:text-brand-500">
{t("nav.tools")}
</Link>
{showPrompts && (
<Link href="/prompts" className="text-slate-600 hover:text-brand-600 dark:text-slate-400 dark:hover:text-brand-500">
{t("nav.prompts")}
<div className="ml-auto flex items-center gap-1 sm:gap-4">
<HeaderSearch />
<nav className="hidden items-center gap-5 text-sm font-medium sm:flex">
<Link href="/tools" className={navCls}>
{t("nav.tools")}
</Link>
)}
<Link href="/about" className="hidden text-slate-600 hover:text-brand-600 dark:text-slate-400 dark:hover:text-brand-500 sm:inline">
{t("nav.about")}
</Link>
<LanguageSwitcher />
</nav>
{showPrompts && (
<Link href="/prompts" className={navCls}>
{t("nav.prompts")}
</Link>
)}
<Link href="/about" className={navCls}>
{t("nav.about")}
</Link>
<LanguageSwitcher />
</nav>
<MobileMenu showPrompts={showPrompts}>
<LanguageSwitcher />
</MobileMenu>
</div>
</div>
</header>
);
Expand Down
193 changes: 193 additions & 0 deletions site/src/components/layout/HeaderSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(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<HTMLInputElement>) {
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 && (
<ul
id={listId}
role="listbox"
aria-label={t("searchResults", { n: hits.length })}
className="max-h-[60vh] overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 text-sm shadow-lg dark:border-slate-700 dark:bg-slate-900 sm:absolute sm:right-0 sm:top-full sm:mt-2 sm:w-80"
>
{index === null ? (
<li className="px-3 py-2 text-slate-500">{t("searchLoading")}</li>
) : hits.length === 0 ? (
<li className="px-3 py-2 text-slate-500">{t("searchNoResults")}</li>
) : (
hits.map((h, i) => (
<li key={h.slug} role="option" aria-selected={i === active}>
<Link
href={`/tools/${h.slug}`}
onMouseEnter={() => 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"}`}
>
<span className="text-base" aria-hidden>
{CATEGORY_CONFIG[h.category].emoji}
</span>
<span className="truncate">{h.title}</span>
</Link>
</li>
))
)}
</ul>
);

return (
<div ref={rootRef} className="sm:relative">
{/* モバイル: アイコンのみ */}
<button
type="button"
onClick={() => (open ? close() : activate())}
aria-label={open ? t("searchClose") : t("searchOpen")}
aria-expanded={open}
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 sm:hidden"
>
<SearchIcon />
</button>

<div
className={
open
? "absolute inset-x-0 top-full z-50 border-b border-slate-200 bg-white p-3 shadow-md dark:border-slate-800 dark:bg-slate-900 sm:static sm:border-0 sm:bg-transparent sm:p-0 sm:shadow-none"
: "hidden sm:block"
}
>
<div className="relative">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" aria-hidden>
<SearchIcon className="h-4 w-4" />
</span>
<input
ref={inputRef}
type="search"
value={q}
onChange={(e) => 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"
/>
</div>
<div className="mt-2 sm:mt-0">{list}</div>
</div>
</div>
);
}

function SearchIcon({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
Loading
Loading