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>
);
}