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 type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link"; 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 { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card"; import { loadPublicSearch } from "@/lib/services/public-search";
import { db, Rooms, User } from "@/lib/db";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Search", const t = await getTranslations("pages.search");
description: "Search for users and rooms in the hotel.", return { title: t("title"), description: t("subtitle") };
openGraph: { }
title: "Search", type Params = {
description: "Search for users and rooms in the hotel.", q?: string;
type: "website", usersPage?: string;
}, roomsPage?: string;
newsPage?: string;
eventsPage?: string;
}; };
type SearchParams = Promise<{ q?: string }>;
export default async function SearchPage({ export default async function SearchPage({
searchParams, searchParams,
}: { }: {
searchParams: SearchParams; searchParams: Promise<Params>;
}) { }) {
const { q } = await searchParams; const t = await getTranslations("pages.search");
const query = q?.trim(); const params = await searchParams;
const result = await loadPublicSearch({
if (!query) { 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 ( return (
<SurfaceCard className="p-10 text-center"> <nav
<p aria-label={t("pagination", { category: t(kind) })}
className="text-sm font-bold" className="mt-4 flex flex-wrap items-center justify-between gap-3"
style={{ color: "var(--color-text-muted)" }} >
> {data.page > 1 ? (
Enter a search term to find users. <Link href={href(data.page - 1)} className="btn">
</p> {t("previous")}
</SurfaceCard> </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 ( return (
<div className="flex flex-col gap-6"> <main className="page-grid">
<SurfaceCard className="p-4"> <ContentCard title={t("title")} subtitle={t("subtitle")} icon="🔎" />
<form action="/search" method="GET" className="flex gap-2"> <ContentCard>
<input <form
type="text" action="/search"
name="q" method="GET"
defaultValue={query} className="flex flex-wrap items-end gap-3"
placeholder="Search users or rooms..." >
className="flex-1 rounded-xl border-2 px-4 py-2.5 text-sm font-medium" <label className="flex min-w-0 flex-1 flex-col gap-1">
style={{ {t("queryLabel")}
backgroundColor: "var(--color-background)", <input
color: "var(--color-text-readable)", name="q"
borderColor: defaultValue={query}
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)", maxLength={191}
}} className="input w-full"
/> />
<button </label>
type="submit" <button type="submit" className="btn btn-primary">
className="rounded-xl px-6 py-2.5 font-extrabold text-sm transition-all duration-200 hover:scale-[1.02] active:scale-95" {t("submit")}
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
}}
>
Search
</button> </button>
</form> </form>
</SurfaceCard> </ContentCard>
{!result ? (
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}> <ContentCard>
Results for &quot;{query}&quot; — {users.length} user <EmptyState>{t("enterQuery")}</EmptyState>
{users.length !== 1 ? "s" : ""} </ContentCard>
{rooms.length > 0 && ) : (
`, ${rooms.length} room${rooms.length !== 1 ? "s" : ""}`} <>
</p> <p>{t("resultsFor", { query })}</p>
<section id="users">
{users.length > 0 && ( <ContentCard title={t("users")}>
<SurfaceCard className="p-5"> {result.users.status === "rejected" ? (
<h2 <p role="alert">{t("loadError")}</p>
className="text-lg font-black tracking-tight mb-4" ) : (
style={{ color: "var(--color-text-readable)" }} <>
> {result.users.value.rows.length === 0 ? (
Users <EmptyState>{t("empty")}</EmptyState>
</h2> ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-5">
{users.map((u) => ( {result.users.value.rows.map((user) => (
<Link <Link
key={u.id} key={user.id}
href={`/u/${u.username}`} href={`/u/${encodeURIComponent(user.username)}`}
className="flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all duration-200 hover:-translate-y-0.5" className="flex min-w-0 flex-col items-center gap-2 rounded-xl p-3"
style={{ >
background: <UserAvatarThumbnail
"color-mix(in srgb, var(--color-primary) 4%, transparent)", figure={user.look}
}} alt={user.username}
> options={{ direction: 2 }}
<UserAvatarThumbnail />
figure={u.look} <span className="max-w-full truncate font-bold">
alt={u.username} {user.username}
options={{ direction: 2 }} </span>
className="rounded-lg" <span className="text-xs muted">
/> {t(user.online === "1" ? "online" : "offline")}
<span </span>
className="text-xs font-extrabold truncate max-w-full text-center" </Link>
style={{ color: "var(--color-text-readable)" }} ))}
> </div>
{u.username} )}
</span> {pagination("users", result.users.value)}
<span </>
className="text-[9px] font-semibold" )}
style={{ color: "var(--color-text-muted)" }} </ContentCard>
> </section>
{u.online === "1" ? "🟢 Online" : "Offline"} <section id="rooms">
</span> <ContentCard title={t("rooms")}>
</Link> {result.rooms.status === "rejected" ? (
))} <p role="alert">{t("loadError")}</p>
</div> ) : (
</SurfaceCard> <>
{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>
</>
)} )}
</main>
{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>
); );
} }
+3 -1
View File
@@ -5,7 +5,9 @@ import { db, WebsiteArticles } from "@/lib/db";
export async function loadNewsArchive( export async function loadNewsArchive(
options: { search?: string; page?: number; order?: string } = {}, 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 order = options.order === "oldest" ? "oldest" : "newest";
const publishedAt = const publishedAt =
sql<Date | null>`COALESCE(${WebsiteArticles.publishAt}, ${WebsiteArticles.createdAt})`.mapWith( 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 };
}