latest changes

This commit is contained in:
remco committed 2026-07-01 15:06:52 +02:00
1 parent 56cd6fd058
commit 1de72863db
40 files changed
+3837 -568

No files matched your search

+310 -70
View File
@@ -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>
);
}