feat(search): search public news events users and open rooms
CI / check (push) Failing after 54s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 00:29:54 +02:00
1 parent 506e14783c
commit ba9c61d808
5 files changed
+424 -184

No files matched your search

+211 -183
View File
@@ -1,200 +1,228 @@
import { and, eq, like } from "drizzle-orm";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { LocalEventTime } from "@/components/public/local-event-time";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
import { db, Rooms, User } from "@/lib/db";
import { loadPublicSearch } from "@/lib/services/public-search";
export const metadata: Metadata = {
title: "Search",
description: "Search for users and rooms in the hotel.",
openGraph: {
title: "Search",
description: "Search for users and rooms in the hotel.",
type: "website",
},
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.search");
return { title: t("title"), description: t("subtitle") };
}
type Params = {
q?: string;
usersPage?: string;
roomsPage?: string;
newsPage?: string;
eventsPage?: string;
};
type SearchParams = Promise<{ q?: string }>;
export default async function SearchPage({
searchParams,
}: {
searchParams: SearchParams;
searchParams: Promise<Params>;
}) {
const { q } = await searchParams;
const query = q?.trim();
if (!query) {
const t = await getTranslations("pages.search");
const params = await searchParams;
const result = await loadPublicSearch({
query: params.q ?? "",
usersPage: Number(params.usersPage ?? 1),
roomsPage: Number(params.roomsPage ?? 1),
newsPage: Number(params.newsPage ?? 1),
eventsPage: Number(params.eventsPage ?? 1),
});
const query = result?.query ?? "";
const pagination = (
kind: "users" | "rooms" | "news" | "events",
data: { page: number; lastPage: number; total: number },
) => {
const href = (page: number) =>
`/search?${new URLSearchParams({ ...Object.fromEntries(Object.entries(params).filter((entry): entry is [string, string] => typeof entry[1] === "string")), q: query, [`${kind}Page`]: String(page) })}#${kind}`;
return (
<SurfaceCard className="p-10 text-center">
<p
className="text-sm font-bold"
style={{ color: "var(--color-text-muted)" }}
>
Enter a search term to find users.
</p>
</SurfaceCard>
<nav
aria-label={t("pagination", { category: t(kind) })}
className="mt-4 flex flex-wrap items-center justify-between gap-3"
>
{data.page > 1 ? (
<Link href={href(data.page - 1)} className="btn">
{t("previous")}
</Link>
) : (
<span />
)}
<span className="text-sm">
{t("pageSummary", {
page: data.page,
pages: data.lastPage,
total: data.total,
})}
</span>
{data.page < data.lastPage ? (
<Link href={href(data.page + 1)} className="btn">
{t("next")}
</Link>
) : (
<span />
)}
</nav>
);
}
const [users, rooms] = await Promise.all([
db
.select({
id: User.id,
username: User.username,
look: User.look,
motto: User.motto,
online: User.online,
rank: User.rank,
})
.from(User)
.where(like(User.username, `%${query}%`))
.limit(30)
.catch(() => []),
db
.select({
id: Rooms.id,
name: Rooms.name,
ownerName: Rooms.ownerName,
users: Rooms.users,
usersMax: Rooms.usersMax,
})
.from(Rooms)
.where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open")))
.limit(12)
.catch(() => []),
]);
};
return (
<div className="flex flex-col gap-6">
<SurfaceCard className="p-4">
<form action="/search" method="GET" className="flex gap-2">
<input
type="text"
name="q"
defaultValue={query}
placeholder="Search users or rooms..."
className="flex-1 rounded-xl border-2 px-4 py-2.5 text-sm font-medium"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text-readable)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
<button
type="submit"
className="rounded-xl px-6 py-2.5 font-extrabold text-sm transition-all duration-200 hover:scale-[1.02] active:scale-95"
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
}}
>
Search
<main className="page-grid">
<ContentCard title={t("title")} subtitle={t("subtitle")} icon="🔎" />
<ContentCard>
<form
action="/search"
method="GET"
className="flex flex-wrap items-end gap-3"
>
<label className="flex min-w-0 flex-1 flex-col gap-1">
{t("queryLabel")}
<input
name="q"
defaultValue={query}
maxLength={191}
className="input w-full"
/>
</label>
<button type="submit" className="btn btn-primary">
{t("submit")}
</button>
</form>
</SurfaceCard>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Results for &quot;{query}&quot; — {users.length} user
{users.length !== 1 ? "s" : ""}
{rooms.length > 0 &&
`, ${rooms.length} room${rooms.length !== 1 ? "s" : ""}`}
</p>
{users.length > 0 && (
<SurfaceCard className="p-5">
<h2
className="text-lg font-black tracking-tight mb-4"
style={{ color: "var(--color-text-readable)" }}
>
Users
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{users.map((u) => (
<Link
key={u.id}
href={`/u/${u.username}`}
className="flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all duration-200 hover:-translate-y-0.5"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
}}
>
<UserAvatarThumbnail
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="rounded-lg"
/>
<span
className="text-xs font-extrabold truncate max-w-full text-center"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
<span
className="text-[9px] font-semibold"
style={{ color: "var(--color-text-muted)" }}
>
{u.online === "1" ? "🟢 Online" : "Offline"}
</span>
</Link>
))}
</div>
</SurfaceCard>
</ContentCard>
{!result ? (
<ContentCard>
<EmptyState>{t("enterQuery")}</EmptyState>
</ContentCard>
) : (
<>
<p>{t("resultsFor", { query })}</p>
<section id="users">
<ContentCard title={t("users")}>
{result.users.status === "rejected" ? (
<p role="alert">{t("loadError")}</p>
) : (
<>
{result.users.value.rows.length === 0 ? (
<EmptyState>{t("empty")}</EmptyState>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-5">
{result.users.value.rows.map((user) => (
<Link
key={user.id}
href={`/u/${encodeURIComponent(user.username)}`}
className="flex min-w-0 flex-col items-center gap-2 rounded-xl p-3"
>
<UserAvatarThumbnail
figure={user.look}
alt={user.username}
options={{ direction: 2 }}
/>
<span className="max-w-full truncate font-bold">
{user.username}
</span>
<span className="text-xs muted">
{t(user.online === "1" ? "online" : "offline")}
</span>
</Link>
))}
</div>
)}
{pagination("users", result.users.value)}
</>
)}
</ContentCard>
</section>
<section id="rooms">
<ContentCard title={t("rooms")}>
{result.rooms.status === "rejected" ? (
<p role="alert">{t("loadError")}</p>
) : (
<>
{result.rooms.value.rows.length === 0 ? (
<EmptyState>{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3">
{result.rooms.value.rows.map((room) => (
<Link
key={room.id}
href={`/room/${room.id}`}
className="card hover p-3"
>
<h3 className="break-words font-bold">{room.name}</h3>
<p className="text-sm muted">
{room.ownerName} · {room.users}/{room.usersMax}
</p>
</Link>
))}
</div>
)}
{pagination("rooms", result.rooms.value)}
</>
)}
</ContentCard>
</section>
<section id="news">
<ContentCard title={t("news")}>
{result.news.status === "rejected" ? (
<p role="alert">{t("loadError")}</p>
) : (
<>
{result.news.value.rows.length === 0 ? (
<EmptyState>{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3">
{result.news.value.rows.map((article) => (
<Link
key={article.slug}
href={`/news/${article.slug}`}
className="card hover p-3"
>
<h3 className="break-words font-bold">
{article.title}
</h3>
</Link>
))}
</div>
)}
{pagination("news", result.news.value)}
</>
)}
</ContentCard>
</section>
<section id="events">
<ContentCard title={t("events")}>
{result.events.status === "rejected" ? (
<p role="alert">{t("loadError")}</p>
) : (
<>
{result.events.value.rows.length === 0 ? (
<EmptyState>{t("empty")}</EmptyState>
) : (
<div className="card-grid sm-2 lg-3">
{result.events.value.rows.map((event) => (
<Link
key={event.id}
href={`/events/${event.id}`}
className="card hover p-3"
>
<h3 className="break-words font-bold">
{event.title}
</h3>
<LocalEventTime
value={event.startsAt.toISOString()}
/>
</Link>
))}
</div>
)}
{pagination("events", result.events.value)}
</>
)}
</ContentCard>
</section>
</>
)}
{rooms.length > 0 && (
<SurfaceCard className="p-5">
<h2
className="text-lg font-black tracking-tight mb-4"
style={{ color: "var(--color-text-readable)" }}
>
Rooms
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{rooms.map((room) => (
<Link
key={room.id}
href={`/room/${room.id}`}
className="rounded-xl border p-3 transition-all duration-200 hover:-translate-y-0.5"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<p
className="text-sm font-extrabold truncate"
style={{ color: "var(--color-text-readable)" }}
>
{room.name}
</p>
<p
className="text-[10px] font-semibold mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{room.ownerName} · {room.users}/{room.usersMax}
</p>
</Link>
))}
</div>
</SurfaceCard>
)}
{users.length === 0 && rooms.length === 0 && (
<SurfaceCard className="p-10 text-center">
<p
className="text-sm font-bold"
style={{ color: "var(--color-text-muted)" }}
>
No users or rooms found for &quot;{query}&quot;.
</p>
</SurfaceCard>
)}
</div>
</main>
);
}
+3 -1
View File
@@ -5,7 +5,9 @@ import { db, WebsiteArticles } from "@/lib/db";
export async function loadNewsArchive(
options: { search?: string; page?: number; order?: string } = {},
) {
const search = (options.search ?? "").trim().slice(0, 191);
const search = (typeof options.search === "string" ? options.search : "")
.trim()
.slice(0, 191);
const order = options.order === "oldest" ? "oldest" : "newest";
const publishedAt =
sql<Date | null>`COALESCE(${WebsiteArticles.publishAt}, ${WebsiteArticles.createdAt})`.mapWith(
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { logger } from "@/lib/logger";
/** Keep failed public reads distinct from valid empty collections and zero counts. */
export function publicReadFailure(section: string) {
return (error: unknown): null => {
logger.error("Public section query failed", {
module: "public-data",
section,
error,
});
return null;
};
}
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
queries: [] as { sql: string; params: unknown[] }[],
failUsers: false,
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
return {
...schema,
db: drizzle(async (sql, params) => {
state.queries.push({ sql, params });
if (state.failUsers && sql.includes("from `users`"))
throw Error("users unavailable");
return { rows: sql.includes("count(*)") ? [[50]] : [] };
}),
};
});
import { loadPublicSearch } from "./public-search";
describe("public search", () => {
beforeEach(() => {
state.queries = [];
state.failUsers = false;
});
it("does not query for a blank term", async () => {
expect(await loadPublicSearch({ query: " " })).toBeNull();
expect(state.queries).toEqual([]);
});
it("searches all four categories with public visibility predicates", async () => {
const result = await loadPublicSearch({ query: "needle'", roomsPage: 2 });
expect(result?.rooms.status).toBe("fulfilled");
const rooms = state.queries.filter((q) => q.sql.includes("from `rooms`"));
expect(rooms[0].params).toEqual(["%needle'%", "open"]);
expect(rooms[1].params.slice(-2)).toEqual([12, 12]);
const news = state.queries.find((q) =>
q.sql.includes("from `website_articles`"),
);
expect(news?.sql).toContain("<= NOW()");
expect(news?.params[0]).toBe("published");
const events = state.queries.find((q) =>
q.sql.includes("from `website_events`"),
);
expect(events?.params.slice(0, 2)).toEqual(["published", "completed"]);
});
it("preserves successful categories when one query fails", async () => {
state.failUsers = true;
const result = await loadPublicSearch({ query: "test" });
expect(result?.users.status).toBe("rejected");
expect(result?.rooms.status).toBe("fulfilled");
expect(result?.news.status).toBe("fulfilled");
expect(result?.events.status).toBe("fulfilled");
});
it("clamps each independent page and limits query length", async () => {
const result = await loadPublicSearch({
query: "x".repeat(500),
usersPage: 999,
roomsPage: -1,
});
expect(result?.query).toHaveLength(191);
expect(result?.users).toMatchObject({
status: "fulfilled",
value: { page: 5 },
});
expect(result?.rooms).toMatchObject({
status: "fulfilled",
value: { page: 1 },
});
});
});
+124
View File
@@ -0,0 +1,124 @@
import { publicReadFailure } from "@/lib/services/public-read";
import "server-only";
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
import { db, Rooms, User, WebsiteEvent } from "@/lib/db";
import { loadNewsArchive } from "@/lib/services/news-archive";
function pageNumber(value: number | undefined, total: number) {
return Math.min(
Math.max(1, Math.ceil(total / 12)),
Number.isFinite(value) ? Math.max(1, Math.trunc(value ?? 1)) : 1,
);
}
export async function loadPublicSearch(options: {
query: string;
usersPage?: number;
roomsPage?: number;
newsPage?: number;
eventsPage?: number;
}) {
const query = (typeof options.query === "string" ? options.query : "")
.trim()
.slice(0, 191);
if (!query) return null;
const userWhere = like(User.username, `%${query}%`);
const roomWhere = and(
like(Rooms.name, `%${query}%`),
eq(Rooms.state, "open"),
);
const eventWhere = and(
inArray(WebsiteEvent.status, ["published", "completed"]),
or(
like(WebsiteEvent.title, `%${query}%`),
like(WebsiteEvent.description, `%${query}%`),
),
);
const [users, rooms, news, events] = await Promise.allSettled([
(async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(User)
.where(userWhere);
const page = pageNumber(options.usersPage, Number(total));
const rows = await db
.select({
id: User.id,
username: User.username,
look: User.look,
online: User.online,
})
.from(User)
.where(userWhere)
.orderBy(asc(User.username), asc(User.id))
.limit(12)
.offset((page - 1) * 12);
return {
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
};
})(),
(async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(Rooms)
.where(roomWhere);
const page = pageNumber(options.roomsPage, Number(total));
const rows = await db
.select({
id: Rooms.id,
name: Rooms.name,
ownerName: Rooms.ownerName,
users: Rooms.users,
usersMax: Rooms.usersMax,
})
.from(Rooms)
.where(roomWhere)
.orderBy(asc(Rooms.name), asc(Rooms.id))
.limit(12)
.offset((page - 1) * 12);
return {
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
};
})(),
loadNewsArchive({ search: query, page: options.newsPage }),
(async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(WebsiteEvent)
.where(eventWhere);
const page = pageNumber(options.eventsPage, Number(total));
const rows = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
startsAt: WebsiteEvent.startsAt,
})
.from(WebsiteEvent)
.where(eventWhere)
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
.limit(12)
.offset((page - 1) * 12);
return {
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
};
})(),
]);
for (const [section, result] of Object.entries({
users,
rooms,
news,
events,
})) {
if (result.status === "rejected")
publicReadFailure(`search.${section}`)(result.reason);
}
return { query, users, rooms, news, events };
}