latest changes
No files matched your search
@@ -1,65 +0,0 @@
|
|||||||
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
|
|
||||||
# The schema is owned by the Arcturus emulator — this app reads/writes data,
|
|
||||||
# it does NOT own or migrate the emulator tables. Format:
|
|
||||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
|
||||||
|
|
||||||
# Optional pool tuning (defaults shown)
|
|
||||||
DATABASE_POOL_SIZE=40
|
|
||||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
|
||||||
|
|
||||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
|
||||||
HOTEL_NAME=Atom
|
|
||||||
APP_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# NextAuth (>=32 chars) + Laravel APP_KEY (base64:...) for existing 2FA secrets
|
|
||||||
AUTH_SECRET=
|
|
||||||
APP_KEY=
|
|
||||||
CONVERT_PASSWORDS=false
|
|
||||||
|
|
||||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
|
||||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
|
||||||
# column). Existing accounts in either format still verify on login.
|
|
||||||
PASSWORD_HASH=bcrypt
|
|
||||||
|
|
||||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
|
||||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
|
||||||
# Leave unset to disable badge uploads.
|
|
||||||
BADGE_UPLOAD_DIR=
|
|
||||||
|
|
||||||
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
|
|
||||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
|
||||||
EMULATOR_JAR_PATH=
|
|
||||||
EMULATOR_BACKUP_DIR=
|
|
||||||
EMULATOR_BACKUP_KEEP=7
|
|
||||||
|
|
||||||
# RCON link to the Arcturus emulator
|
|
||||||
RCON_HOST=127.0.0.1
|
|
||||||
RCON_PORT=3001
|
|
||||||
|
|
||||||
# Optional OAuth (enabled when both id+secret are set)
|
|
||||||
DISCORD_CLIENT_ID=
|
|
||||||
DISCORD_CLIENT_SECRET=
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Optional SMTP (password reset / alert emails)
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
SMTP_FROM=
|
|
||||||
|
|
||||||
# Optional alerting (jobs worker / alert service)
|
|
||||||
DISCORD_WEBHOOK_URL=
|
|
||||||
ALERT_EMAIL=
|
|
||||||
|
|
||||||
# Optional AI content moderation (user comments / guestbook).
|
|
||||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
|
||||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
|
||||||
OPENAI_API_KEY=
|
|
||||||
|
|
||||||
# Optional PayPal top-up (sandbox by default)
|
|
||||||
PAYPAL_CLIENT_ID=
|
|
||||||
PAYPAL_SECRET=
|
|
||||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
|
||||||
@@ -1564,7 +1564,7 @@ model WebsiteArticles {
|
|||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
shortStory String @map("short_story") @db.VarChar(255)
|
shortStory String @map("short_story") @db.VarChar(255)
|
||||||
fullStory String @map("full_story") @db.Text
|
fullStory String @map("full_story") @db.Text
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
userId Int? @map("user_id")
|
||||||
image String @db.VarChar(255)
|
image String @db.VarChar(255)
|
||||||
createdAt DateTime? @map("created_at") @db.Timestamp(0)
|
createdAt DateTime? @map("created_at") @db.Timestamp(0)
|
||||||
updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
|
updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 235 B |
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 491 B After Width: | Height: | Size: 150 B |
|
After Width: | Height: | Size: 281 B |
|
Before Width: | Height: | Size: 485 B After Width: | Height: | Size: 116 B |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 824 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -10,7 +10,7 @@ async function uniqueSlug(title: string): Promise<string> {
|
|||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
let slug = base;
|
let slug = base;
|
||||||
let n = 2;
|
let n = 2;
|
||||||
while (await prisma.websiteArticles.findUnique({ where: { slug } })) {
|
while (await prisma.websiteArticles.findUnique({ where: { slug }, select: { id: true } })) {
|
||||||
slug = `${base}-${n++}`;
|
slug = `${base}-${n++}`;
|
||||||
}
|
}
|
||||||
return slug;
|
return slug;
|
||||||
@@ -32,7 +32,7 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
shortStory: shortStory.slice(0, 255),
|
shortStory: shortStory.slice(0, 255),
|
||||||
fullStory,
|
fullStory,
|
||||||
image: image.slice(0, 255),
|
image: image.slice(0, 255),
|
||||||
userId: String(staff.id),
|
userId: staff.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
@@ -59,6 +59,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
await requireStaff();
|
await requireStaff();
|
||||||
await prisma.websiteArticles.delete({ where: { id: BigInt(String(formData.get("id"))) } });
|
const id = BigInt(String(formData.get("id")));
|
||||||
|
await prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } });
|
||||||
|
await prisma.websiteArticleComments.deleteMany({ where: { articleId: id } });
|
||||||
|
await prisma.websiteArticles.delete({ where: { id } });
|
||||||
redirect("/admin/articles");
|
redirect("/admin/articles");
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,10 @@ export default async function EditArticle({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
|
const article = await prisma.websiteArticles.findUnique({
|
||||||
try {
|
where: { id: BigInt(id) },
|
||||||
article = await prisma.websiteArticles.findUnique({ where: { id: BigInt(id) } });
|
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true, createdAt: true, updatedAt: true },
|
||||||
} catch {
|
}).catch(() => null);
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +34,7 @@ export default async function EditArticle({
|
|||||||
</form>
|
</form>
|
||||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||||
<input type="hidden" name="id" value={String(article.id)} />
|
<input type="hidden" name="id" value={String(article.id)} />
|
||||||
<button type="submit">Delete article</button>
|
<button type="submit" className="btn btn-primary">Delete article</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
import { deleteArticle } from "@/actions/admin-articles";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminArticles() {
|
export default async function AdminArticles() {
|
||||||
// .catch(() => []) keeps the precise selected-row type while falling back to
|
|
||||||
// an empty list when the DB is unavailable (instead of a 500).
|
|
||||||
const articles = await prisma.websiteArticles
|
const articles = await prisma.websiteArticles
|
||||||
.findMany({
|
.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@@ -47,13 +46,16 @@ export default async function AdminArticles() {
|
|||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Slug</th>
|
<th>Slug</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{articles.map((a) => (
|
{articles.map((a) => (
|
||||||
<tr key={String(a.id)}>
|
<tr key={String(a.id)}>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
<Link href={`/admin/articles/${a.id}`} style={{ color: "var(--link-color)" }}>
|
||||||
|
{a.title}
|
||||||
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">
|
<td className="muted">
|
||||||
<code>{a.slug}</code>
|
<code>{a.slug}</code>
|
||||||
@@ -61,6 +63,23 @@ export default async function AdminArticles() {
|
|||||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary" style={{ fontSize: "0.8rem", padding: "0.3rem 0.6rem" }}>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<form action={deleteArticle} style={{ margin: 0 }}>
|
||||||
|
<input type="hidden" name="id" value={String(a.id)} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-danger"
|
||||||
|
style={{ fontSize: "0.8rem", padding: "0.3rem 0.6rem" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -58,20 +58,20 @@ export default async function AdminDashboard() {
|
|||||||
value={online}
|
value={online}
|
||||||
hint={dbOk ? `${users} accounts` : "DB unavailable"}
|
hint={dbOk ? `${users} accounts` : "DB unavailable"}
|
||||||
state={dbOk ? "neutral" : "danger"}
|
state={dbOk ? "neutral" : "danger"}
|
||||||
icon="\u{1F464}"
|
icon="👤"
|
||||||
/>
|
/>
|
||||||
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="\u{1F4F0}" />
|
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="📰" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Active bans"
|
label="Active bans"
|
||||||
value={dbOk ? bans : "—"}
|
value={dbOk ? bans : "—"}
|
||||||
state={bans > 0 ? "warn" : "neutral"}
|
state={bans > 0 ? "warn" : "neutral"}
|
||||||
icon="\u{1F528}"
|
icon="🔧"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Database"
|
label="Database"
|
||||||
value={dbOk ? "Connected" : "Down"}
|
value={dbOk ? "Connected" : "Down"}
|
||||||
state={dbOk ? "ok" : "danger"}
|
state={dbOk ? "ok" : "danger"}
|
||||||
icon="\u{1F5C4}️"
|
icon="🗄️"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
const [hotelName, clientUrl] = await Promise.all([
|
||||||
|
siteSettings.get("hotel_name", "Atom"),
|
||||||
|
siteSettings.get("nitro_client_url", ""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hdrs = await headers();
|
||||||
|
const ip =
|
||||||
|
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
|
||||||
|
|
||||||
|
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
|
||||||
|
|
||||||
|
return Response.json({ ticket, hotelName: hotelName ?? "Atom", clientUrl });
|
||||||
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
|
||||||
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
|
||||||
import { buildNitroClientUrl } from "@/lib/services/clients";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function FlashClientPage() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) redirect("/login");
|
|
||||||
|
|
||||||
const userId = Number(session.user.id);
|
|
||||||
// The Flash client base URL may be stored under either settings key.
|
|
||||||
const [hotelNameRaw, flashUrl, flashUrlAlt] = await Promise.all([
|
|
||||||
siteSettings.get("hotel_name", "Atom").catch(() => "Atom"),
|
|
||||||
siteSettings.get("flash_client_url", "").catch(() => ""),
|
|
||||||
siteSettings.get("client_flash_url", "").catch(() => ""),
|
|
||||||
]);
|
|
||||||
const hotelName = hotelNameRaw ?? "Atom";
|
|
||||||
|
|
||||||
const hdrs = await headers();
|
|
||||||
const ip =
|
|
||||||
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
|
|
||||||
|
|
||||||
// Faithful to AtomCMS's client controllers: issue (and persist) the SSO ticket
|
|
||||||
// on the launcher render, then hand it to the Flash client.
|
|
||||||
const ticket = await issueSsoTicket(prisma, userId, hotelName, ip);
|
|
||||||
|
|
||||||
// Use a configured Flash base URL (with the SSO ticket appended); otherwise
|
|
||||||
// fall back to the Nitro client so the launcher still works.
|
|
||||||
const flashBase = flashUrl || flashUrlAlt || "";
|
|
||||||
const flashSrc = flashBase
|
|
||||||
? `${flashBase}${flashBase.includes("?") ? "&" : "?"}sso=${encodeURIComponent(ticket)}`
|
|
||||||
: "";
|
|
||||||
const src = flashSrc || (await buildNitroClientUrl(ticket));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
|
||||||
<ContentCard
|
|
||||||
icon="🎮"
|
|
||||||
title={`Launching ${hotelName ?? "Atom"} (Flash)…`}
|
|
||||||
subtitle="Your single sign-on ticket is ready"
|
|
||||||
action={
|
|
||||||
<Link href="/client" className="btn btn-outline">
|
|
||||||
Use Nitro client
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{src ? (
|
|
||||||
<>
|
|
||||||
<ContentCard
|
|
||||||
icon="🚀"
|
|
||||||
title="Open the Flash client"
|
|
||||||
subtitle="Flash works best in its own tab"
|
|
||||||
action={
|
|
||||||
<a
|
|
||||||
href={src}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
Launch in a new tab
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ContentCard padded={false}>
|
|
||||||
<iframe
|
|
||||||
title="Flash hotel client"
|
|
||||||
src={src}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "80vh",
|
|
||||||
border: 0,
|
|
||||||
borderRadius: 10,
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ContentCard>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ContentCard
|
|
||||||
icon="🔌"
|
|
||||||
title="No Flash client configured"
|
|
||||||
subtitle="Set flash_client_url in settings"
|
|
||||||
>
|
|
||||||
<EmptyState icon="🔌">
|
|
||||||
No Flash client URL configured (set <code>flash_client_url</code> in settings). SSO
|
|
||||||
ticket issued for this session: <code>{ticket}</code>
|
|
||||||
</EmptyState>
|
|
||||||
</ContentCard>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -197,6 +197,9 @@ body {
|
|||||||
.text-shadow {
|
.text-shadow {
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
.text-shadow-sm {
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
.transition-base {
|
.transition-base {
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,10 @@ export default async function ArticlePage({
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const t = await getTranslations("pages.article");
|
const t = await getTranslations("pages.article");
|
||||||
|
|
||||||
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
|
const article = await prisma.websiteArticles.findUnique({
|
||||||
try {
|
where: { slug },
|
||||||
article = await prisma.websiteArticles.findUnique({ where: { slug } });
|
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true, createdAt: true, updatedAt: true },
|
||||||
} catch {
|
}).catch(() => null);
|
||||||
article = null;
|
|
||||||
}
|
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
const articleId = article.id;
|
const articleId = article.id;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import Script from "next/script";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { register } from "@/actions/register";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { RegisterForm } from "@/components/auth/register-form";
|
||||||
import { captchaConfig } from "@/lib/services/captcha";
|
import { captchaConfig } from "@/lib/services/captcha";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,51 +12,32 @@ export default async function RegisterPage({
|
|||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("pages.register");
|
const t = await getTranslations("pages.register");
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const captcha = await captchaConfig();
|
|
||||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
const [hotelName, imager, captcha, femaleFigures, maleFigures] = await Promise.all([
|
||||||
|
siteSettings.get("hotel_name", "Atom"),
|
||||||
|
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
|
||||||
|
captchaConfig(),
|
||||||
|
siteSettings.get("register_female_figures", "hr-100-45.hd-180-1.ch-215-62.lg-270-62.sh-290-62,hr-130-45.hd-180-2.ch-220-62.lg-280-62.sh-305-62,hr-140-45.hd-180-3.ch-225-62.lg-270-62.sh-310-62,hr-165-45.hd-180-1.ch-210-62.lg-270-62.sh-290-62,hr-190-45.hd-180-2.ch-215-62.lg-270-62.sh-305-62"),
|
||||||
|
siteSettings.get("register_male_figures", "hr-100-61.hd-180-1.ch-210-66.lg-270-110.sh-305-62,hr-105-61.hd-190-2.ch-215-66.lg-280-110.sh-310-62,hr-110-61.hd-200-3.ch-220-66.lg-290-110.sh-320-62,hr-115-61.hd-185-1.ch-210-66.lg-275-110.sh-315-62,hr-120-61.hd-180-2.ch-205-66.lg-285-110.sh-305-62"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allFigures = [
|
||||||
|
...(femaleFigures?.split(",").filter(Boolean) || []),
|
||||||
|
...(maleFigures?.split(",").filter(Boolean) || []),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
<main className="flex min-h-[calc(100vh-200px)] items-center justify-center px-4 py-8">
|
||||||
<ContentCard
|
<div className="w-full">
|
||||||
icon="🎉"
|
<h1 className="sr-only">{t("title")}</h1>
|
||||||
title={t("title")}
|
<RegisterForm
|
||||||
subtitle={t("subtitle")}
|
initialFigures={allFigures}
|
||||||
>
|
imager={imager ?? ""}
|
||||||
<form action={register} style={{ display: "grid", gap: "0.7rem" }}>
|
hotelName={hotelName ?? "Atom"}
|
||||||
<input name="username" placeholder={t("usernamePlaceholder")} autoComplete="username" required />
|
captcha={captcha}
|
||||||
<input name="mail" type="email" placeholder={t("emailPlaceholder")} autoComplete="email" required />
|
error={error}
|
||||||
<input
|
/>
|
||||||
name="password"
|
</div>
|
||||||
type="password"
|
|
||||||
placeholder={t("passwordPlaceholder")}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
|
||||||
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
|
|
||||||
) : null}
|
|
||||||
{showCaptcha && captcha.provider === "recaptcha" ? (
|
|
||||||
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
|
|
||||||
) : null}
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
{t("register")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
|
||||||
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
|
|
||||||
) : null}
|
|
||||||
{showCaptcha && captcha.provider === "recaptcha" ? (
|
|
||||||
<Script src="https://www.google.com/recaptcha/api.js" async defer />
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
|
|
||||||
{t("alreadyHaveAccount")} <Link href="/login">{t("signIn")}</Link>
|
|
||||||
</p>
|
|
||||||
</ContentCard>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { getRequestConfig } from "next-intl/server";
|
|||||||
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie (set by
|
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie (set by
|
||||||
// the language switcher) rather than a URL segment, so every existing route and
|
// the language switcher) rather than a URL segment, so every existing route and
|
||||||
// the access-guard middleware keep working unchanged. English is the fallback.
|
// the access-guard middleware keep working unchanged. English is the fallback.
|
||||||
export const SUPPORTED_LOCALES = ["en", "it"] as const;
|
export const SUPPORTED_LOCALES = ["en", "it", "nl", "de", "fr", "es"] as const;
|
||||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
export const DEFAULT_LOCALE: AppLocale = "en";
|
export const DEFAULT_LOCALE: AppLocale = "en";
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { siteSettings } from "./site-settings";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the Nitro client launch URL, faithful to AtomCMS's NitroController +
|
|
||||||
* nitro.blade.php:
|
|
||||||
*
|
|
||||||
* {nitro_path}/index.html?sso={ticket}&toolbar_primary=…&toolbar_hover=…
|
|
||||||
* &toolbar_border=…&toolbar_text=…
|
|
||||||
*
|
|
||||||
* The client files are served by the web root at `nitro_path` (e.g.
|
|
||||||
* `/Nitro-V3/dist`). Returns "" when no `nitro_path` is configured.
|
|
||||||
*/
|
|
||||||
export async function buildNitroClientUrl(ticket: string): Promise<string> {
|
|
||||||
const [nitroPathRaw, tPrimary, tHover, tBorder, tText, bPrimary, bHover, bBorder, bText] =
|
|
||||||
await Promise.all([
|
|
||||||
siteSettings.get("nitro_path", ""),
|
|
||||||
siteSettings.get("toolbar_primary_color", ""),
|
|
||||||
siteSettings.get("toolbar_hover_color", ""),
|
|
||||||
siteSettings.get("toolbar_border_color", ""),
|
|
||||||
siteSettings.get("toolbar_text_color", ""),
|
|
||||||
siteSettings.get("button_primary_color", ""),
|
|
||||||
siteSettings.get("button_hover_color", ""),
|
|
||||||
siteSettings.get("button_border_color", ""),
|
|
||||||
siteSettings.get("button_text_color", ""),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const nitroPath = (nitroPathRaw ?? "").trim().replace(/\/+$/, "");
|
|
||||||
if (!nitroPath) return "";
|
|
||||||
|
|
||||||
// Same fallbacks as the blade: toolbar_* color, else button_* color, else the
|
|
||||||
// atom defaults. URLSearchParams encodes "#" → "%23" so the query is valid.
|
|
||||||
const params = new URLSearchParams({ sso: ticket });
|
|
||||||
params.set("toolbar_primary", tPrimary || bPrimary || "#eeb425");
|
|
||||||
params.set("toolbar_hover", tHover || bHover || "#cf9d15");
|
|
||||||
params.set("toolbar_border", tBorder || bBorder || "#cf9d15");
|
|
||||||
params.set("toolbar_text", tText || bText || "#1a1a2e");
|
|
||||||
|
|
||||||
return `${nitroPath}/index.html?${params.toString()}`;
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/prisma";
|
|||||||
// so the app still renders (e.g. local dev without a DATABASE_URL).
|
// so the app still renders (e.g. local dev without a DATABASE_URL).
|
||||||
const DEFAULTS: Record<string, string> = {
|
const DEFAULTS: Record<string, string> = {
|
||||||
hotel_name: "Atom",
|
hotel_name: "Atom",
|
||||||
habbo_imaging_url: "https://www.habbo.com/habbo-imaging/avatarimage",
|
habbo_imaging_url: "https://epicnabbo.nl/imaging/",
|
||||||
logo_url: "",
|
logo_url: "",
|
||||||
nitro_path: "",
|
nitro_client_url: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -121,11 +121,17 @@
|
|||||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||||
"enterHotel": "Enter hotel",
|
"enterHotel": "Enter hotel",
|
||||||
|
"register": "REGISTER NOW",
|
||||||
|
"joinNow": "Join {hotelName} today!",
|
||||||
|
"recentUsers": "Recent Users",
|
||||||
|
"recentPhotos": "Recent Photos",
|
||||||
"latestNews": "Latest news",
|
"latestNews": "Latest news",
|
||||||
"newsTitle": "Latest news",
|
"newsTitle": "Latest news",
|
||||||
"newsSubtitle": "Fresh from the hotel",
|
"newsSubtitle": "Fresh from the hotel",
|
||||||
"allNews": "All news →",
|
"allNews": "All news →",
|
||||||
"noArticles": "No articles yet."
|
"noArticles": "No articles yet.",
|
||||||
|
"noPhotos": "No photos yet.",
|
||||||
|
"noUsers": "No users online"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "News",
|
"title": "News",
|
||||||
@@ -565,12 +571,30 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"title": "Create account",
|
"title": "Create account",
|
||||||
"subtitle": "Join the hotel — it only takes a moment.",
|
"subtitle": "Join the hotel — it only takes a moment.",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Repeat password",
|
||||||
"usernamePlaceholder": "Username",
|
"usernamePlaceholder": "Username",
|
||||||
"emailPlaceholder": "Email",
|
"emailPlaceholder": "Email",
|
||||||
"passwordPlaceholder": "Password (min 6 chars)",
|
"passwordPlaceholder": "Password (min 6 chars)",
|
||||||
|
"confirmPasswordPlaceholder": "Repeat password",
|
||||||
|
"selectOutfit": "Select your outfit",
|
||||||
|
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
|
||||||
|
"createAccount": "Create account",
|
||||||
|
"creatingAccount": "Creating account...",
|
||||||
|
"alreadyHaveAccount": "Already have an account? Login!",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
"alreadyHaveAccount": "Already have an account?",
|
"termsError": "You must accept the terms & rules to register.",
|
||||||
"signIn": "Sign in"
|
"usernameError": "Username is required.",
|
||||||
|
"emailError": "Valid email is required.",
|
||||||
|
"passwordError": "Password must be at least 6 characters.",
|
||||||
|
"confirmPasswordError": "Passwords do not match.",
|
||||||
|
"termsRequired": "You must accept the terms & rules.",
|
||||||
|
"usernameRequired": "Username is required.",
|
||||||
|
"emailRequired": "Email is required.",
|
||||||
|
"passwordRequired": "Password is required.",
|
||||||
|
"passwordsDontMatch": "Passwords do not match."
|
||||||
},
|
},
|
||||||
"forgot": {
|
"forgot": {
|
||||||
"title": "Reset password",
|
"title": "Reset password",
|
||||||
|
|||||||
@@ -121,11 +121,17 @@
|
|||||||
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
|
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
|
||||||
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
|
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
|
||||||
"enterHotel": "Entra nell'hotel",
|
"enterHotel": "Entra nell'hotel",
|
||||||
|
"register": "REGISTRATI ORA",
|
||||||
|
"joinNow": "Unisciti a {hotelName} oggi!",
|
||||||
|
"recentUsers": "Utenti recenti",
|
||||||
|
"recentPhotos": "Foto recenti",
|
||||||
"latestNews": "Ultime notizie",
|
"latestNews": "Ultime notizie",
|
||||||
"newsTitle": "Ultime notizie",
|
"newsTitle": "Ultime notizie",
|
||||||
"newsSubtitle": "Appena arrivate dall'hotel",
|
"newsSubtitle": "Appena arrivate dall'hotel",
|
||||||
"allNews": "Tutte le notizie →",
|
"allNews": "Tutte le notizie →",
|
||||||
"noArticles": "Ancora nessun articolo."
|
"noArticles": "Ancora nessun articolo.",
|
||||||
|
"noPhotos": "Ancora nessuna foto.",
|
||||||
|
"noUsers": "Nessun utente online"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "Notizie",
|
"title": "Notizie",
|
||||||
@@ -565,12 +571,30 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"title": "Crea un account",
|
"title": "Crea un account",
|
||||||
"subtitle": "Entra nell'hotel — bastano pochi istanti.",
|
"subtitle": "Entra nell'hotel — bastano pochi istanti.",
|
||||||
|
"username": "Nome utente",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Ripeti la password",
|
||||||
"usernamePlaceholder": "Nome utente",
|
"usernamePlaceholder": "Nome utente",
|
||||||
"emailPlaceholder": "Email",
|
"emailPlaceholder": "Email",
|
||||||
"passwordPlaceholder": "Password (min 6 caratteri)",
|
"passwordPlaceholder": "Password (min 6 caratteri)",
|
||||||
|
"confirmPasswordPlaceholder": "Ripeti la password",
|
||||||
|
"selectOutfit": "Scegli il tuo outfit",
|
||||||
|
"termsAccept": "Ho 18+ anni e accetto i termini e regole di {hotel}.",
|
||||||
|
"createAccount": "Crea account",
|
||||||
|
"creatingAccount": "Creazione account...",
|
||||||
|
"alreadyHaveAccount": "Hai già un account? Accedi!",
|
||||||
"register": "Registrati",
|
"register": "Registrati",
|
||||||
"alreadyHaveAccount": "Hai già un account?",
|
"termsError": "Devi accettare i termini e regole per registrarti.",
|
||||||
"signIn": "Accedi"
|
"usernameError": "Il nome utente è obbligatorio.",
|
||||||
|
"emailError": "Email valida obbligatoria.",
|
||||||
|
"passwordError": "La password deve essere di almeno 6 caratteri.",
|
||||||
|
"confirmPasswordError": "Le password non coincidono.",
|
||||||
|
"termsRequired": "Devi accettare i termini e le regole.",
|
||||||
|
"usernameRequired": "Il nome utente è obbligatorio.",
|
||||||
|
"emailRequired": "Email è obbligatoria.",
|
||||||
|
"passwordRequired": "La password è obbligatoria.",
|
||||||
|
"passwordsDontMatch": "Le password non coincidono."
|
||||||
},
|
},
|
||||||
"forgot": {
|
"forgot": {
|
||||||
"title": "Reimposta password",
|
"title": "Reimposta password",
|
||||||
|
|||||||