latest changes
This commit is contained in:
1 parent
56cd6fd058
commit
1de72863db
40 files changed
+3837
-568
No files matched your search
@@ -12,12 +12,10 @@ export default async function EditArticle({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
|
||||
try {
|
||||
article = await prisma.websiteArticles.findUnique({ where: { id: BigInt(id) } });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true, createdAt: true, updatedAt: true },
|
||||
}).catch(() => null);
|
||||
if (!article) notFound();
|
||||
|
||||
return (
|
||||
@@ -36,7 +34,7 @@ export default async function EditArticle({
|
||||
</form>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(article.id)} />
|
||||
<button type="submit">Delete article</button>
|
||||
<button type="submit" className="btn btn-primary">Delete article</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { deleteArticle } from "@/actions/admin-articles";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminArticles() {
|
||||
// .catch(() => []) keeps the precise selected-row type while falling back to
|
||||
// an empty list when the DB is unavailable (instead of a 500).
|
||||
const articles = await prisma.websiteArticles
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
@@ -47,13 +46,16 @@ export default async function AdminArticles() {
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td>
|
||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
||||
<Link href={`/admin/articles/${a.id}`} style={{ color: "var(--link-color)" }}>
|
||||
{a.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code>{a.slug}</code>
|
||||
@@ -61,6 +63,23 @@ export default async function AdminArticles() {
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary" style={{ fontSize: "0.8rem", padding: "0.3rem 0.6rem" }}>
|
||||
Edit
|
||||
</Link>
|
||||
<form action={deleteArticle} style={{ margin: 0 }}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: "0.8rem", padding: "0.3rem 0.6rem" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -58,20 +58,20 @@ export default async function AdminDashboard() {
|
||||
value={online}
|
||||
hint={dbOk ? `${users} accounts` : "DB unavailable"}
|
||||
state={dbOk ? "neutral" : "danger"}
|
||||
icon="\u{1F464}"
|
||||
icon="👤"
|
||||
/>
|
||||
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="\u{1F4F0}" />
|
||||
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="📰" />
|
||||
<StatusCard
|
||||
label="Active bans"
|
||||
value={dbOk ? bans : "—"}
|
||||
state={bans > 0 ? "warn" : "neutral"}
|
||||
icon="\u{1F528}"
|
||||
icon="🔧"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Database"
|
||||
value={dbOk ? "Connected" : "Down"}
|
||||
state={dbOk ? "ok" : "danger"}
|
||||
icon="\u{1F5C4}️"
|
||||
icon="🗄️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const [hotelName, clientUrl] = await Promise.all([
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
siteSettings.get("nitro_client_url", ""),
|
||||
]);
|
||||
|
||||
const hdrs = await headers();
|
||||
const ip =
|
||||
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
|
||||
|
||||
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
|
||||
|
||||
return Response.json({ ticket, hotelName: hotelName ?? "Atom", clientUrl });
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||
import { buildNitroClientUrl } from "@/lib/services/clients";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function FlashClientPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
// The Flash client base URL may be stored under either settings key.
|
||||
const [hotelNameRaw, flashUrl, flashUrlAlt] = await Promise.all([
|
||||
siteSettings.get("hotel_name", "Atom").catch(() => "Atom"),
|
||||
siteSettings.get("flash_client_url", "").catch(() => ""),
|
||||
siteSettings.get("client_flash_url", "").catch(() => ""),
|
||||
]);
|
||||
const hotelName = hotelNameRaw ?? "Atom";
|
||||
|
||||
const hdrs = await headers();
|
||||
const ip =
|
||||
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
|
||||
|
||||
// Faithful to AtomCMS's client controllers: issue (and persist) the SSO ticket
|
||||
// on the launcher render, then hand it to the Flash client.
|
||||
const ticket = await issueSsoTicket(prisma, userId, hotelName, ip);
|
||||
|
||||
// Use a configured Flash base URL (with the SSO ticket appended); otherwise
|
||||
// fall back to the Nitro client so the launcher still works.
|
||||
const flashBase = flashUrl || flashUrlAlt || "";
|
||||
const flashSrc = flashBase
|
||||
? `${flashBase}${flashBase.includes("?") ? "&" : "?"}sso=${encodeURIComponent(ticket)}`
|
||||
: "";
|
||||
const src = flashSrc || (await buildNitroClientUrl(ticket));
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🎮"
|
||||
title={`Launching ${hotelName ?? "Atom"} (Flash)…`}
|
||||
subtitle="Your single sign-on ticket is ready"
|
||||
action={
|
||||
<Link href="/client" className="btn btn-outline">
|
||||
Use Nitro client
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{src ? (
|
||||
<>
|
||||
<ContentCard
|
||||
icon="🚀"
|
||||
title="Open the Flash client"
|
||||
subtitle="Flash works best in its own tab"
|
||||
action={
|
||||
<a
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Launch in a new tab
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<ContentCard padded={false}>
|
||||
<iframe
|
||||
title="Flash hotel client"
|
||||
src={src}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "80vh",
|
||||
border: 0,
|
||||
borderRadius: 10,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
</ContentCard>
|
||||
</>
|
||||
) : (
|
||||
<ContentCard
|
||||
icon="🔌"
|
||||
title="No Flash client configured"
|
||||
subtitle="Set flash_client_url in settings"
|
||||
>
|
||||
<EmptyState icon="🔌">
|
||||
No Flash client URL configured (set <code>flash_client_url</code> in settings). SSO
|
||||
ticket issued for this session: <code>{ticket}</code>
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+100
-50
@@ -1,61 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||
import { buildNitroClientUrl } from "@/lib/services/clients";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export default function ClientPage() {
|
||||
const [ticket, setTicket] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hotelName, setHotelName] = useState("Hubbly");
|
||||
const [launched, setLaunched] = useState(false);
|
||||
|
||||
export default async function ClientPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
useEffect(() => {
|
||||
async function fetchTicket() {
|
||||
try {
|
||||
const res = await fetch("/api/client/sso");
|
||||
if (!res.ok) throw new Error("Failed to get SSO ticket");
|
||||
const data = await res.json();
|
||||
setTicket(data.ticket);
|
||||
setHotelName(data.hotelName);
|
||||
} catch (e) {
|
||||
setError("Could not generate login ticket");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchTicket();
|
||||
}, []);
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
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");
|
||||
}
|
||||
}, [ticket, launched]);
|
||||
|
||||
const hdrs = await headers();
|
||||
const ip =
|
||||
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
|
||||
<header
|
||||
className="flex h-14 items-center justify-between border-b px-4"
|
||||
style={{ backgroundColor: "var(--color-navbar)", borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold text-lg">{hotelName}</span>
|
||||
<span className="text-sm text-gray-500">Loading client…</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<ContentCard icon="⏳" title="Loading" subtitle="Generating SSO ticket…" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Faithful to AtomCMS's NitroController: issue (and persist) the SSO ticket on
|
||||
// the launcher render, then hand it to the Nitro client at {nitro_path}.
|
||||
const ticket = await issueSsoTicket(prisma, userId, hotelName, ip);
|
||||
const src = await buildNitroClientUrl(ticket);
|
||||
if (error || !ticket) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
|
||||
<header
|
||||
className="flex h-14 items-center justify-between border-b px-4"
|
||||
style={{ backgroundColor: "var(--color-navbar)", borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold text-lg">{hotelName}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 flex items-center justify-center">
|
||||
<ContentCard icon="⚠" title="Error" subtitle={error ?? "No SSO ticket"}>
|
||||
<EmptyState icon="⚠">{error ?? "Could not generate SSO ticket"}</EmptyState>
|
||||
</ContentCard>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🏨"
|
||||
title={`Launching ${hotelName}…`}
|
||||
subtitle="Your single sign-on ticket is ready"
|
||||
action={
|
||||
<Link href="/client/flash" className="btn btn-outline">
|
||||
Use Flash client
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{src ? (
|
||||
<ContentCard padded={false}>
|
||||
<iframe
|
||||
title="Hotel client"
|
||||
src={src}
|
||||
style={{ width: "100%", height: "85vh", border: 0, borderRadius: 10, display: "block" }}
|
||||
/>
|
||||
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
|
||||
<header
|
||||
className="flex h-14 items-center justify-between border-b px-4"
|
||||
style={{
|
||||
backgroundColor: "var(--color-navbar)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
color: "var(--color-navbar-text)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold text-lg">{hotelName}</span>
|
||||
<span className="text-sm" style={{ color: "var(--color-navbar-text)" }}>Connected</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 flex items-center justify-center" style={{ backgroundColor: "var(--color-background)" }}>
|
||||
<ContentCard>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.75rem", fontWeight: 700, marginBottom: "0.5rem" }}>
|
||||
Welcome to {hotelName}
|
||||
</h1>
|
||||
<p style={{ color: "var(--color-text-muted)", marginBottom: "2rem" }}>
|
||||
Opening the client in a new tab… If it doesn't open,{" "}
|
||||
<a href={`/nitro-client/?sso=${encodeURIComponent(ticket)}`} target="_blank" rel="noopener noreferrer">
|
||||
click here
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard icon="🔌" title="No client configured" subtitle="Set nitro_path in settings">
|
||||
<EmptyState icon="🔌">
|
||||
No Nitro client configured — set <code>nitro_path</code> (e.g.{" "}
|
||||
<code>/Nitro-V3/dist</code>) in the admin settings. SSO ticket issued for this session:{" "}
|
||||
<code>{ticket}</code>
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
)}
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,9 @@ body {
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.text-shadow-sm {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.transition-base {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -45,12 +45,10 @@ export default async function ArticlePage({
|
||||
const { slug } = await params;
|
||||
const t = await getTranslations("pages.article");
|
||||
|
||||
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
|
||||
try {
|
||||
article = await prisma.websiteArticles.findUnique({ where: { slug } });
|
||||
} catch {
|
||||
article = null;
|
||||
}
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true, createdAt: true, updatedAt: true },
|
||||
}).catch(() => null);
|
||||
if (!article) notFound();
|
||||
|
||||
const articleId = article.id;
|
||||
|
||||
+310
-70
@@ -1,91 +1,331 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { ArticleSlider, type SliderArticle } from "@/components/public/article-slider";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations("pages.home");
|
||||
const session = await auth();
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
const imager =
|
||||
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
|
||||
|
||||
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = [];
|
||||
try {
|
||||
articles = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 4,
|
||||
select: { slug: true, title: true, shortStory: true, image: true },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — show the shell without articles.
|
||||
if (!session?.user) {
|
||||
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = [];
|
||||
try {
|
||||
articles = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
select: { slug: true, title: true, shortStory: true, image: true },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
let recentUsers: Array<{ username: string; look: string }> = [];
|
||||
try {
|
||||
recentUsers = await prisma.user.findMany({
|
||||
orderBy: { lastOnline: "desc" },
|
||||
take: 12,
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
{/* LEFT COLUMN — 1/3 */}
|
||||
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
||||
{/* Login Card */}
|
||||
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<div
|
||||
className="relative h-[120px] overflow-hidden bg-center md:h-[150px]"
|
||||
style={{
|
||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
||||
backgroundSize: "auto",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "calc(30% - 10px) calc(50% - 35px)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 z-10 bg-black/30" />
|
||||
</div>
|
||||
<div className="bg-gray-100 p-3 dark:bg-gray-800">
|
||||
<HomeLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Button */}
|
||||
<a href="/register" className="relative block">
|
||||
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<img src="/assets/images/EnterHubbly.png" alt="Register" className="block" />
|
||||
</div>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white"
|
||||
style={{ textShadow: "1px 1px 2px rgba(0,0,0,0.8)", marginLeft: "15px" }}
|
||||
>
|
||||
REGISTER NOW
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Recent Users */}
|
||||
{recentUsers.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<div
|
||||
className="flex h-12 w-full items-center justify-center bg-repeat"
|
||||
style={{
|
||||
background: "linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="px-3 text-xl font-bold text-white">Recent Users</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4 p-4">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="relative overflow-visible rounded-md bg-white p-0 shadow-lg dark:bg-gray-800"
|
||||
style={{ width: "50px", height: "50px", justifySelf: "center" }}
|
||||
>
|
||||
<img
|
||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
|
||||
alt={u.username}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-12px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
maxWidth: "none",
|
||||
maxHeight: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN — 2/3 */}
|
||||
<div className="flex w-full flex-col gap-4 md:w-2/3">
|
||||
{/* News */}
|
||||
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<div
|
||||
className="flex h-12 w-full items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-white">Latest News</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{articles.length === 0 ? (
|
||||
<p className="py-8 text-center text-gray-500 dark:text-gray-400">No articles available</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{articles.map((a) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/news/${a.slug}`}
|
||||
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
|
||||
style={{
|
||||
height: "215px",
|
||||
backgroundColor: "var(--color-surface)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={a.image}
|
||||
alt={a.title}
|
||||
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 w-full p-2"
|
||||
style={{ bottom: "-2px", background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)" }}
|
||||
>
|
||||
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">{a.title}</h3>
|
||||
{a.shortStory && (
|
||||
<p className="truncate text-xs font-normal text-white text-shadow-sm">
|
||||
{excerpt(a.shortStory, 80)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-shape for the client slider: compute excerpts server-side so the client
|
||||
// component carries no prisma/i18n dependency.
|
||||
const slides: SliderArticle[] = articles.map((a) => ({
|
||||
slug: a.slug,
|
||||
title: a.title,
|
||||
image: a.image,
|
||||
excerpt: excerpt(a.shortStory, 110),
|
||||
}));
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(session.user.id) },
|
||||
select: { username: true, look: true },
|
||||
}).catch(() => null);
|
||||
if (!dbUser) {
|
||||
return <p className="py-8 text-center text-gray-500">User not found.</p>;
|
||||
}
|
||||
|
||||
// Staff-authored content boxes (managed at /admin/writeable-boxes).
|
||||
const boxes = await prisma.websiteWriteableBoxes
|
||||
.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
select: { id: true, title: true, icon: true, content: true },
|
||||
})
|
||||
.catch(() => []);
|
||||
const userId = Number(session.user.id);
|
||||
let onlineFriends: Array<{ username: string; look: string }> = [];
|
||||
try {
|
||||
const friendships = await prisma.messengerFriendships.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: userId },
|
||||
{ userTwoId: userId },
|
||||
],
|
||||
},
|
||||
select: { userOneId: true, userTwoId: true },
|
||||
});
|
||||
const friendIds = friendships.map((f) =>
|
||||
f.userOneId === userId ? f.userTwoId : f.userOneId,
|
||||
);
|
||||
if (friendIds.length > 0) {
|
||||
onlineFriends = await prisma.user.findMany({
|
||||
where: { id: { in: friendIds }, online: "1" },
|
||||
take: 10,
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const [latestArticle] = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { slug: true, title: true, shortStory: true, image: true },
|
||||
}).catch(() => []);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
<ContentCard
|
||||
icon="🏨"
|
||||
title={t("welcome", { hotelName })}
|
||||
subtitle={t("welcomeSubtitle")}
|
||||
>
|
||||
<p className="muted" style={{ margin: "0 0 0.9rem" }}>
|
||||
{t("welcomeBody")}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||||
<Link className="btn btn-primary" href="/client">
|
||||
{t("enterHotel")}
|
||||
</Link>
|
||||
<Link className="btn btn-outline" href="/news">
|
||||
{t("latestNews")}
|
||||
</Link>
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
{/* Left column — 9/12 */}
|
||||
<div className="flex w-full flex-col gap-6 md:w-3/4">
|
||||
{/* User Hero */}
|
||||
<div
|
||||
className="relative flex h-[180px] items-center overflow-hidden rounded-lg"
|
||||
style={{
|
||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
||||
backgroundSize: "auto",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "calc(30% - 10px) calc(50% - 35px)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 z-10 bg-black/30" />
|
||||
<div className="relative z-20 flex w-full items-center justify-between px-4">
|
||||
<Link href={`/u/${dbUser.username}`}>
|
||||
<img
|
||||
src={avatarImageUrl(imager, dbUser.look, { size: "l", direction: 2 })}
|
||||
alt={dbUser.username}
|
||||
className="relative transition-all duration-300 hover:scale-105 drop-shadow"
|
||||
style={{ imageRendering: "pixelated", marginTop: "30px" }}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<Link href="/client">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/90 px-6 py-2 text-lg font-semibold text-black transition duration-300 ease-in-out hover:bg-white dark:bg-gray-900 dark:text-white"
|
||||
>
|
||||
Go to {hotelName}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="📰"
|
||||
title={t("newsTitle")}
|
||||
subtitle={t("newsSubtitle")}
|
||||
action={
|
||||
<Link className="btn btn-outline" href="/news">
|
||||
{t("allNews")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{slides.length === 0 ? (
|
||||
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
|
||||
) : (
|
||||
<ArticleSlider articles={slides} />
|
||||
)}
|
||||
</ContentCard>
|
||||
{/* Online Friends */}
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
|
||||
style={{ backgroundColor: "#e9b124" }}
|
||||
>
|
||||
<span className="relative text-white">Online Friends</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
{onlineFriends.length === 0 ? (
|
||||
<p className="mb-3 block w-full text-center text-xs font-medium text-gray-500 dark:text-white md:mb-0 md:text-left">
|
||||
No friends online
|
||||
</p>
|
||||
) : (
|
||||
onlineFriends.map((f) => (
|
||||
<Link
|
||||
key={f.username}
|
||||
href={`/u/${f.username}`}
|
||||
className="transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
<img
|
||||
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
||||
alt={f.username}
|
||||
className="h-10 w-10"
|
||||
loading="lazy"
|
||||
title={f.username}
|
||||
/>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{boxes.map((b) => (
|
||||
<ContentCard key={String(b.id)} icon={b.icon ?? "📌"} title={b.title}>
|
||||
{/* Staff-authored HTML, sanitised server-side before injection. */}
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised writeable box */}
|
||||
<div dangerouslySetInnerHTML={{ __html: sanitize(b.content) }} />
|
||||
</ContentCard>
|
||||
))}
|
||||
</main>
|
||||
{/* Right column — 3/12 */}
|
||||
<div className="flex w-full flex-col gap-6 md:w-1/4">
|
||||
{latestArticle ? (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-12 items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-white">Latest Article</h2>
|
||||
</div>
|
||||
<Link
|
||||
href={`/news/${latestArticle.slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
src={latestArticle.image}
|
||||
alt={latestArticle.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
|
||||
{latestArticle.shortStory && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{excerpt(latestArticle.shortStory, 100)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+27
-48
@@ -1,8 +1,6 @@
|
||||
import Script from "next/script";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { register } from "@/actions/register";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,51 +12,32 @@ export default async function RegisterPage({
|
||||
}) {
|
||||
const t = await getTranslations("pages.register");
|
||||
const { error } = await searchParams;
|
||||
const captcha = await captchaConfig();
|
||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
||||
|
||||
const [hotelName, imager, captcha, femaleFigures, maleFigures] = await Promise.all([
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
||||
captchaConfig(),
|
||||
siteSettings.get("register_female_figures", "hr-100-45.hd-180-1.ch-215-62.lg-270-62.sh-290-62,hr-130-45.hd-180-2.ch-220-62.lg-280-62.sh-305-62,hr-140-45.hd-180-3.ch-225-62.lg-270-62.sh-310-62,hr-165-45.hd-180-1.ch-210-62.lg-270-62.sh-290-62,hr-190-45.hd-180-2.ch-215-62.lg-270-62.sh-305-62"),
|
||||
siteSettings.get("register_male_figures", "hr-100-61.hd-180-1.ch-210-66.lg-270-110.sh-305-62,hr-105-61.hd-190-2.ch-215-66.lg-280-110.sh-310-62,hr-110-61.hd-200-3.ch-220-66.lg-290-110.sh-320-62,hr-115-61.hd-185-1.ch-210-66.lg-275-110.sh-315-62,hr-120-61.hd-180-2.ch-205-66.lg-285-110.sh-305-62"),
|
||||
]);
|
||||
|
||||
const allFigures = [
|
||||
...(femaleFigures?.split(",").filter(Boolean) || []),
|
||||
...(maleFigures?.split(",").filter(Boolean) || []),
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
||||
<ContentCard
|
||||
icon="🎉"
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
>
|
||||
<form action={register} style={{ display: "grid", gap: "0.7rem" }}>
|
||||
<input name="username" placeholder={t("usernamePlaceholder")} autoComplete="username" required />
|
||||
<input name="mail" type="email" placeholder={t("emailPlaceholder")} autoComplete="email" required />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
||||
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
|
||||
) : null}
|
||||
{showCaptcha && captcha.provider === "recaptcha" ? (
|
||||
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("register")}
|
||||
</button>
|
||||
</form>
|
||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
||||
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
|
||||
) : null}
|
||||
{showCaptcha && captcha.provider === "recaptcha" ? (
|
||||
<Script src="https://www.google.com/recaptcha/api.js" async defer />
|
||||
) : null}
|
||||
{error ? (
|
||||
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
|
||||
{t("alreadyHaveAccount")} <Link href="/login">{t("signIn")}</Link>
|
||||
</p>
|
||||
</ContentCard>
|
||||
<main className="flex min-h-[calc(100vh-200px)] items-center justify-center px-4 py-8">
|
||||
<div className="w-full">
|
||||
<h1 className="sr-only">{t("title")}</h1>
|
||||
<RegisterForm
|
||||
initialFigures={allFigures}
|
||||
imager={imager ?? ""}
|
||||
hotelName={hotelName ?? "Atom"}
|
||||
captcha={captcha}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user