Add atom-theme visual design (header/footer, theme system, restyled pages)
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.
This commit is contained in:
1 parent
5f8b465451
commit
9a79acebe7
10 files changed
+490
-109
No files matched your search
+290
-34
@@ -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;
|
||||
}
|
||||
+9
-3
@@ -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 (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<SiteNav />
|
||||
<div className="container">{children}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
|
||||
<SiteHeader />
|
||||
<div className="container" style={{ flex: 1, width: "100%" }}>
|
||||
{children}
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+28
-21
@@ -23,27 +23,34 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 360 }}>
|
||||
<h1>Login</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<main style={{ maxWidth: 400, margin: "2rem auto" }}>
|
||||
<div className="card" style={{ padding: "1.75rem" }}>
|
||||
<h1 style={{ marginTop: 0, textAlign: "center" }}>Sign in</h1>
|
||||
<p className="muted" style={{ textAlign: "center", marginTop: 0 }}>
|
||||
Welcome back — log in to enter the hotel.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem", marginTop: "1rem" }}>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={pending}>
|
||||
{pending ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
{error ? (
|
||||
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+27
-10
@@ -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 (
|
||||
<main>
|
||||
<h1>Welcome to {hotelName ?? "Atom"}</h1>
|
||||
<p className="muted">AtomCMS → Next.js. Reading live data through the converted Prisma schema.</p>
|
||||
<section className="hero">
|
||||
<h1 style={{ fontSize: 36, marginTop: 0 }}>Welcome to {hotelName}</h1>
|
||||
<p className="muted" style={{ fontSize: 16 }}>
|
||||
Your retro hotel, now powered by Next.js. Hang out, collect furni, and meet friends.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "0.6rem", marginTop: "0.5rem" }}>
|
||||
<Link className="btn btn-primary" href="/client">
|
||||
Enter hotel
|
||||
</Link>
|
||||
<Link className="btn btn-outline" href="/news">
|
||||
Latest news
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2>Latest news</h2>
|
||||
{articles.length === 0 ? (
|
||||
@@ -25,9 +42,9 @@ export default async function HomePage() {
|
||||
) : (
|
||||
<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" }}>
|
||||
<article key={a.slug} className="card hover article">
|
||||
{a.image ? <img className="article-img" src={a.image} alt="" /> : <div className="article-img" />}
|
||||
<div className="body">
|
||||
<h3 style={{ margin: "0 0 0.3rem" }}>
|
||||
<Link href={`/news/${a.slug}`}>{a.title}</Link>
|
||||
</h3>
|
||||
|
||||
@@ -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 (
|
||||
<footer className="site-footer">
|
||||
<div className="inner">
|
||||
<span>
|
||||
© {year} {hotelName}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">AtomCMS → Next.js conversion</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<header className="site-header">
|
||||
<div className="bar">
|
||||
<Link href="/" className="brand">
|
||||
<span className="dot" />
|
||||
{hotelName}
|
||||
</Link>
|
||||
<nav className="nav">
|
||||
<Link className="nav-item" href="/">
|
||||
Home
|
||||
</Link>
|
||||
<Link className="nav-item" href="/news">
|
||||
News
|
||||
</Link>
|
||||
{session?.user ? (
|
||||
<Link className="nav-item" href="/client">
|
||||
Hotel
|
||||
</Link>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link className="nav-item" href="/admin">
|
||||
Admin
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
<span className="spacer" />
|
||||
{session?.user ? (
|
||||
<>
|
||||
<div className="currencies">
|
||||
<span className="currency">
|
||||
<span className="coin credits">cr</span>
|
||||
{credits}
|
||||
</span>
|
||||
<span className="currency">
|
||||
<span className="coin duckets">d</span>
|
||||
{duckets}
|
||||
</span>
|
||||
<span className="currency">
|
||||
<span className="coin diamonds">◆</span>
|
||||
{diamonds}
|
||||
</span>
|
||||
</div>
|
||||
<Link className="nav-item" href={`/u/${session.user.name}`}>
|
||||
{session.user.name}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Link className="btn btn-outline" href="/login">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
+2
-1
@@ -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({
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> | null = null;
|
||||
|
||||
private async load(): Promise<Map<string, string>> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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. */
|
||||
|
||||
Reference in new issue
Block a user