feat: add Toaster, notification bell, search, unread badges, last login, recent rooms, hotel alerts, quick room entry, friend requests in top bar
This commit is contained in:
1 parent
a906d671fb
commit
3284e5a915
5 files changed
+484
-19
No files matched your search
@@ -69,7 +69,7 @@ export default async function MePage({
|
|||||||
|
|
||||||
let content: ReactNode;
|
let content: ReactNode;
|
||||||
try {
|
try {
|
||||||
const [user, imagerBase, hotelName, neededRaw, amountRaw, currencyRaw] =
|
const [user, imagerBase, hotelName, neededRaw, amountRaw, currencyRaw, alertRaw, recentRooms] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -81,6 +81,8 @@ export default async function MePage({
|
|||||||
rank: true,
|
rank: true,
|
||||||
credits: true,
|
credits: true,
|
||||||
accountCreated: true,
|
accountCreated: true,
|
||||||
|
lastLogin: true,
|
||||||
|
lastOnline: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
siteSettings.get(
|
siteSettings.get(
|
||||||
@@ -113,6 +115,14 @@ export default async function MePage({
|
|||||||
select: { value: true },
|
select: { value: true },
|
||||||
})
|
})
|
||||||
.catch(() => null),
|
.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");
|
if (!user) throw new Error("user-not-found");
|
||||||
@@ -169,6 +179,12 @@ export default async function MePage({
|
|||||||
const registered = new Date(user.accountCreated * 1000)
|
const registered = new Date(user.accountCreated * 1000)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(0, 10);
|
.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 referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
|
||||||
|
|
||||||
const imager = imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
const imager = imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||||
@@ -256,6 +272,23 @@ export default async function MePage({
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</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 ── */}
|
{/* ── Enter hotel ── */}
|
||||||
<Reveal delay={0.05}>
|
<Reveal delay={0.05}>
|
||||||
<Link
|
<Link
|
||||||
@@ -285,7 +318,7 @@ export default async function MePage({
|
|||||||
{[
|
{[
|
||||||
{ value: user.credits.toLocaleString(), label: "Credits" },
|
{ value: user.credits.toLocaleString(), label: "Credits" },
|
||||||
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
|
{ value: `Rank ${user.rank}`, label: "Hotel rank" },
|
||||||
{ value: registered, label: "Registered" },
|
{ value: lastLoginDate, label: "Last login" },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
@@ -489,6 +522,62 @@ export default async function MePage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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 "{query}" — {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 "{query}".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Script from "next/script";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale, getMessages } from "next-intl/server";
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import { ViewTransitions } from "next-view-transitions";
|
import { ViewTransitions } from "next-view-transitions";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
import { ThemeVars } from "@/components/theme-vars";
|
import { ThemeVars } from "@/components/theme-vars";
|
||||||
@@ -94,6 +95,18 @@ export default async function RootLayout({
|
|||||||
</ViewTransitions>
|
</ViewTransitions>
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
<SmoothScroll />
|
<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>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
|||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function Navigation({ session }: { session: Session | null }) {
|
export async function Navigation({ session }: { session: Session | null }) {
|
||||||
const t = await getTranslations("nav");
|
const t = await getTranslations("nav");
|
||||||
@@ -24,11 +25,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
ctx.session.user.rank,
|
ctx.session.user.rank,
|
||||||
);
|
);
|
||||||
showMod =
|
showMod =
|
||||||
canAccess(
|
canAccess(ctx.permissions, PERMS.MOD_DASHBOARD, ctx.session.user.rank) ||
|
||||||
ctx.permissions,
|
|
||||||
PERMS.MOD_DASHBOARD,
|
|
||||||
ctx.session.user.rank,
|
|
||||||
) ||
|
|
||||||
canAccess(
|
canAccess(
|
||||||
ctx.permissions,
|
ctx.permissions,
|
||||||
PERMS.MODERATION_VIEW,
|
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 (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="sticky top-0 z-40"
|
className="sticky top-0 z-40"
|
||||||
@@ -156,7 +166,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
|
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/friends" className="nav-item">
|
<Link href="/friends" className="nav-item relative">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/images/icons/navigation/goody.png"
|
src="/assets/images/icons/navigation/goody.png"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -164,9 +174,9 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
height={20}
|
height={20}
|
||||||
className="mr-1.5"
|
className="mr-1.5"
|
||||||
/>
|
/>
|
||||||
{t("friends")}
|
<span>{t("friends")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/messages" className="nav-item">
|
<Link href="/messages" className="nav-item relative">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/images/icons/navigation/bb.png"
|
src="/assets/images/icons/navigation/bb.png"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -174,7 +184,20 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
height={20}
|
height={20}
|
||||||
className="mr-1.5"
|
className="mr-1.5"
|
||||||
/>
|
/>
|
||||||
{t("messages")}
|
<span>{t("messages")}</span>
|
||||||
|
{(unreadMessages + pendingFriendRequests) > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1.5 -right-1.5 flex min-w-[18px] h-[18px] items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-danger)",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{unreadMessages + pendingFriendRequests > 9
|
||||||
|
? "9+"
|
||||||
|
: unreadMessages + pendingFriendRequests}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -206,6 +229,42 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: search + controls */}
|
||||||
|
<div className="ml-auto hidden md:flex items-center gap-x-2">
|
||||||
|
{session?.user ? (
|
||||||
|
<form
|
||||||
|
action="/search"
|
||||||
|
method="GET"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
placeholder="Search users..."
|
||||||
|
className="w-32 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-all focus:w-40"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-background)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg px-2 py-1.5 text-xs font-bold transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<NavbarColorPicker />
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile: hamburger menu (details/summary) */}
|
{/* Mobile: hamburger menu (details/summary) */}
|
||||||
<MobileNav
|
<MobileNav
|
||||||
menuLabel={t("openMenu") || "Open menu"}
|
menuLabel={t("openMenu") || "Open menu"}
|
||||||
@@ -397,13 +456,6 @@ export async function Navigation({ session }: { session: Session | null }) {
|
|||||||
Atom
|
Atom
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop: controls */}
|
|
||||||
<div className="ml-auto hidden md:flex items-center gap-x-1">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<NavbarColorPicker />
|
|
||||||
<ThemeSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
if (c.type === 5) diamonds = c.amount;
|
if (c.type === 5) diamonds = c.amount;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// render without live balances
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const imager =
|
const imager =
|
||||||
@@ -108,6 +107,34 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
online = 0;
|
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 (
|
return (
|
||||||
<div style={{ backgroundColor: "var(--color-navbar)" }}>
|
<div style={{ backgroundColor: "var(--color-navbar)" }}>
|
||||||
<div className="max-w-7xl min-h-[44px] sm:min-h-[60px] px-2 sm:px-4 flex items-center justify-between mx-auto">
|
<div className="max-w-7xl min-h-[44px] sm:min-h-[60px] px-2 sm:px-4 flex items-center justify-between mx-auto">
|
||||||
@@ -126,6 +153,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
{t("online", { count: online, hotel: hotelName })}
|
{t("online", { count: online, hotel: hotelName })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/client"
|
href="/client"
|
||||||
className="flex items-center gap-1.5 rounded-xl border py-1.5 px-3 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
className="flex items-center gap-1.5 rounded-xl border py-1.5 px-3 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
|
||||||
@@ -145,6 +173,41 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
/>
|
/>
|
||||||
Enter
|
Enter
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={`/client`}
|
||||||
|
method="GET"
|
||||||
|
className="hidden sm:flex items-center gap-1"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
const input = (e.target as HTMLFormElement).querySelector("input");
|
||||||
|
if (input?.value) {
|
||||||
|
e.currentTarget.action = `/room/${input.value}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="roomId"
|
||||||
|
placeholder="Room ID"
|
||||||
|
className="w-20 rounded-lg border px-2 py-1 text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-background)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg px-2 py-1 text-xs font-bold transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
||||||
|
color: "var(--color-primary-readable, var(--color-primary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<Currency icon="nav-credit-icon" amount={credits} label="Credits" />
|
<Currency icon="nav-credit-icon" amount={credits} label="Credits" />
|
||||||
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
|
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
|
||||||
<Currency
|
<Currency
|
||||||
@@ -155,6 +218,38 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-1 sm:gap-x-3">
|
<div className="flex items-center gap-x-1 sm:gap-x-3">
|
||||||
|
<Link
|
||||||
|
href="/messages"
|
||||||
|
className="relative flex items-center gap-1 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ color: textColor }}
|
||||||
|
>
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</svg>
|
||||||
|
{totalNotifications > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1 -right-1 flex min-w-[16px] h-4 items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-danger)",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totalNotifications > 99 ? "99+" : totalNotifications}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
{showAdmin || showMod ? (
|
{showAdmin || showMod ? (
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary
|
<summary
|
||||||
@@ -256,6 +351,49 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
>
|
>
|
||||||
Login activity
|
Login activity
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{pendingFriendRequests > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="h-px my-1 mx-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<p className="text-xs font-bold mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Friend requests ({pendingFriendRequests})
|
||||||
|
</p>
|
||||||
|
{friendRequestUsers.slice(0, 3).map((u) => (
|
||||||
|
<Link
|
||||||
|
key={u.id}
|
||||||
|
href="/messages"
|
||||||
|
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={avatarImageUrl(imager, u.look, {
|
||||||
|
headOnly: true,
|
||||||
|
direction: 2,
|
||||||
|
size: "s",
|
||||||
|
})}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={24}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<span style={{ color: textColor }}>{u.username}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{friendRequestUsers.length > 3 && (
|
||||||
|
<p className="text-xs mt-1 opacity-60" style={{ color: textColor }}>
|
||||||
|
+{friendRequestUsers.length - 3} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="h-px my-1 mx-3"
|
className="h-px my-1 mx-3"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Reference in new issue
Block a user