From 3284e5a9155eff1459417b23f02e09cc4d10f490 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 26 Jul 2026 13:17:51 +0200 Subject: [PATCH] feat: add Toaster, notification bell, search, unread badges, last login, recent rooms, hotel alerts, quick room entry, friend requests in top bar --- src/app/(site)/me/page.tsx | 93 +++++++++++++++++- src/app/(site)/search/page.tsx | 173 +++++++++++++++++++++++++++++++++ src/app/layout.tsx | 13 +++ src/components/navigation.tsx | 84 +++++++++++++--- src/components/top-header.tsx | 140 +++++++++++++++++++++++++- 5 files changed, 484 insertions(+), 19 deletions(-) create mode 100644 src/app/(site)/search/page.tsx diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 7863254e..7aea5294 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -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({ + {/* ── Hotel alert banner ── */} + {alertRaw ? ( + +
+

+ {alertRaw} +

+
+
+ ) : null} + {/* ── Enter hotel ── */} (
+ {/* ── Recent rooms ── */} + {recentRooms.length > 0 && ( + +
+
+
+

+ Recent rooms +

+

+ Newest rooms in the hotel +

+
+ + View all + +
+
+ {recentRooms.map((room) => ( + +

+ {room.name} +

+

+ {room.ownerName} · {room.users}/{room.usersMax} +

+ + ))} +
+
+
+ )} ); } catch { diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx new file mode 100644 index 00000000..3d0aba39 --- /dev/null +++ b/src/app/(site)/search/page.tsx @@ -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 ( +
+

+ Enter a search term to find users. +

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

+ Results for "{query}" — {users.length} user{users.length !== 1 ? "s" : ""} + {rooms.length > 0 && `, ${rooms.length} room${rooms.length !== 1 ? "s" : ""}`} +

+ + {users.length > 0 && ( +
+

+ Users +

+
+ {users.map((u) => ( + + {u.username} + + {u.username} + + + {u.online === "1" ? "🟢 Online" : "Offline"} + + + ))} +
+
+ )} + + {rooms.length > 0 && ( +
+

+ Rooms +

+
+ {rooms.map((room) => ( + +

+ {room.name} +

+

+ {room.ownerName} · {room.users}/{room.usersMax} +

+ + ))} +
+
+ )} + + {users.length === 0 && rooms.length === 0 && ( +
+

+ No users or rooms found for "{query}". +

+
+ )} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dbccfa81..3f3bb5bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ + diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 9860f124..75d1d896 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -9,6 +9,7 @@ import { NavbarColorPicker } from "@/components/navbar-color-picker"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { resolveHotelName } from "@/lib/hotel-name"; import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; export async function Navigation({ session }: { session: Session | null }) { const t = await getTranslations("nav"); @@ -24,11 +25,7 @@ export async function Navigation({ session }: { session: Session | null }) { ctx.session.user.rank, ); showMod = - canAccess( - ctx.permissions, - PERMS.MOD_DASHBOARD, - ctx.session.user.rank, - ) || + canAccess(ctx.permissions, PERMS.MOD_DASHBOARD, ctx.session.user.rank) || canAccess( ctx.permissions, PERMS.MODERATION_VIEW, @@ -39,6 +36,19 @@ export async function Navigation({ session }: { session: Session | null }) { } } + let unreadMessages = 0; + let pendingFriendRequests = 0; + if (session?.user?.id) { + const id = Number(session.user.id); + try { + [unreadMessages, pendingFriendRequests] = await Promise.all([ + prisma.messengerOffline.count({ where: { userId: id } }), + prisma.messengerFriendrequests.count({ where: { userToId: id } }), + ]); + } catch { + } + } + return ( ); diff --git a/src/components/top-header.tsx b/src/components/top-header.tsx index 8b7d45c8..8e2531b8 100644 --- a/src/components/top-header.tsx +++ b/src/components/top-header.tsx @@ -65,7 +65,6 @@ export async function TopHeader({ session }: { session: Session | null }) { if (c.type === 5) diamonds = c.amount; } } catch { - // render without live balances } const imager = @@ -108,6 +107,34 @@ export async function TopHeader({ session }: { session: Session | null }) { online = 0; } + let unreadMessages = 0; + let pendingFriendRequests = 0; + try { + [unreadMessages, pendingFriendRequests] = await Promise.all([ + prisma.messengerOffline.count({ where: { userId: id } }), + prisma.messengerFriendrequests.count({ where: { userToId: id } }), + ]); + } catch { + } + + const friendRequests = await prisma.messengerFriendrequests + .findMany({ + where: { userToId: id }, + select: { userFromId: true }, + }) + .catch(() => []); + + const friendRequestUsers = friendRequests.length + ? await prisma.user + .findMany({ + where: { id: { in: friendRequests.map((r) => r.userFromId) } }, + select: { id: true, username: true, look: true }, + }) + .catch(() => []) + : []; + + const totalNotifications = unreadMessages + pendingFriendRequests; + return (
@@ -126,6 +153,7 @@ export async function TopHeader({ session }: { session: Session | null }) { {t("online", { count: online, hotel: hotelName })}
+ Enter + +
{ + const input = (e.target as HTMLFormElement).querySelector("input"); + if (input?.value) { + e.currentTarget.action = `/room/${input.value}`; + } + }} + > + + +
+
+ + + + + + {totalNotifications > 0 && ( + + {totalNotifications > 99 ? "99+" : totalNotifications} + + )} + + {showAdmin || showMod ? (
Login activity + + {pendingFriendRequests > 0 && ( + <> +
+
+

+ Friend requests ({pendingFriendRequests}) +

+ {friendRequestUsers.slice(0, 3).map((u) => ( + + + {u.username} + + ))} + {friendRequestUsers.length > 3 && ( +

+ +{friendRequestUsers.length - 3} more +

+ )} +
+ + )} +