feat: add Toaster, notification bell, search, unread badges, last login, recent rooms, hotel alerts, quick room entry, friend requests in top bar
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m53s

This commit is contained in:
openhands committed 2026-07-26 13:17:51 +02:00
1 parent a906d671fb
commit 3284e5a915
5 files changed
+484 -19

No files matched your search

+91 -2
View File
@@ -69,7 +69,7 @@ export default async function MePage({
let content: ReactNode;
try {
const [user, imagerBase, hotelName, neededRaw, amountRaw, currencyRaw] =
const [user, imagerBase, hotelName, neededRaw, amountRaw, currencyRaw, alertRaw, recentRooms] =
await Promise.all([
prisma.user.findUnique({
where: { id: userId },
@@ -81,6 +81,8 @@ export default async function MePage({
rank: true,
credits: true,
accountCreated: true,
lastLogin: true,
lastOnline: true,
},
}),
siteSettings.get(
@@ -113,6 +115,14 @@ export default async function MePage({
select: { value: true },
})
.catch(() => null),
siteSettings.get("hotel_alert_banner", ""),
prisma.rooms
.findMany({
orderBy: { id: "desc" },
select: { id: true, name: true, ownerName: true, users: true, usersMax: true },
take: 6,
})
.catch(() => []),
]);
if (!user) throw new Error("user-not-found");
@@ -169,6 +179,12 @@ export default async function MePage({
const registered = new Date(user.accountCreated * 1000)
.toISOString()
.slice(0, 10);
const lastLoginDate = user.lastLogin
? new Date(user.lastLogin * 1000).toISOString().slice(0, 10)
: "Never";
const lastOnlineDate = user.lastOnline
? new Date(user.lastOnline * 1000).toISOString().slice(0, 10)
: "Never";
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
const imager = imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
@@ -256,6 +272,23 @@ export default async function MePage({
</div>
</Reveal>
{/* ── Hotel alert banner ── */}
{alertRaw ? (
<Reveal delay={0.05}>
<div
className="rounded-2xl border px-6 py-4 text-center"
style={{
background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
borderColor: "color-mix(in srgb, var(--color-accent) 25%, transparent)",
}}
>
<p className="text-sm font-bold" style={{ color: "var(--color-accent)" }}>
{alertRaw}
</p>
</div>
</Reveal>
) : null}
{/* ── Enter hotel ── */}
<Reveal delay={0.05}>
<Link
@@ -285,7 +318,7 @@ export default async function MePage({
{[
{ value: user.credits.toLocaleString(), label: "Credits" },
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
{ value: registered, label: "Registered" },
{ value: lastLoginDate, label: "Last login" },
].map((s) => (
<div
key={s.label}
@@ -489,6 +522,62 @@ export default async function MePage({
</div>
</div>
</Reveal>
{/* ── Recent rooms ── */}
{recentRooms.length > 0 && (
<Reveal delay={0.25}>
<div
className="rounded-2xl border p-5"
style={{
background: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
}}
>
<div className="flex items-center justify-between mb-4">
<div>
<h2
className="text-lg font-black tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
Recent rooms
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
Newest rooms in the hotel
</p>
</div>
<Link
href="/community"
className="text-xs font-bold hover:underline shrink-0"
style={{ color: "var(--color-primary)" }}
>
View all
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{recentRooms.map((room) => (
<Link
key={room.id}
href={`/room/${room.id}`}
className="block 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>
</div>
</Reveal>
)}
</div>
);
} catch {
+173
View File
@@ -0,0 +1,173 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata: Metadata = { title: "Search" };
type SearchParams = Promise<{ q?: string }>;
export default async function SearchPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { q } = await searchParams;
const query = q?.trim();
const imager = (await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? "";
if (!query) {
return (
<div className="rounded-2xl border p-10 text-center" style={{ background: "var(--color-surface)", borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}>
<p className="text-sm font-bold" style={{ color: "var(--color-text-muted)" }}>
Enter a search term to find users.
</p>
</div>
);
}
const users = await prisma.user
.findMany({
where: {
username: { contains: query },
},
select: {
id: true,
username: true,
look: true,
motto: true,
online: true,
rank: true,
},
take: 30,
})
.catch(() => []);
const rooms = await prisma.rooms
.findMany({
where: {
name: { contains: query },
state: "open",
},
select: {
id: true,
name: true,
ownerName: true,
users: true,
usersMax: true,
},
take: 12,
})
.catch(() => []);
return (
<div className="flex flex-col gap-6">
<div className="rounded-2xl border p-4" style={{ background: "var(--color-surface)", borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}>
<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
</button>
</form>
</div>
<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 && (
<div className="rounded-2xl border p-5" style={{ background: "var(--color-surface)", borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}>
<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)" }}
>
<Image
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 2 })}
alt={u.username}
width={40}
height={48}
unoptimized
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>
</div>
)}
{rooms.length > 0 && (
<div className="rounded-2xl border p-5" style={{ background: "var(--color-surface)", borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}>
<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>
</div>
)}
{users.length === 0 && rooms.length === 0 && (
<div className="rounded-2xl border p-10 text-center" style={{ background: "var(--color-surface)", borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}>
<p className="text-sm font-bold" style={{ color: "var(--color-text-muted)" }}>
No users or rooms found for &quot;{query}&quot;.
</p>
</div>
)}
</div>
);
}
+13
View File
@@ -5,6 +5,7 @@ import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
import { Toaster } from "sonner";
import { ViewTransitions } from "next-view-transitions";
import { PwaRegister } from "@/components/pwa-register";
import { ThemeVars } from "@/components/theme-vars";
@@ -94,6 +95,18 @@ export default async function RootLayout({
</ViewTransitions>
<PwaRegister />
<SmoothScroll />
<Toaster
position="top-right"
richColors
closeButton
toastOptions={{
style: {
background: "var(--color-surface)",
color: "var(--color-text-readable)",
border: "1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
},
}}
/>
</NextIntlClientProvider>
</body>
</html>