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}
+
);
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
-
- {error && {error}
}
+
+
+
Sign in
+
+ Welcome back — log in to enter the hotel.
+
+
+ {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 (
+
+ );
+}
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 (
+
+ );
+}
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