diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 00000000..85ccb730
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,81 @@
+:root {
+ --bg: #0f1217;
+ --panel: #171b22;
+ --border: #262c36;
+ --text: #e6e9ef;
+ --muted: #97a0ae;
+ --primary: #4f8cff;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+ line-height: 1.5;
+}
+
+a {
+ color: var(--primary);
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+
+.container {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 1.5rem;
+}
+
+.site-nav {
+ border-bottom: 1px solid var(--border);
+ background: var(--panel);
+}
+.site-nav .container {
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+ padding-top: 0.9rem;
+ padding-bottom: 0.9rem;
+}
+.site-nav .brand {
+ font-weight: 700;
+ color: var(--text);
+ font-size: 1.1rem;
+}
+.site-nav .spacer {
+ flex: 1;
+}
+
+.card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 1rem 1.2rem;
+}
+
+.grid {
+ display: grid;
+ gap: 1rem;
+}
+.grid.cols-2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.article-img {
+ width: 100%;
+ height: 140px;
+ object-fit: cover;
+ border-radius: 8px 8px 0 0;
+}
+
+.muted {
+ color: var(--muted);
+ font-size: 0.85rem;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 2aa034b3..d7e2f9a9 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
+import { SiteNav } from "@/components/site-nav";
+import "./globals.css";
export const metadata: Metadata = {
title: "AtomCMS",
@@ -9,7 +11,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
{children}
+
+
+ {children}
+
);
}
diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx
new file mode 100644
index 00000000..8282cfa2
--- /dev/null
+++ b/src/app/news/[slug]/page.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { prisma } from "@/lib/prisma";
+
+export const dynamic = "force-dynamic";
+
+export default async function ArticlePage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const article = await prisma.websiteArticles.findUnique({ where: { slug } });
+ if (!article) notFound();
+
+ return (
+
+
+ ← News
+
+ {article.title}
+ {article.createdAt ? (
+ {article.createdAt.toISOString().slice(0, 10)}
+ ) : null}
+ {article.image ? (
+
+ ) : null}
+ {article.fullStory}
+
+ );
+}
diff --git a/src/app/news/page.tsx b/src/app/news/page.tsx
new file mode 100644
index 00000000..eed8a959
--- /dev/null
+++ b/src/app/news/page.tsx
@@ -0,0 +1,39 @@
+import Link from "next/link";
+import { excerpt } from "@/lib/format";
+import { prisma } from "@/lib/prisma";
+
+export const dynamic = "force-dynamic";
+
+function formatDate(d: Date | null): string {
+ return d ? d.toISOString().slice(0, 10) : "";
+}
+
+export default async function NewsPage() {
+ const articles = await prisma.websiteArticles.findMany({
+ orderBy: { createdAt: "desc" },
+ take: 30,
+ });
+
+ return (
+
+ News
+ {articles.length === 0 ? (
+ No articles yet.
+ ) : (
+
+ {articles.map((a) => (
+
+
+ {a.title}
+
+
+ {formatDate(a.createdAt)}
+
+ {excerpt(a.shortStory, 200)}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index fbe76e7e..b3348e3a 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,17 +1,44 @@
import Link from "next/link";
+import { excerpt } from "@/lib/format";
+import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
-// Reads settings from the DB at request time — never at build.
export const dynamic = "force-dynamic";
export default async function HomePage() {
- const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
+ const [hotelName, articles] = await Promise.all([
+ siteSettings.get("hotel_name", "Atom"),
+ prisma.websiteArticles.findMany({
+ orderBy: { createdAt: "desc" },
+ take: 4,
+ }),
+ ]);
return (
-
- Welcome to {hotelName}
- AtomCMS → Next.js conversion. Foundation, full Prisma schema and auth core are in place.
- Login →
+
+ Welcome to {hotelName ?? "Atom"}
+ AtomCMS → Next.js. Reading live data through the converted Prisma schema.
+
+ Latest news
+ {articles.length === 0 ? (
+ No articles yet.
+ ) : (
+
+ {articles.map((a) => (
+
+ {a.image ?
: null}
+
+
+ {a.title}
+
+
+ {excerpt(a.shortStory, 120)}
+
+
+
+ ))}
+
+ )}
);
}
diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx
new file mode 100644
index 00000000..c65c944c
--- /dev/null
+++ b/src/app/u/[username]/page.tsx
@@ -0,0 +1,52 @@
+import { notFound } from "next/navigation";
+import { avatarImageUrl } from "@/lib/format";
+import { prisma } from "@/lib/prisma";
+import { siteSettings } from "@/lib/services/site-settings";
+
+export const dynamic = "force-dynamic";
+
+export default async function ProfilePage({
+ params,
+}: {
+ params: Promise<{ username: string }>;
+}) {
+ const { username } = await params;
+
+ const [user, imagerBase] = await Promise.all([
+ prisma.user.findUnique({
+ where: { username },
+ select: {
+ username: true,
+ motto: true,
+ look: true,
+ rank: true,
+ credits: true,
+ online: true,
+ accountCreated: true,
+ },
+ }),
+ siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
+ ]);
+
+ if (!user) notFound();
+
+ const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" });
+ const registered = new Date(user.accountCreated * 1000).toISOString().slice(0, 10);
+
+ return (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
{user.username}
+
{user.motto || No motto}
+
+ Rank {user.rank} · {user.credits} credits · {user.online === "1" ? "Online" : "Offline"} ·
+ Registered {registered}
+
+
+
+
+ );
+}
diff --git a/src/components/site-nav.tsx b/src/components/site-nav.tsx
new file mode 100644
index 00000000..0aa7b972
--- /dev/null
+++ b/src/components/site-nav.tsx
@@ -0,0 +1,30 @@
+import Link from "next/link";
+import { auth } from "@/lib/auth";
+import { siteSettings } from "@/lib/services/site-settings";
+
+export async function SiteNav() {
+ const [hotelName, session] = await Promise.all([
+ siteSettings.get("hotel_name", "Atom"),
+ auth(),
+ ]);
+
+ return (
+
+ );
+}
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
new file mode 100644
index 00000000..fc21675b
--- /dev/null
+++ b/src/lib/format.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { avatarImageUrl, excerpt } from "./format";
+
+describe("avatarImageUrl", () => {
+ const base = "https://www.habbo.com/habbo-imaging/avatarimage";
+
+ it("appends the figure and options", () => {
+ const url = avatarImageUrl(base, "hr-100", { size: "l", headOnly: true });
+ expect(url).toContain("figure=hr-100");
+ expect(url).toContain("size=l");
+ expect(url).toContain("headonly=1");
+ expect(url.startsWith(`${base}?`)).toBe(true);
+ });
+
+ it("uses & when the base already has a query string", () => {
+ expect(avatarImageUrl(`${base}?x=1`, "hr-100").includes("?x=1&figure=hr-100")).toBe(true);
+ });
+});
+
+describe("excerpt", () => {
+ it("returns short text unchanged", () => {
+ expect(excerpt("hello", 160)).toBe("hello");
+ });
+
+ it("truncates on a word boundary with an ellipsis", () => {
+ const out = excerpt("the quick brown fox jumps", 12);
+ expect(out).toBe("the quick…");
+ expect(out.length).toBeLessThanOrEqual(13);
+ });
+});
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 00000000..866fd211
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,21 @@
+/** Build a Habbo avatar-imager URL for a figure string. */
+export function avatarImageUrl(
+ base: string,
+ look: string,
+ opts: { size?: "s" | "m" | "l"; headOnly?: boolean; direction?: number } = {},
+): string {
+ const params = new URLSearchParams({ figure: look });
+ if (opts.size) params.set("size", opts.size);
+ if (opts.headOnly) params.set("headonly", "1");
+ if (opts.direction !== undefined) params.set("direction", String(opts.direction));
+ const sep = base.includes("?") ? "&" : "?";
+ return `${base}${sep}${params.toString()}`;
+}
+
+/** Trim text to `max` chars on a word boundary, adding an ellipsis. */
+export function excerpt(text: string, max = 160): string {
+ if (text.length <= max) return text;
+ const cut = text.slice(0, max);
+ const lastSpace = cut.lastIndexOf(" ");
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
+}