summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-15 20:12:25 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-08-15 20:12:25 +0900
commitb9e06d9a139e6d9a6d509f500beee97d0d071602 (patch)
tree1e131cac0c65471da6b17ca1abff258412f8816f /app
parent1777cdbf812959e39a2b2abc142afc2fe8932938 (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.
Diffstat (limited to 'app')
-rw-r--r--app/app.css2
-rw-r--r--app/lib/db.server.ts19
-rw-r--r--app/root.tsx4
-rw-r--r--app/routes.ts1
-rw-r--r--app/routes/search.tsx73
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>
+ );
+}