From 6e34d485d4cbd8370d257082510eae9cc73ce821 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 27 Jun 2026 16:42:59 +0200 Subject: [PATCH] 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. --- src/app/globals.css | 81 +++++++++++++++++++++++++++++++++++ src/app/layout.tsx | 7 ++- src/app/news/[slug]/page.tsx | 35 +++++++++++++++ src/app/news/page.tsx | 39 +++++++++++++++++ src/app/page.tsx | 39 ++++++++++++++--- src/app/u/[username]/page.tsx | 52 ++++++++++++++++++++++ src/components/site-nav.tsx | 30 +++++++++++++ src/lib/format.test.ts | 30 +++++++++++++ src/lib/format.ts | 21 +++++++++ 9 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 src/app/globals.css create mode 100644 src/app/news/[slug]/page.tsx create mode 100644 src/app/news/page.tsx create mode 100644 src/app/u/[username]/page.tsx create mode 100644 src/components/site-nav.tsx create mode 100644 src/lib/format.test.ts create mode 100644 src/lib/format.ts 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.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()}…`; +}