diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 20:12:25 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 20:12:25 +0900 |
| commit | b9e06d9a139e6d9a6d509f500beee97d0d071602 (patch) | |
| tree | 1e131cac0c65471da6b17ca1abff258412f8816f | |
| parent | 1777cdbf812959e39a2b2abc142afc2fe8932938 (diff) | |
Add site-wide search for bands and artists
Adds a nav search box and /search route backed by a LIKE-based
searchAll() query over band name/area/description and artist name.
| -rw-r--r-- | app/app.css | 2 | ||||
| -rw-r--r-- | app/lib/db.server.ts | 19 | ||||
| -rw-r--r-- | app/root.tsx | 4 | ||||
| -rw-r--r-- | app/routes.ts | 1 | ||||
| -rw-r--r-- | app/routes/search.tsx | 73 |
5 files changed, 99 insertions, 0 deletions
diff --git a/app/app.css b/app/app.css index 9125f90..0e301b5 100644 --- a/app/app.css +++ b/app/app.css @@ -23,6 +23,8 @@ button { cursor: pointer; } .nav-brand { font-weight: 700; color: #f9fafb; font-size: 1.1rem; } .nav a { font-size: .875rem; color: #9ca3af; } .nav a:hover { color: #f3f4f6; text-decoration: none; } +.nav-search { margin-left: auto; } +.nav-search input { font-size: .8rem; padding: .3rem .625rem; width: 12rem; } main { max-width: 760px; margin: 2rem auto; padding: 0 1.5rem; } diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts index 37d819b..e554d75 100644 --- a/app/lib/db.server.ts +++ b/app/lib/db.server.ts @@ -481,6 +481,25 @@ function buildSnapshot(band: Band, links: BandLink[], members: BandMemberRow[]): }); } +// ── Search ──────────────────────────────────────────────────────────────────── + +export interface SearchResults { + bands: Band[]; + artists: Artist[]; +} + +export function searchAll(query: string): SearchResults { + const db = getDb(); + const q = `%${query}%`; + const bands = db + .prepare("SELECT * FROM bands WHERE name LIKE ? OR area LIKE ? OR description LIKE ? ORDER BY name LIMIT 50") + .all(q, q, q) as Band[]; + const artists = db + .prepare("SELECT * FROM artists WHERE name LIKE ? ORDER BY name LIMIT 50") + .all(q) as Artist[]; + return { bands, artists }; +} + // ── Artist queries ──────────────────────────────────────────────────────────── export function listArtists(): Artist[] { diff --git a/app/root.tsx b/app/root.tsx index 6c60350..f422f52 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,5 @@ import { + Form, isRouteErrorResponse, Link, Links, @@ -55,6 +56,9 @@ export default function App() { <Link to="/" className="nav-brand">whois.band</Link> <Link to="/bands">Bands</Link> <Link to="/artists">Artists</Link> + <Form method="get" action="/search" className="nav-search"> + <input type="search" name="q" placeholder="検索" /> + </Form> </nav> <Outlet /> </> diff --git a/app/routes.ts b/app/routes.ts index 6125a73..5660edb 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), + route("/search", "routes/search.tsx"), route("/api/bands", "routes/api-bands.tsx"), route("/api/artists", "routes/api-artists.tsx"), route("/api/export", "routes/api-export.tsx"), diff --git a/app/routes/search.tsx b/app/routes/search.tsx new file mode 100644 index 0000000..a4c03b7 --- /dev/null +++ b/app/routes/search.tsx @@ -0,0 +1,73 @@ +import { Form, Link, useLoaderData } from "react-router"; +import type { Route } from "./+types/search"; +import { searchAll } from "~/lib/db.server"; + +export function loader({ request }: Route.LoaderArgs) { + const q = new URL(request.url).searchParams.get("q")?.trim() ?? ""; + if (!q) return { q, bands: [], artists: [] }; + const { bands, artists } = searchAll(q); + return { q, bands, artists }; +} + +export default function Search() { + const { q, bands, artists } = useLoaderData<typeof loader>(); + const hasResults = bands.length > 0 || artists.length > 0; + + return ( + <main> + <div className="page-header"> + <h1>検索</h1> + </div> + + <Form method="get" action="/search" style={{ marginBottom: "1.5rem" }}> + <input + type="search" + name="q" + defaultValue={q} + placeholder="バンド名・アーティスト名で検索" + autoFocus + /> + </Form> + + {!q ? ( + <p className="muted">検索キーワードを入力してください。</p> + ) : !hasResults ? ( + <p className="muted">「{q}」に一致する結果が見つかりませんでした。</p> + ) : ( + <> + {bands.length > 0 && ( + <section> + <h2>Bands</h2> + <ul className="band-list"> + {bands.map((band) => ( + <li key={band.id}> + <Link to={`/bands/of/${band.id}`}>{band.name}</Link> + {band.area && ( + <span className="muted" style={{ fontSize: ".75rem" }}>{band.area}</span> + )} + {band.status === "hiatus" && ( + <span className="muted" style={{ fontSize: ".75rem" }}>活動休止</span> + )} + </li> + ))} + </ul> + </section> + )} + + {artists.length > 0 && ( + <section> + <h2>Artists</h2> + <ul className="band-list"> + {artists.map((artist) => ( + <li key={artist.id}> + <Link to={`/artists/of/${artist.id}`}>{artist.name}</Link> + </li> + ))} + </ul> + </section> + )} + </> + )} + </main> + ); +} |
