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
@@ -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 (
|
||||
<nav
|
||||
className="sticky top-0 z-40"
|
||||
@@ -156,7 +166,7 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
|
||||
{session?.user ? (
|
||||
<>
|
||||
<Link href="/friends" className="nav-item">
|
||||
<Link href="/friends" className="nav-item relative">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/goody.png"
|
||||
alt=""
|
||||
@@ -164,9 +174,9 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
height={20}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{t("friends")}
|
||||
<span>{t("friends")}</span>
|
||||
</Link>
|
||||
<Link href="/messages" className="nav-item">
|
||||
<Link href="/messages" className="nav-item relative">
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/bb.png"
|
||||
alt=""
|
||||
@@ -174,7 +184,20 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
height={20}
|
||||
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>
|
||||
</>
|
||||
) : null}
|
||||
@@ -206,6 +229,42 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
) : null}
|
||||
</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) */}
|
||||
<MobileNav
|
||||
menuLabel={t("openMenu") || "Open menu"}
|
||||
@@ -397,13 +456,6 @@ export async function Navigation({ session }: { session: Session | null }) {
|
||||
Atom
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop: controls */}
|
||||
<div className="ml-auto hidden md:flex items-center gap-x-1">
|
||||
<LanguageSwitcher />
|
||||
<NavbarColorPicker />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -126,6 +153,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
{t("online", { count: online, hotel: hotelName })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
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"
|
||||
@@ -145,6 +173,41 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
/>
|
||||
Enter
|
||||
</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-ducket-icon" amount={duckets} label="Duckets" />
|
||||
<Currency
|
||||
@@ -155,6 +218,38 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<details className="relative">
|
||||
<summary
|
||||
@@ -256,6 +351,49 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
>
|
||||
Login activity
|
||||
</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
|
||||
className="h-px my-1 mx-3"
|
||||
style={{
|
||||
|
||||
Reference in new issue
Block a user