From d6ccc250f66bc181605d2dd3a6e9a1428041a6a1 Mon Sep 17 00:00:00 2001 From: remco Date: Wed, 1 Jul 2026 15:59:02 +0200 Subject: [PATCH] feat: mobile nav, radio layout/requests, leaderboard tabs, client i18n, habbo background, navbar fixes --- prisma/schema.prisma | 14 +- src/actions/radio-requests.ts | 36 +++++ src/app/client/page.tsx | 110 ++++++++++----- src/app/globals.css | 88 +++++++++++- src/app/leaderboard/page.tsx | 72 ++++++++-- src/app/radio/layout.tsx | 82 +++++++++++ src/app/radio/requests/page.tsx | 143 +++++++++++++++++++ src/components/language-switcher.tsx | 15 +- src/components/mobile-nav.tsx | 56 ++++++++ src/components/navbar-color-picker.tsx | 109 +++++++++++++++ src/components/navigation.tsx | 184 +++++++++++++++---------- src/components/public/ui.tsx | 57 ++++++++ src/components/theme-switcher.tsx | 2 +- src/components/top-header.tsx | 18 ++- src/messages/de.json | 3 + src/messages/en.json | 39 ++++++ src/messages/es.json | 3 + src/messages/fr.json | 3 + src/messages/it.json | 3 + src/messages/nl.json | 39 ++++++ 20 files changed, 936 insertions(+), 140 deletions(-) create mode 100644 src/actions/radio-requests.ts create mode 100644 src/app/radio/layout.tsx create mode 100644 src/app/radio/requests/page.tsx create mode 100644 src/components/mobile-nav.tsx create mode 100644 src/components/navbar-color-picker.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b3952ce2..83f4536b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2139,9 +2139,17 @@ model RadioListenerPoints { } model RadioSongRequests { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - createdAt DateTime? @map("created_at") @db.Timestamp(0) - updatedAt DateTime? @map("updated_at") @db.Timestamp(0) + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt? @map("user_id") @db.UnsignedBigInt + songTitle String? @map("song_title") @db.VarChar(255) + artist String? @map("artist") @db.VarChar(255) + isApproved Boolean @default(false) @map("is_approved") + isPlayed Boolean @default(false) @map("is_played") + votes Int @default(0) + submittedAt DateTime? @map("submitted_at") @db.Timestamp(0) + playedAt DateTime? @map("played_at") @db.Timestamp(0) + createdAt DateTime? @map("created_at") @db.Timestamp(0) + updatedAt DateTime? @map("updated_at") @db.Timestamp(0) @@map("radio_song_requests") } diff --git a/src/actions/radio-requests.ts b/src/actions/radio-requests.ts new file mode 100644 index 00000000..ceb9c246 --- /dev/null +++ b/src/actions/radio-requests.ts @@ -0,0 +1,36 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +const SONG_MAX = 255; +const ARTIST_MAX = 255; + +export async function submitRequest(formData: FormData): Promise { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isInteger(userId) || userId <= 0) return; + + const songTitle = String(formData.get("songTitle") ?? "").trim().slice(0, SONG_MAX); + const artist = String(formData.get("artist") ?? "").trim().slice(0, ARTIST_MAX); + if (!songTitle && !artist) return; + + const now = new Date(); + try { + await prisma.radioSongRequests.create({ + data: { + userId: BigInt(userId), + songTitle: songTitle || null, + artist: artist || null, + submittedAt: now, + createdAt: now, + updatedAt: now, + }, + }); + } catch { + return; + } + + revalidatePath("/radio/requests"); +} diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx index 8dfd4ccf..f454b1a7 100644 --- a/src/app/client/page.tsx +++ b/src/app/client/page.tsx @@ -1,81 +1,113 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { ContentCard, EmptyState } from "@/components/public/ui"; +import { useTranslations } from "next-intl"; +import { ContentCard, EmptyState, SkeletonCard } from "@/components/public/ui"; export default function ClientPage() { + const t = useTranslations("pages.client"); const [ticket, setTicket] = useState(null); + const [clientUrl, setClientUrl] = useState("/nitro-client/"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hotelName, setHotelName] = useState("Hubbly"); + const [errorType, setErrorType] = useState<"auth" | "generic" | null>(null); + const [hotelName, setHotelName] = useState(""); const [launched, setLaunched] = useState(false); + const fetchedRef = useRef(false); useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; + async function fetchTicket() { try { const res = await fetch("/api/client/sso"); - if (!res.ok) throw new Error("Failed to get SSO ticket"); + if (res.status === 401) { + setErrorType("auth"); + setError(t("errors.unauthorized")); + return; + } + if (!res.ok) throw new Error("SSO fetch failed"); const data = await res.json(); setTicket(data.ticket); setHotelName(data.hotelName); + if (data.clientUrl) setClientUrl(data.clientUrl); } catch (e) { - setError("Could not generate login ticket"); + setErrorType("generic"); + setError(t("errors.generic")); } finally { setLoading(false); } } fetchTicket(); - }, []); + }, [t]); + + const openClient = useCallback((tk: string) => { + const sep = clientUrl.includes("?") ? "&" : "?"; + const fullUrl = `${clientUrl}${sep}sso=${encodeURIComponent(tk)}`; + window.open(fullUrl, "_blank", "noopener,noreferrer"); + }, [clientUrl]); useEffect(() => { if (ticket && !launched) { setLaunched(true); - const clientUrl = `/nitro-client/?sso=${encodeURIComponent(ticket)}`; - const fullUrl = `${window.location.origin}${clientUrl}`; - window.open(fullUrl, "_blank", "noopener,noreferrer"); + openClient(ticket); } - }, [ticket, launched]); + }, [ticket, launched, openClient]); + // ── Loading ── if (loading) { return ( -
-
-
- {hotelName} - Loading client… -
-
-
- -
+
+
); } - if (error || !ticket) { + // ── Error (auth) ── + if (errorType === "auth") { return (
-
- {hotelName} -
+ {hotelName || "Atom"}
- - {error ?? "Could not generate SSO ticket"} + +
+ + {t("errors.loginRedirect")} + +
); } + // ── Error (generic) ── + if (errorType === "generic" || !ticket) { + return ( +
+
+ {hotelName || "Atom"} +
+
+ + {error ?? t("errors.generic")} + +
+
+ ); + } + + // ── Success (client launched) ── return (
{hotelName} - Connected + + {t("connected")} +

- Welcome to {hotelName} + {t("welcome", { hotel: hotelName })}

- Opening the client in a new tab… If it doesn't open,{" "} - - click here + {t("openingClient")} {t("fallbackLink")}{" "} + + {t("fallbackClick")} .

@@ -108,4 +146,4 @@ export default function ClientPage() {
); -} \ No newline at end of file +} diff --git a/src/app/globals.css b/src/app/globals.css index 43b88200..63343b6f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -110,16 +110,36 @@ body { /* ── Atom theme components ────────────────────────────────── */ @layer components { .nav-item { - @apply flex h-auto md:h-[60px] items-center text-[14px] font-semibold uppercase transition duration-200 ease-in-out md:border-b-4! md:border-transparent! md:hover:border-b-[#eeb425]!; - color: var(--color-navbar-text, #1e293b); + @apply flex h-auto md:h-[54px] items-center text-[13px] font-bold transition-all duration-150 ease-in-out px-3 md:px-4 relative; + color: var(--color-navbar-text, #64748b); + letter-spacing: 0.03em; + text-transform: uppercase; + } + .nav-item::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%) scaleX(0); + width: 70%; + height: 3px; + border-radius: 3px 3px 0 0; + background-color: var(--color-primary, #eeb425); + transition: transform 0.2s ease-in-out; + } + .nav-item:hover::after { + transform: translateX(-50%) scaleX(1); + } + .nav-item:hover { + color: var(--color-primary, #eeb425); } .dropdown-item { - @apply block py-2 px-4 font-semibold transition duration-200; + @apply block py-2 px-4 text-sm font-semibold transition-all duration-100 rounded-lg mx-1; color: var(--color-text); } .dropdown-item:hover { - background-color: var(--color-primary, #eeb425); - color: var(--button-text-color, #1a1a2e); + background-color: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary, #eeb425); } } @@ -164,10 +184,14 @@ body { } .site-bg { - background: url("/assets/images/background-light.jpg") no-repeat fixed right bottom; + background: + linear-gradient(to bottom, rgba(255,255,255,0.88), rgba(248,250,252,0.95)), + url("/assets/images/background-light.jpg") no-repeat fixed center / cover; } .app.dark .site-bg { - background: url("/assets/images/background-dark.jpg") no-repeat fixed right bottom; + background: + linear-gradient(to bottom, rgba(15,17,23,0.92), rgba(15,17,23,0.97)), + url("/assets/images/background-dark.jpg") no-repeat fixed center / cover; } .article-body { @@ -858,3 +882,53 @@ td { background: color-mix(in srgb, #f97316 20%, transparent); border-color: #f97316; } + +/* ── Radio sidebar ──────────────────────────────────────────── */ +.radio-layout { + display: flex; + gap: 1.5rem; + align-items: flex-start; +} +.radio-sidebar { + flex: none; + width: 200px; + position: sticky; + top: 5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.radio-sidebar-link:hover { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + text-decoration: none; +} +@media (max-width: 48rem) { + .radio-layout { + flex-direction: column; + } + .radio-sidebar { + position: static; + width: 100%; + flex-direction: row; + flex-wrap: wrap; + gap: 0.25rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent); + overflow-x: auto; + } + .radio-sidebar-head { + display: none; + } + .radio-sidebar-link { + font-size: 0.82rem; + padding: 0.35rem 0.6rem; + white-space: nowrap; + } +} + +/* ── Skeleton shimmer animation ─────────────────────────────── */ +@keyframes shimmer { + 0% { opacity: 0.6; } + 50% { opacity: 1; } + 100% { opacity: 0.6; } +} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index e9da06b0..3d3f687b 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -9,21 +9,34 @@ export const dynamic = "force-dynamic"; export const metadata = { title: "Leaderboard" }; -// AtomCMS-faithful currency type ids (see prisma/schema.prisma UsersCurrency): +// Currency type ids (see prisma/schema.prisma UsersCurrency): // Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5. const TABS = [ { key: "credits", label: "Credits", coin: "credits" }, { key: "diamonds", label: "Diamonds", coin: "diamonds" }, { key: "duckets", label: "Duckets", coin: "duckets" }, + { key: "achievement", label: "Achievement", coin: "" }, + { key: "respects", label: "Respects", coin: "" }, + { key: "onlinetime", label: "Online Time", coin: "" }, ] as const; type TabKey = (typeof TABS)[number]["key"]; -const CURRENCY_TYPE: Record, number> = { +const CURRENCY_TYPE: Record<"diamonds" | "duckets", number> = { diamonds: 5, duckets: 0, }; +function formatValue(key: TabKey, value: number): string { + if (key === "onlinetime") { + const hours = Math.floor(value / 3600); + const days = Math.floor(hours / 24); + if (days > 0) return `${days}d ${hours % 24}h`; + return `${hours}h`; + } + return value.toLocaleString(); +} + type Row = { username: string; look: string; value: number }; async function loadCreditsRows(): Promise { @@ -67,6 +80,41 @@ async function loadCurrencyRows(type: number): Promise { } } +async function loadSettingsRows( + field: "achievementScore" | "respectsReceived" | "onlineTime" | "hofPoints", +): Promise { + try { + const top = await prisma.usersSettings.findMany({ + orderBy: { [field]: "desc" }, + take: 20, + select: { userId: true, [field]: true }, + }); + if (top.length === 0) return []; + + const users = await prisma.user.findMany({ + where: { id: { in: top.map((t) => t.userId) } }, + select: { id: true, username: true, look: true }, + }); + const byId = new Map(users.map((u) => [u.id, u])); + + return (top as unknown as Array<{ userId: number; [k: string]: unknown }>) + .map((t) => { + const u = byId.get(t.userId as number); + if (!u) return null; + return { username: u.username, look: u.look, value: Number(t[field] ?? 0) }; + }) + .filter((r): r is Row => r !== null); + } catch { + return []; + } +} + +const SETTINGS_FIELD: Record<"achievement" | "respects" | "onlinetime", "achievementScore" | "respectsReceived" | "onlineTime"> = { + achievement: "achievementScore", + respects: "respectsReceived", + onlinetime: "onlineTime", +}; + export default async function LeaderboardPage({ searchParams, }: { @@ -80,10 +128,14 @@ export default async function LeaderboardPage({ const [rows, imagerBase] = await Promise.all([ active === "credits" ? loadCreditsRows() - : loadCurrencyRows(CURRENCY_TYPE[active as Exclude]), + : active === "diamonds" || active === "duckets" + ? loadCurrencyRows(CURRENCY_TYPE[active]) + : loadSettingsRows(SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD]), siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"), ]); + const valueOnly = active === "achievement" || active === "respects" || active === "onlinetime"; + return (
-
+
{TABS.map((t2) => ( - - - {row.value.toLocaleString()} - + {valueOnly ? ( + {formatValue(active, row.value)} + ) : ( + + + {row.value.toLocaleString()} + + )} ); diff --git a/src/app/radio/layout.tsx b/src/app/radio/layout.tsx new file mode 100644 index 00000000..c1bebff5 --- /dev/null +++ b/src/app/radio/layout.tsx @@ -0,0 +1,82 @@ +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import type { ReactNode } from "react"; + +const RADIO_SIDEBAR_LINKS = [ + { key: "schedule", href: "/radio/schedule", icon: "🗓️" }, + { key: "shouts", href: "/radio/shouts", icon: "📣" }, + { key: "requests", href: "/radio/requests", icon: "🎵" }, + { key: "apply", href: "/radio/apply", icon: "🎤" }, + { key: "leaderboard", href: "/radio/leaderboard", icon: "🏆" }, +] as const; + +const SIDEBAR_KEYS = RADIO_SIDEBAR_LINKS.map((l) => l.key); + +export default async function RadioLayout({ children }: { children: ReactNode }) { + const t = await getTranslations("pages.radio"); + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/src/app/radio/requests/page.tsx b/src/app/radio/requests/page.tsx new file mode 100644 index 00000000..cf7c97b5 --- /dev/null +++ b/src/app/radio/requests/page.tsx @@ -0,0 +1,143 @@ +import { getTranslations } from "next-intl/server"; +import { ContentCard, EmptyState } from "@/components/public/ui"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { submitRequest } from "@/actions/radio-requests"; + +export const dynamic = "force-dynamic"; + +function formatDate(d: Date | null | undefined): string { + return d ? d.toISOString().slice(0, 16).replace("T", " ") : ""; +} + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const; + +function formatDay(d: Date): string { + return DAYS[d.getUTCDay()] ?? ""; +} + +export default async function RadioRequestsPage() { + const t = await getTranslations("pages.radioRequests"); + const genericT = await getTranslations("pages.radio"); + + const [requests, session] = await Promise.all([ + prisma.radioSongRequests + .findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + }) + .catch(() => []), + auth(), + ]); + + const isLoggedIn = Boolean(session?.user?.id); + + const userIds = Array.from(new Set(requests.map((r) => Number(r.userId)))); + const users = userIds.length + ? await prisma.user + .findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true }, + }) + .catch(() => []) + : []; + const userById = new Map(users.map((u) => [u.id, u.username])); + + return ( + <> + + + {isLoggedIn ? ( + +
+
+
+ + +
+
+ + +
+
+ +
+
+ ) : ( + + {t("loginToRequest")} + + )} + + + {requests.length === 0 ? ( + {t("empty")} + ) : ( + + + + + + + + + + + + {requests.map((r) => { + const requester = userById.get(Number(r.userId)); + const isPlayed = r.isPlayed; + const isApproved = r.isApproved; + let status = t("statusPending"); + if (isPlayed) status = t("statusPlayed"); + else if (isApproved) status = t("statusApproved"); + + return ( + + + + + + + + ); + })} + +
{t("colSong")}{t("colArtist")}{t("colRequester")}{t("colDate")}{t("colStatus")}
{r.songTitle || —}{r.artist || —} + {requester ? ( + {requester} + ) : ( + #{String(r.userId)} + )} + {r.submittedAt ? formatDate(r.submittedAt) : "—"}{status}
+ )} +
+ + ); +} diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 7d7d9486..7c359451 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -58,15 +58,15 @@ export function LanguageSwitcher() { {open && (
{LOCALES.map((l) => ( + + {open && ( +
setOpen(false)} + style={{ backgroundColor: "rgba(0,0,0,0.4)" }} + /> + )} + +
+
+ {children} +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/navbar-color-picker.tsx b/src/components/navbar-color-picker.tsx new file mode 100644 index 00000000..42b99f8e --- /dev/null +++ b/src/components/navbar-color-picker.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const COLORS = [ + { name: "Wit", value: "#ffffff", text: "#1e293b" }, + { name: "Zwart", value: "#0f172a", text: "#f8fafc" }, + { name: "Donker", value: "#1e293b", text: "#f8fafc" }, + { name: "Grijs", value: "#f1f5f9", text: "#1e293b" }, + { name: "Amber", value: "#f59e0b", text: "#1e293b" }, + { name: "Goud", value: "#eab308", text: "#1e293b" }, + { name: "Oranje", value: "#ea580c", text: "#ffffff" }, + { name: "Rood", value: "#ef4444", text: "#ffffff" }, + { name: "Roze", value: "#ec4899", text: "#ffffff" }, + { name: "Paars", value: "#8b5cf6", text: "#ffffff" }, + { name: "Indigo", value: "#6366f1", text: "#ffffff" }, + { name: "Blauw", value: "#3b82f6", text: "#ffffff" }, + { name: "Cyaan", value: "#06b6d4", text: "#ffffff" }, + { name: "Groen", value: "#22c55e", text: "#1e293b" }, + { name: "Olijf", value: "#84cc16", text: "#1e293b" }, +]; + +export function NavbarColorPicker() { + const [open, setOpen] = useState(false); + const [active, setActive] = useState(() => { + if (typeof document === "undefined") return 0; + const current = getComputedStyle(document.documentElement).getPropertyValue("--color-navbar").trim(); + const idx = COLORS.findIndex((c) => c.value === current); + return idx >= 0 ? idx : 0; + }); + + function apply(index: number) { + const c = COLORS[index]; + if (!c) return; + document.documentElement.style.setProperty("--color-navbar", c.value); + document.documentElement.style.setProperty("--color-navbar-text", c.text); + try { + localStorage.setItem("navbarColor", c.value); + localStorage.setItem("navbarTextColor", c.text); + } catch { } + setActive(index); + setOpen(false); + } + + useEffect(() => { + const saved = localStorage.getItem("navbarColor"); + const savedText = localStorage.getItem("navbarTextColor"); + if (saved && savedText) { + document.documentElement.style.setProperty("--color-navbar", saved); + document.documentElement.style.setProperty("--color-navbar-text", savedText); + const idx = COLORS.findIndex((c) => c.value === saved); + if (idx >= 0) setActive(idx); + } + }, []); + + return ( +
+ + + {open && ( +
+
Navbar kleur
+
+ {COLORS.map((c, i) => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 5cd1566b..8d2649d8 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -2,6 +2,8 @@ import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { auth } from "@/lib/auth"; import { LanguageSwitcher } from "@/components/language-switcher"; +import { MobileNav } from "@/components/mobile-nav"; +import { NavbarColorPicker } from "@/components/navbar-color-picker"; import { ThemeSwitcher } from "@/components/theme-switcher"; export async function Navigation() { @@ -10,85 +12,117 @@ export async function Navigation() { const isStaff = (session?.user?.rank ?? 0) >= 7; return ( -