From 9a79acebe7e6e773011acef13b4775d451a34c75 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 28 Jun 2026 13:13:00 +0200 Subject: [PATCH] Add atom-theme visual design (header/footer, theme system, restyled pages) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faithful port of AtomCMS's "atom" theme look (light, Habbo-retro, golden #eeb425 / amber #f59e0b accents, white cards, 12px radii, Nunito-first stack): - globals.css: full token set + components (.site-header/.nav-item, .btn-*, .card, .hero, currency pills, article cards, inputs, tables, footer). - SiteHeader (brand + nav + currency pills + auth box, staff-aware) + SiteFooter; layout wraps header/content/footer. Removed the bare SiteNav. - Restyled home (hero + article cards), login (centered card). - siteSettings now falls back to DEFAULTS on missing key OR DB error, so pages render without a DB; DATABASE_CONNECT_TIMEOUT_MS env + pool acquireTimeout make the no-DB fallback fast. Verified in a real browser (computed styles): header white/sticky, golden logo gradient, uppercase nav, amber primary button @12px radius, golden outline, hero gradient — all correct. tsc exit 0, vitest 48/48, next build exit 0. --- src/app/globals.css | 324 ++++++++++++++++++++++++++---- src/app/layout.tsx | 12 +- src/app/login/page.tsx | 49 +++-- src/app/page.tsx | 37 +++- src/components/site-footer.tsx | 18 ++ src/components/site-header.tsx | 88 ++++++++ src/components/site-nav.tsx | 30 --- src/env.ts | 1 + src/lib/prisma.ts | 3 +- src/lib/services/site-settings.ts | 37 +++- 10 files changed, 490 insertions(+), 109 deletions(-) create mode 100644 src/components/site-footer.tsx create mode 100644 src/components/site-header.tsx delete mode 100644 src/components/site-nav.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 85ccb730..e1b82a46 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,63 +1,229 @@ :root { - --bg: #0f1217; - --panel: #171b22; - --border: #262c36; - --text: #e6e9ef; - --muted: #97a0ae; - --primary: #4f8cff; + /* Atom theme tokens (faithful to AtomCMS app.blade.php defaults) */ + --color-primary: #f59e0b; + --color-primary-hover: #cf9d15; + --color-golden: #eeb425; + --color-background: #f1f5f9; + --color-surface: #ffffff; + --color-navbar: #ffffff; + --color-text: #0f172a; + --color-text-muted: #64748b; + --color-accent: #10b981; + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-secondary: #22c55e; + --color-secondary-hover: #16a34a; + --border-color: #e7ebf0; + --border-radius: 12px; + --radius-sm: 8px; + --navbar-height: 64px; + --shadow-card: 0 1px 2px rgba(15, 23, 42, 0.06), 0 4px 14px rgba(15, 23, 42, 0.05); + --shadow-card-hover: 0 6px 22px rgba(15, 23, 42, 0.12); + --font-family: "Nunito", ui-rounded, "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; } * { box-sizing: border-box; } +html, body { margin: 0; - background: var(--bg); - color: var(--text); - font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; - line-height: 1.5; + padding: 0; +} + +body { + background: + radial-gradient(1200px 500px at 50% -200px, #fff7e6 0%, transparent 60%), + var(--color-background); + color: var(--color-text); + font-family: var(--font-family); + font-size: 16px; + line-height: 1.55; + min-height: 100vh; } a { - color: var(--primary); + color: var(--color-primary-hover); text-decoration: none; } a:hover { text-decoration: underline; } -.container { - max-width: 960px; - margin: 0 auto; - padding: 1.5rem; +h1 { + font-size: 30px; + font-weight: 800; + margin: 0.2em 0 0.5em; +} +h2 { + font-size: 22px; + font-weight: 700; +} +h3 { + font-size: 18px; + font-weight: 700; } -.site-nav { - border-bottom: 1px solid var(--border); - background: var(--panel); +.container { + max-width: 1180px; + margin: 0 auto; + padding: 1.75rem 1.25rem 3rem; } -.site-nav .container { + +.muted { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +/* ── Header / nav ─────────────────────────────────────────── */ +.site-header { + background: var(--color-navbar); + border-bottom: 1px solid var(--border-color); + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03); + position: sticky; + top: 0; + z-index: 50; +} +.site-header .bar { + max-width: 1180px; + margin: 0 auto; + height: var(--navbar-height); display: flex; align-items: center; - gap: 1.25rem; - padding-top: 0.9rem; - padding-bottom: 0.9rem; + gap: 1.5rem; + padding: 0 1.25rem; } -.site-nav .brand { +.brand { + display: flex; + align-items: center; + gap: 0.55rem; + font-weight: 800; + font-size: 1.25rem; + color: var(--color-text); +} +.brand .dot { + width: 26px; + height: 26px; + border-radius: 8px; + background: linear-gradient(135deg, var(--color-primary), var(--color-golden)); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.4); +} +.nav { + display: flex; + align-items: center; + gap: 0.35rem; + height: 100%; +} +.nav-item { + display: inline-flex; + align-items: center; + height: 100%; + padding: 0 0.85rem; + font-size: 13px; font-weight: 700; - color: var(--text); - font-size: 1.1rem; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--color-text); + border-bottom: 3px solid transparent; + transition: all 0.18s ease-in-out; } -.site-nav .spacer { +.nav-item:hover { + color: var(--color-primary-hover); + border-bottom-color: var(--color-primary); + text-decoration: none; +} +.spacer { flex: 1; } +/* ── Currency pills ───────────────────────────────────────── */ +.currencies { + display: flex; + gap: 0.5rem; +} +.currency { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 800; + font-size: 0.85rem; + padding: 0.2rem 0.6rem 0.2rem 0.2rem; + border-radius: 999px; + background: #f8fafc; + border: 1px solid var(--border-color); +} +.currency .coin { + width: 22px; + height: 22px; + border-radius: 999px; + display: grid; + place-items: center; + color: #fff; + font-size: 0.7rem; +} +.coin.credits { + background: #e9b124; +} +.coin.duckets { + background: #c44aac; +} +.coin.diamonds { + background: #4bc0d9; +} + +/* ── Buttons ──────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.5rem 1.1rem; + border-radius: var(--border-radius); + border: 2px solid transparent; + font-family: inherit; + font-weight: 700; + font-size: 14px; + cursor: pointer; + transition: all 0.18s ease-in-out; + background: #eef2f7; + color: var(--color-text); +} +.btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-card); + text-decoration: none; +} +.btn-primary { + background: var(--color-primary); + color: #3a2c05; +} +.btn-secondary { + background: var(--color-secondary); + color: #fff; +} +.btn-danger { + background: var(--color-danger); + color: #fff; +} +.btn-outline { + background: transparent; + border-color: var(--color-golden); + color: #8a6a05; +} + +/* ── Cards ────────────────────────────────────────────────── */ .card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1rem 1.2rem; + background: var(--color-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + padding: 1.1rem 1.25rem; +} +.card.hover:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-card-hover); + transition: all 0.2s ease-in-out; } .grid { @@ -67,15 +233,105 @@ a:hover { .grid.cols-2 { grid-template-columns: repeat(2, 1fr); } +.grid.cols-3 { + grid-template-columns: repeat(3, 1fr); +} +@media (max-width: 720px) { + .grid.cols-2, + .grid.cols-3 { + grid-template-columns: 1fr; + } +} -.article-img { +/* ── Article cards ────────────────────────────────────────── */ +.article { + overflow: hidden; + padding: 0; +} +.article .article-img { width: 100%; height: 140px; object-fit: cover; - border-radius: 8px 8px 0 0; + display: block; + background: linear-gradient(135deg, #fde68a, #fbbf24); +} +.article .body { + padding: 0.85rem 1.05rem 1.05rem; } -.muted { - color: var(--muted); +/* ── Forms ────────────────────────────────────────────────── */ +input, +select, +textarea { + font-family: inherit; + font-size: 14px; + padding: 0.5rem 0.7rem; + border: 2px solid var(--border-color); + border-radius: var(--radius-sm); + background: #fbfcfe; + color: var(--color-text); + outline: none; + transition: border-color 0.15s ease; +} +input:focus, +select:focus, +textarea:focus { + border-color: var(--color-primary); +} +button { + font-family: inherit; +} + +/* ── Hero ─────────────────────────────────────────────────── */ +.hero { + background: + linear-gradient(120deg, rgba(245, 158, 11, 0.16), rgba(16, 185, 129, 0.12)), + var(--color-surface); + border: 1px solid var(--border-color); + border-radius: 18px; + padding: 2rem 2.25rem; + box-shadow: var(--shadow-card); + margin-bottom: 1.5rem; +} + +/* ── Tables (admin) ───────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; +} +th { + text-align: left; + color: var(--color-text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.4rem 0.5rem; +} +td { + padding: 0.55rem 0.5rem; + border-top: 1px solid var(--border-color); +} + +/* ── Avatar ───────────────────────────────────────────────── */ +.avatar { + border-radius: 8px; + background: #f1f5f9; + border: 2px solid var(--color-golden); +} + +/* ── Footer ───────────────────────────────────────────────── */ +.site-footer { + border-top: 1px solid var(--border-color); + background: var(--color-surface); + color: var(--color-text-muted); font-size: 0.85rem; } +.site-footer .inner { + max-width: 1180px; + margin: 0 auto; + padding: 1.1rem 1.25rem; + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: center; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d7e2f9a9..bc7c1897 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; -import { SiteNav } from "@/components/site-nav"; +import { SiteFooter } from "@/components/site-footer"; +import { SiteHeader } from "@/components/site-header"; import "./globals.css"; export const metadata: Metadata = { @@ -12,8 +13,13 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( - -
{children}
+
+ +
+ {children} +
+ +
); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 75708c32..54576768 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -23,27 +23,34 @@ export default function LoginPage() { } return ( -
-

Login

-
- setUsername(e.target.value)} - placeholder="Username" - autoComplete="username" - /> - setPassword(e.target.value)} - placeholder="Password" - autoComplete="current-password" - /> - -
- {error &&

{error}

} +
+
+

Sign in

+

+ Welcome back — log in to enter the hotel. +

+
+ setUsername(e.target.value)} + placeholder="Username" + autoComplete="username" + /> + setPassword(e.target.value)} + placeholder="Password" + autoComplete="current-password" + /> + +
+ {error ? ( +

{error}

+ ) : null} +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index b3348e3a..54a7b16d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,18 +6,35 @@ import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; export default async function HomePage() { - const [hotelName, articles] = await Promise.all([ - siteSettings.get("hotel_name", "Atom"), - prisma.websiteArticles.findMany({ + const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom"; + + 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. + } return (
-

Welcome to {hotelName ?? "Atom"}

-

AtomCMS → Next.js. Reading live data through the converted Prisma schema.

+
+

Welcome to {hotelName}

+

+ Your retro hotel, now powered by Next.js. Hang out, collect furni, and meet friends. +

+
+ + Enter hotel + + + Latest news + +
+

Latest news

{articles.length === 0 ? ( @@ -25,9 +42,9 @@ export default async function HomePage() { ) : (
{articles.map((a) => ( -
- {a.image ? : null} -
+
+ {a.image ? :
} +

{a.title}

diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx new file mode 100644 index 00000000..3fb77a6f --- /dev/null +++ b/src/components/site-footer.tsx @@ -0,0 +1,18 @@ +import { siteSettings } from "@/lib/services/site-settings"; + +export async function SiteFooter() { + const hotelName = await siteSettings.get("hotel_name", "Atom"); + const year = new Date().getFullYear(); + + return ( +
+
+ + © {year} {hotelName} + + + AtomCMS → Next.js conversion +
+
+ ); +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx new file mode 100644 index 00000000..6c11325c --- /dev/null +++ b/src/components/site-header.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; + +export async function SiteHeader() { + const [hotelName, session] = await Promise.all([ + siteSettings.get("hotel_name", "Atom"), + auth(), + ]); + + let credits = 0; + let duckets = 0; + let diamonds = 0; + if (session?.user?.id) { + try { + const id = Number(session.user.id); + const [user, currencies] = await Promise.all([ + prisma.user.findUnique({ where: { id }, select: { credits: true } }), + prisma.usersCurrency.findMany({ where: { userId: id } }), + ]); + credits = user?.credits ?? 0; + for (const c of currencies) { + if (c.type === 0) duckets = c.amount; + if (c.type === 5) diamonds = c.amount; + } + } catch { + // DB unavailable — render the header without live balances. + } + } + + const isStaff = (session?.user?.rank ?? 0) >= 7; + + return ( +
+
+ + + {hotelName} + + + + {session?.user ? ( + <> +
+ + cr + {credits} + + + d + {duckets} + + + ◆ + {diamonds} + +
+ + {session.user.name} + + + ) : ( + + Login + + )} +
+
+ ); +} diff --git a/src/components/site-nav.tsx b/src/components/site-nav.tsx deleted file mode 100644 index 0aa7b972..00000000 --- a/src/components/site-nav.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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/env.ts b/src/env.ts index 4694dd09..fd00d38c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -8,6 +8,7 @@ const schema = z.object({ DATABASE_URL: z.string().url(), DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(40), DATABASE_IDLE_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000), + DATABASE_CONNECT_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), HOTEL_NAME: z.string().default("Atom"), // RCON link to the Arcturus emulator (raw-JSON TCP protocol). RCON_HOST: z.string().default("127.0.0.1"), diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 2bccc7da..3a562031 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -13,7 +13,8 @@ function createPrismaClient(): PrismaClient { password: decodeURIComponent(url.password), database: url.pathname.replace(/^\//, ""), connectionLimit: env.DATABASE_POOL_SIZE, - connectTimeout: 10_000, + connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, + acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, }); return new PrismaClient({ diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index 52b2ffc8..eb8cfe0e 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -1,33 +1,50 @@ import { prisma } from "@/lib/prisma"; +// Fallback values used when a setting row is missing OR the DB is unreachable, +// so the app still renders (e.g. local dev without a DATABASE_URL). +const DEFAULTS: Record = { + hotel_name: "Atom", + habbo_imaging_url: "https://www.habbo.com/habbo-imaging/avatarimage", + logo_url: "", + nitro_client_url: "", +}; + /** * DB-driven CMS config, mirroring AtomCMS's `setting()` / habbo-next's * `siteSettings`. Loads the whole website_settings table into a key→value map, - * cached in-process. Booleans are stored as the strings '1' / '0'. + * cached in-process, with graceful fallback to DEFAULTS. Booleans are stored as + * the strings '1' / '0'. */ class SiteSettings { private cache: Map | null = null; private async load(): Promise> { if (this.cache === null) { - const rows = await prisma.websiteSetting.findMany({ - select: { key: true, value: true }, - }); - this.cache = new Map(rows.map((r) => [r.key, r.value])); + try { + const rows = await prisma.websiteSetting.findMany({ + select: { key: true, value: true }, + }); + this.cache = new Map(rows.map((r) => [r.key, r.value])); + } catch { + // DB unavailable — serve defaults without caching so we retry later. + return new Map(Object.entries(DEFAULTS)); + } } return this.cache; } async get(key: string, fallback: string | null = null): Promise { const map = await this.load(); - return map.has(key) ? (map.get(key) as string) : fallback; + if (map.has(key)) return map.get(key) as string; + if (key in DEFAULTS) return DEFAULTS[key] as string; + return fallback; } async getBool(key: string, fallback = false): Promise { - const map = await this.load(); - if (!map.has(key)) return fallback; - const v = (map.get(key) as string).toLowerCase(); - return v === "1" || v === "true"; + const v = await this.get(key, null); + if (v === null) return fallback; + const s = v.toLowerCase(); + return s === "1" || s === "true"; } /** Invalidate the in-process cache after a settings write. */