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

+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()}…`;
}