Add data-driven public pages (home, news, profile)

Real App Router pages reading the converted Prisma models:
- home: latest 4 articles (websiteArticles) + hotel_name from settings
- /news + /news/[slug]: article index and detail
- /u/[username]: profile (avatar via imager helper, motto, rank, credits, online)
- shared SiteNav (reads session via auth() + hotel_name), globals.css
- src/lib/format.ts: avatarImageUrl + excerpt helpers (unit-tested)

Verified: tsc exit 0, vitest 43/43, next build exit 0 (7 routes). Pages render
against a live DB (deferred until DATABASE_URL is provided). i18n to be layered
on next.
This commit is contained in:
Simo committed 2026-06-27 16:42:59 +02:00
1 parent 8fca407f0f
commit 6e34d485d4
9 files changed
+327 -7

No files matched your search

+81
View File
@@ -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;
}
+6 -1
View File
@@ -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 (
<html lang="en">
<body>{children}</body>
<body>
<SiteNav />
<div className="container">{children}</div>
</body>
</html>
);
}
+35
View File
@@ -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 (
<main>
<p className="muted">
<Link href="/news">← News</Link>
</p>
<h1>{article.title}</h1>
{article.createdAt ? (
<p className="muted">{article.createdAt.toISOString().slice(0, 10)}</p>
) : null}
{article.image ? (
<img
src={article.image}
alt=""
style={{ width: "100%", borderRadius: 10, margin: "1rem 0" }}
/>
) : null}
<div style={{ whiteSpace: "pre-wrap" }}>{article.fullStory}</div>
</main>
);
}
+39
View File
@@ -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 (
<main>
<h1>News</h1>
{articles.length === 0 ? (
<p className="muted">No articles yet.</p>
) : (
<div className="grid">
{articles.map((a) => (
<article key={a.slug} className="card">
<h3 style={{ margin: "0 0 0.25rem" }}>
<Link href={`/news/${a.slug}`}>{a.title}</Link>
</h3>
<p className="muted" style={{ marginTop: 0 }}>
{formatDate(a.createdAt)}
</p>
<p style={{ margin: 0 }}>{excerpt(a.shortStory, 200)}</p>
</article>
))}
</div>
)}
</main>
);
}
+33 -6
View File
@@ -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 (
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 640 }}>
<h1>Welcome to {hotelName}</h1>
<p>AtomCMS → Next.js conversion. Foundation, full Prisma schema and auth core are in place.</p>
<Link href="/login">Login →</Link>
<main>
<h1>Welcome to {hotelName ?? "Atom"}</h1>
<p className="muted">AtomCMS → Next.js. Reading live data through the converted Prisma schema.</p>
<h2>Latest news</h2>
{articles.length === 0 ? (
<p className="muted">No articles yet.</p>
) : (
<div className="grid cols-2">
{articles.map((a) => (
<article key={a.slug} className="card" style={{ padding: 0, overflow: "hidden" }}>
{a.image ? <img className="article-img" src={a.image} alt="" /> : null}
<div style={{ padding: "0.9rem 1.1rem" }}>
<h3 style={{ margin: "0 0 0.3rem" }}>
<Link href={`/news/${a.slug}`}>{a.title}</Link>
</h3>
<p className="muted" style={{ margin: 0 }}>
{excerpt(a.shortStory, 120)}
</p>
</div>
</article>
))}
</div>
)}
</main>
);
}
+52
View File
@@ -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 (
<main>
<div className="card" style={{ display: "flex", gap: "1.5rem", alignItems: "center" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={avatar} alt={`${user.username} avatar`} width={100} height={150} />
<div>
<h1 style={{ margin: "0 0 0.25rem" }}>{user.username}</h1>
<p style={{ margin: "0 0 0.5rem" }}>{user.motto || <span className="muted">No motto</span>}</p>
<p className="muted" style={{ margin: 0 }}>
Rank {user.rank} · {user.credits} credits · {user.online === "1" ? "Online" : "Offline"} ·
Registered {registered}
</p>
</div>
</div>
</main>
);
}
+30
View File
@@ -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 (
<nav className="site-nav">
<div className="container">
<Link href="/" className="brand">
{hotelName ?? "Atom"}
</Link>
<Link href="/news">News</Link>
<span className="spacer" />
{session?.user ? (
<>
<Link href={`/u/${session.user.name}`}>{session.user.name}</Link>
<Link href="/client">Hotel</Link>
</>
) : (
<Link href="/login">Login</Link>
)}
</div>
</nav>
);
}
+30
View File
@@ -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);
});
});
+21
View File
@@ -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()}…`;
}