latest changes

This commit is contained in:
remco committed 2026-07-01 15:06:52 +02:00
1 parent 56cd6fd058
commit 1de72863db
40 files changed
+3837 -568

No files matched your search

-65
View File
@@ -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
+1 -1
View File
@@ -1564,7 +1564,7 @@ model WebsiteArticles {
title String @db.VarChar(255)
shortStory String @map("short_story") @db.VarChar(255)
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)
createdAt DateTime? @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+6 -3
View File
@@ -10,7 +10,7 @@ async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
let slug = base;
let n = 2;
while (await prisma.websiteArticles.findUnique({ where: { slug } })) {
while (await prisma.websiteArticles.findUnique({ where: { slug }, select: { id: true } })) {
slug = `${base}-${n++}`;
}
return slug;
@@ -32,7 +32,7 @@ export async function createArticle(formData: FormData): Promise<void> {
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: String(staff.id),
userId: staff.id,
createdAt: now,
updatedAt: now,
},
@@ -59,6 +59,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
export async function deleteArticle(formData: FormData): Promise<void> {
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");
}
+5 -7
View File
@@ -12,12 +12,10 @@ export default async function EditArticle({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
try {
article = await prisma.websiteArticles.findUnique({ where: { id: BigInt(id) } });
} catch {
notFound();
}
const 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(() => null);
if (!article) notFound();
return (
@@ -36,7 +34,7 @@ export default async function EditArticle({
</form>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
<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>
</main>
);
+22 -3
View File
@@ -1,12 +1,11 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { deleteArticle } from "@/actions/admin-articles";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
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
.findMany({
orderBy: { createdAt: "desc" },
@@ -47,13 +46,16 @@ export default async function AdminArticles() {
<th>Title</th>
<th>Slug</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={String(a.id)}>
<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 className="muted">
<code>{a.slug}</code>
@@ -61,6 +63,23 @@ export default async function AdminArticles() {
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
</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>
))}
</tbody>
+4 -4
View File
@@ -58,20 +58,20 @@ export default async function AdminDashboard() {
value={online}
hint={dbOk ? `${users} accounts` : "DB unavailable"}
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
label="Active bans"
value={dbOk ? bans : "—"}
state={bans > 0 ? "warn" : "neutral"}
icon="\u{1F528}"
icon="🔧"
/>
<StatusCard
label="Database"
value={dbOk ? "Connected" : "Down"}
state={dbOk ? "ok" : "danger"}
icon="\u{1F5C4}️"
icon="🗄️"
/>
</div>
+28
View File
@@ -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 });
}
-100
View File
@@ -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>
);
}
+100 -50
View File
@@ -1,61 +1,111 @@
"use client";
import { useEffect, useState } from "react";
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 function ClientPage() {
const [ticket, setTicket] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hotelName, setHotelName] = useState("Hubbly");
const [launched, setLaunched] = useState(false);
export default async function ClientPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
useEffect(() => {
async function fetchTicket() {
try {
const res = await fetch("/api/client/sso");
if (!res.ok) throw new Error("Failed to get SSO ticket");
const data = await res.json();
setTicket(data.ticket);
setHotelName(data.hotelName);
} catch (e) {
setError("Could not generate login ticket");
} finally {
setLoading(false);
}
}
fetchTicket();
}, []);
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
useEffect(() => {
if (ticket && !launched) {
setLaunched(true);
const clientUrl = `/nitro-client/?sso=${encodeURIComponent(ticket)}`;
const fullUrl = `${window.location.origin}${clientUrl}`;
window.open(fullUrl, "_blank", "noopener,noreferrer");
}
}, [ticket, launched]);
const hdrs = await headers();
const ip =
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
if (loading) {
return (
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
<header
className="flex h-14 items-center justify-between border-b px-4"
style={{ backgroundColor: "var(--color-navbar)", borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }}
>
<div className="flex items-center gap-4">
<span className="font-bold text-lg">{hotelName}</span>
<span className="text-sm text-gray-500">Loading client…</span>
</div>
</header>
<main className="flex-1 flex items-center justify-center">
<ContentCard icon="⏳" title="Loading" subtitle="Generating SSO ticket…" />
</main>
</div>
);
}
// Faithful to AtomCMS's NitroController: issue (and persist) the SSO ticket on
// the launcher render, then hand it to the Nitro client at {nitro_path}.
const ticket = await issueSsoTicket(prisma, userId, hotelName, ip);
const src = await buildNitroClientUrl(ticket);
if (error || !ticket) {
return (
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
<header
className="flex h-14 items-center justify-between border-b px-4"
style={{ backgroundColor: "var(--color-navbar)", borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }}
>
<div className="flex items-center gap-4">
<span className="font-bold text-lg">{hotelName}</span>
</div>
</header>
<main className="flex-1 flex items-center justify-center">
<ContentCard icon="⚠" title="Error" subtitle={error ?? "No SSO ticket"}>
<EmptyState icon="⚠">{error ?? "Could not generate SSO ticket"}</EmptyState>
</ContentCard>
</main>
</div>
);
}
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🏨"
title={`Launching ${hotelName}…`}
subtitle="Your single sign-on ticket is ready"
action={
<Link href="/client/flash" className="btn btn-outline">
Use Flash client
</Link>
}
/>
{src ? (
<ContentCard padded={false}>
<iframe
title="Hotel client"
src={src}
style={{ width: "100%", height: "85vh", border: 0, borderRadius: 10, display: "block" }}
/>
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
<header
className="flex h-14 items-center justify-between border-b px-4"
style={{
backgroundColor: "var(--color-navbar)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
color: "var(--color-navbar-text)",
}}
>
<div className="flex items-center gap-4">
<span className="font-bold text-lg">{hotelName}</span>
<span className="text-sm" style={{ color: "var(--color-navbar-text)" }}>Connected</span>
</div>
</header>
<main className="flex-1 flex items-center justify-center" style={{ backgroundColor: "var(--color-background)" }}>
<ContentCard>
<div style={{ textAlign: "center" }}>
<h1 style={{ fontSize: "1.75rem", fontWeight: 700, marginBottom: "0.5rem" }}>
Welcome to {hotelName}
</h1>
<p style={{ color: "var(--color-text-muted)", marginBottom: "2rem" }}>
Opening the client in a new tab… If it doesn't open,{" "}
<a href={`/nitro-client/?sso=${encodeURIComponent(ticket)}`} target="_blank" rel="noopener noreferrer">
click here
</a>.
</p>
</div>
</ContentCard>
) : (
<ContentCard icon="🔌" title="No client configured" subtitle="Set nitro_path in settings">
<EmptyState icon="🔌">
No Nitro client configured — set <code>nitro_path</code> (e.g.{" "}
<code>/Nitro-V3/dist</code>) in the admin settings. SSO ticket issued for this session:{" "}
<code>{ticket}</code>
</EmptyState>
</ContentCard>
)}
</main>
</main>
</div>
);
}
}
+3
View File
@@ -197,6 +197,9 @@ body {
.text-shadow {
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: all 0.2s ease-in-out;
}
+4 -6
View File
@@ -45,12 +45,10 @@ export default async function ArticlePage({
const { slug } = await params;
const t = await getTranslations("pages.article");
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
try {
article = await prisma.websiteArticles.findUnique({ where: { slug } });
} catch {
article = null;
}
const 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(() => null);
if (!article) notFound();
const articleId = article.id;
+310 -70
View File
@@ -1,91 +1,331 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { ArticleSlider, type SliderArticle } from "@/components/public/article-slider";
import { excerpt } from "@/lib/format";
import { auth } from "@/lib/auth";
import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { sanitize } from "@/lib/sanitize";
import { siteSettings } from "@/lib/services/site-settings";
import { HomeLoginForm } from "@/components/auth/home-login-form";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const t = await getTranslations("pages.home");
const session = await auth();
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
const imager =
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
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.
if (!session?.user) {
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = [];
try {
articles = await prisma.websiteArticles.findMany({
orderBy: { createdAt: "desc" },
take: 8,
select: { slug: true, title: true, shortStory: true, image: true },
});
} catch {}
let recentUsers: Array<{ username: string; look: string }> = [];
try {
recentUsers = await prisma.user.findMany({
orderBy: { lastOnline: "desc" },
take: 12,
select: { username: true, look: true },
});
} catch {}
return (
<div className="flex flex-col gap-4 md:flex-row">
{/* LEFT COLUMN — 1/3 */}
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
{/* Login Card */}
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<div
className="relative h-[120px] overflow-hidden bg-center md:h-[150px]"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "auto",
backgroundRepeat: "no-repeat",
backgroundPosition: "calc(30% - 10px) calc(50% - 35px)",
}}
>
<div className="absolute inset-0 z-10 bg-black/30" />
</div>
<div className="bg-gray-100 p-3 dark:bg-gray-800">
<HomeLoginForm />
</div>
</div>
{/* Register Button */}
<a href="/register" className="relative block">
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<img src="/assets/images/EnterHubbly.png" alt="Register" className="block" />
</div>
<span
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white"
style={{ textShadow: "1px 1px 2px rgba(0,0,0,0.8)", marginLeft: "15px" }}
>
REGISTER NOW
</span>
</a>
{/* Recent Users */}
{recentUsers.length > 0 && (
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<div
className="flex h-12 w-full items-center justify-center bg-repeat"
style={{
background: "linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
}}
>
<h2 className="px-3 text-xl font-bold text-white">Recent Users</h2>
</div>
<div className="grid grid-cols-6 gap-4 p-4">
{recentUsers.map((u) => (
<div
key={u.username}
className="relative overflow-visible rounded-md bg-white p-0 shadow-lg dark:bg-gray-800"
style={{ width: "50px", height: "50px", justifySelf: "center" }}
>
<img
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
alt={u.username}
loading="lazy"
decoding="async"
style={{
position: "absolute",
top: "-12px",
left: "50%",
transform: "translateX(-50%)",
width: "auto",
height: "auto",
maxWidth: "none",
maxHeight: "none",
}}
/>
</div>
))}
</div>
</div>
)}
</div>
{/* RIGHT COLUMN — 2/3 */}
<div className="flex w-full flex-col gap-4 md:w-2/3">
{/* News */}
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
<div
className="flex h-12 w-full items-center justify-center"
style={{
background: "linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-xl font-bold text-white">Latest News</h2>
</div>
<div className="p-4">
{articles.length === 0 ? (
<p className="py-8 text-center text-gray-500 dark:text-gray-400">No articles available</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{articles.map((a) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
style={{
height: "215px",
backgroundColor: "var(--color-surface)",
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-lg">
<img
src={a.image}
alt={a.title}
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
loading="lazy"
/>
<div
className="absolute left-0 w-full p-2"
style={{ bottom: "-2px", background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)" }}
>
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">{a.title}</h3>
{a.shortStory && (
<p className="truncate text-xs font-normal text-white text-shadow-sm">
{excerpt(a.shortStory, 80)}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
// Pre-shape for the client slider: compute excerpts server-side so the client
// component carries no prisma/i18n dependency.
const slides: SliderArticle[] = articles.map((a) => ({
slug: a.slug,
title: a.title,
image: a.image,
excerpt: excerpt(a.shortStory, 110),
}));
const dbUser = await prisma.user.findUnique({
where: { id: Number(session.user.id) },
select: { username: true, look: true },
}).catch(() => null);
if (!dbUser) {
return <p className="py-8 text-center text-gray-500">User not found.</p>;
}
// Staff-authored content boxes (managed at /admin/writeable-boxes).
const boxes = await prisma.websiteWriteableBoxes
.findMany({
where: { isActive: true },
orderBy: [{ position: "asc" }, { id: "asc" }],
select: { id: true, title: true, icon: true, content: true },
})
.catch(() => []);
const userId = Number(session.user.id);
let onlineFriends: Array<{ username: string; look: string }> = [];
try {
const friendships = await prisma.messengerFriendships.findMany({
where: {
OR: [
{ userOneId: userId },
{ userTwoId: userId },
],
},
select: { userOneId: true, userTwoId: true },
});
const friendIds = friendships.map((f) =>
f.userOneId === userId ? f.userTwoId : f.userOneId,
);
if (friendIds.length > 0) {
onlineFriends = await prisma.user.findMany({
where: { id: { in: friendIds }, online: "1" },
take: 10,
select: { username: true, look: true },
});
}
} catch {}
const [latestArticle] = await prisma.websiteArticles.findMany({
orderBy: { createdAt: "desc" },
take: 1,
select: { slug: true, title: true, shortStory: true, image: true },
}).catch(() => []);
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🏨"
title={t("welcome", { hotelName })}
subtitle={t("welcomeSubtitle")}
>
<p className="muted" style={{ margin: "0 0 0.9rem" }}>
{t("welcomeBody")}
</p>
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
<Link className="btn btn-primary" href="/client">
{t("enterHotel")}
</Link>
<Link className="btn btn-outline" href="/news">
{t("latestNews")}
</Link>
<div className="flex flex-col gap-6 md:flex-row">
{/* Left column — 9/12 */}
<div className="flex w-full flex-col gap-6 md:w-3/4">
{/* User Hero */}
<div
className="relative flex h-[180px] items-center overflow-hidden rounded-lg"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "auto",
backgroundRepeat: "no-repeat",
backgroundPosition: "calc(30% - 10px) calc(50% - 35px)",
}}
>
<div className="absolute inset-0 z-10 bg-black/30" />
<div className="relative z-20 flex w-full items-center justify-between px-4">
<Link href={`/u/${dbUser.username}`}>
<img
src={avatarImageUrl(imager, dbUser.look, { size: "l", direction: 2 })}
alt={dbUser.username}
className="relative transition-all duration-300 hover:scale-105 drop-shadow"
style={{ imageRendering: "pixelated", marginTop: "30px" }}
/>
</Link>
<div className="flex items-center">
<Link href="/client">
<button
type="button"
className="rounded-full bg-white/90 px-6 py-2 text-lg font-semibold text-black transition duration-300 ease-in-out hover:bg-white dark:bg-gray-900 dark:text-white"
>
Go to {hotelName}
</button>
</Link>
</div>
</div>
</div>
</ContentCard>
<ContentCard
icon="📰"
title={t("newsTitle")}
subtitle={t("newsSubtitle")}
action={
<Link className="btn btn-outline" href="/news">
{t("allNews")}
</Link>
}
>
{slides.length === 0 ? (
<EmptyState icon="📰">{t("noArticles")}</EmptyState>
) : (
<ArticleSlider articles={slides} />
)}
</ContentCard>
{/* Online Friends */}
<div
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
style={{ backgroundColor: "#e9b124" }}
>
<span className="relative text-white">Online Friends</span>
</div>
<div className="flex items-center gap-3 px-3">
{onlineFriends.length === 0 ? (
<p className="mb-3 block w-full text-center text-xs font-medium text-gray-500 dark:text-white md:mb-0 md:text-left">
No friends online
</p>
) : (
onlineFriends.map((f) => (
<Link
key={f.username}
href={`/u/${f.username}`}
className="transition-all duration-200 hover:scale-110"
>
<img
src={avatarImageUrl(imager, f.look, { headOnly: true })}
alt={f.username}
className="h-10 w-10"
loading="lazy"
title={f.username}
/>
</Link>
))
)}
</div>
</div>
</div>
{boxes.map((b) => (
<ContentCard key={String(b.id)} icon={b.icon ?? "📌"} title={b.title}>
{/* Staff-authored HTML, sanitised server-side before injection. */}
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: sanitised writeable box */}
<div dangerouslySetInnerHTML={{ __html: sanitize(b.content) }} />
</ContentCard>
))}
</main>
{/* Right column — 3/12 */}
<div className="flex w-full flex-col gap-6 md:w-1/4">
{latestArticle ? (
<div
className="overflow-hidden rounded-lg border"
style={{
backgroundColor: "var(--color-surface)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="flex h-12 items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-lg font-bold text-white">Latest Article</h2>
</div>
<Link
href={`/news/${latestArticle.slug}`}
className="group block"
>
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={latestArticle.image}
alt={latestArticle.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-3">
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
{latestArticle.shortStory && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
</div>
</Link>
</div>
) : null}
</div>
</div>
);
}
+27 -48
View File
@@ -1,8 +1,6 @@
import Script from "next/script";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { register } from "@/actions/register";
import { ContentCard } from "@/components/public/ui";
import { siteSettings } from "@/lib/services/site-settings";
import { RegisterForm } from "@/components/auth/register-form";
import { captchaConfig } from "@/lib/services/captcha";
export const dynamic = "force-dynamic";
@@ -14,51 +12,32 @@ export default async function RegisterPage({
}) {
const t = await getTranslations("pages.register");
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 (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard
icon="🎉"
title={t("title")}
subtitle={t("subtitle")}
>
<form action={register} style={{ display: "grid", gap: "0.7rem" }}>
<input name="username" placeholder={t("usernamePlaceholder")} autoComplete="username" required />
<input name="mail" type="email" placeholder={t("emailPlaceholder")} autoComplete="email" required />
<input
name="password"
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 className="flex min-h-[calc(100vh-200px)] items-center justify-center px-4 py-8">
<div className="w-full">
<h1 className="sr-only">{t("title")}</h1>
<RegisterForm
initialFigures={allFigures}
imager={imager ?? ""}
hotelName={hotelName ?? "Atom"}
captcha={captcha}
error={error}
/>
</div>
</main>
);
}
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { avatarImageUrl } from "@/lib/format";
interface AvatarCarouselProps {
figures: string[];
imager: string;
onSelect: (figure: string) => void;
initialIndex?: number;
}
export function AvatarCarousel({
figures,
imager,
onSelect,
initialIndex = 0,
}: AvatarCarouselProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [direction, setDirection] = useState(0);
const isAnimating = useRef(false);
const prevIndexRef = useRef(initialIndex);
const navigate = useCallback((dir: number) => {
if (isAnimating.current || figures.length <= 1) return;
isAnimating.current = true;
setDirection(dir);
prevIndexRef.current = currentIndex;
setCurrentIndex((prev) => (prev + dir + figures.length) % figures.length);
setTimeout(() => { isAnimating.current = false; }, 300);
}, [currentIndex, figures.length]);
const goToIndex = useCallback((index: number) => {
if (isAnimating.current) return;
isAnimating.current = true;
const dir = index > currentIndex ? 1 : -1;
setDirection(dir);
prevIndexRef.current = currentIndex;
setCurrentIndex(index);
setTimeout(() => { isAnimating.current = false; }, 300);
}, [currentIndex]);
return (
<div className="relative w-full max-w-sm mx-auto" role="region" aria-label="Avatar selection">
<div className="relative overflow-hidden rounded-2xl bg-white p-4 shadow-xl dark:bg-gray-800">
<div className="transition-transform duration-300 ease-out" style={{ transform: `translateX(${direction * 100}%)` }}>
{figures.map((figure, idx) => (
<div
key={figure}
className="relative w-full h-48 flex items-center justify-center"
style={{
position: "absolute",
left: `${idx * 100}%`,
top: 0,
transition: "transform 300ms ease-out",
transform: `translateX(${(currentIndex - prevIndexRef.current) * -100}%)`,
}}
>
<div className="relative w-48 h-64">
<img
src={avatarImageUrl(imager, figure, { headOnly: false, direction: 2 })}
alt={`Avatar ${idx + 1}`}
className="w-full h-full object-contain"
loading="lazy"
/>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-center gap-3 mt-4">
<button
type="button"
onClick={() => navigate(-1)}
disabled={isAnimating.current || figures.length <= 1}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/80 dark:bg-gray-800/80 text-gray-700 dark:text-gray-300 shadow-md hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Previous avatar"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center gap-2">
{figures.map((_, idx) => (
<button
key={idx}
type="button"
onClick={() => goToIndex(idx)}
disabled={isAnimating.current}
className={`w-2.5 h-2.5 rounded-full transition-all ${
idx === currentIndex
? "bg-[#eeb425] scale-125"
: "bg-gray-400 hover:bg-gray-500 dark:bg-gray-600 dark:hover:bg-gray-500"
}`}
aria-label={`Select avatar ${idx + 1}`}
/>
))}
</div>
<button
type="button"
onClick={() => navigate(1)}
disabled={isAnimating.current || figures.length <= 1}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/80 dark:bg-gray-800/80 text-gray-700 dark:text-gray-300 shadow-md hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Next avatar"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { signIn } from "next-auth/react";
import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
export function HomeLoginForm() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [needs2fa, setNeeds2fa] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
try {
if (!needs2fa) {
const pre = await precheckLogin(username, password);
if (pre === "invalid") {
setError("Invalid username or password");
return;
}
if (pre === "twofactor") {
setNeeds2fa(true);
return;
}
}
const res = await signIn("credentials", {
username,
password,
code,
redirect: false,
});
if (!res || res.error) {
setError(needs2fa ? "Invalid 2FA code" : "Invalid username or password");
return;
}
window.location.href = "/";
} finally {
setPending(false);
}
}
return (
<form onSubmit={onSubmit} className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800">
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
{needs2fa ? (
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">2FA Code</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter your 2FA code"
inputMode="numeric"
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
autoFocus
/>
</fieldset>
) : null}
{error ? (
<p className="m-0 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
) : null}
<button
type="submit"
disabled={pending}
className="w-full cursor-pointer rounded-md border-2 p-2 text-base font-semibold text-white transition-colors disabled:opacity-50"
style={{
backgroundColor: "#27a44d",
borderColor: "#34d399",
}}
>
{pending ? "Please wait..." : needs2fa ? "Verify" : "Login"}
</button>
</form>
);
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import { useState, FormEvent, ChangeEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { AvatarCarousel } from "@/components/auth/avatar-carousel";
interface RegisterFormProps {
initialFigures: string[];
imager: string;
hotelName: string;
captcha: { provider: string; siteKey?: string };
error?: string;
}
export function RegisterForm({
initialFigures,
imager,
hotelName,
captcha,
error,
}: RegisterFormProps) {
const t = useTranslations("pages.register");
const router = useRouter();
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [selectedFigure, setSelectedFigure] = useState(initialFigures[0] || "");
const [formData, setFormData] = useState({
username: "",
mail: "",
password: "",
password_confirmation: "",
terms: false,
beta_code: "",
referral_code: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({ ...prev, [name]: type === "checkbox" ? checked : value }));
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) newErrors.username = t("usernameError");
if (!formData.mail.trim()) newErrors.mail = t("emailError");
if (!formData.password) newErrors.password = t("passwordError");
if (formData.password !== formData.password_confirmation) {
newErrors.password_confirmation = t("confirmPasswordError");
}
if (!formData.terms) newErrors.terms = t("termsError");
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
setIsSubmitting(false);
return;
}
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
look: selectedFigure,
}),
});
const data = await res.json();
if (!res.ok) {
setErrors({ form: data.message || "Registration failed" });
setIsSubmitting(false);
return;
}
router.push("/login?registered=1");
} catch {
setErrors({ form: "An error occurred. Please try again." });
setIsSubmitting(false);
}
};
return (
<div className="mx-auto w-full max-w-[800px]">
{/* Header Banner */}
<div
className="relative overflow-hidden bg-center bg-cover rounded-t-lg"
style={{
backgroundImage: "url(/assets/images/mybobba_banner.png)",
height: "180px",
maxHeight: "150px",
}}
>
<div className="absolute inset-0 z-10 bg-black/30" />
<div className="relative flex items-center justify-center w-full h-full z-20">
<div className="relative" style={{ width: "140px", height: "140px" }}>
<div className="absolute inset-0 rounded-full overflow-hidden" style={{ zIndex: -1 }}>
<div className="w-full h-full" style={{ background: "black", filter: "blur(8px)", transform: "scale(1.2)", opacity: 0.6 }} />
</div>
<img src="/assets/images/FrankwithBag.gif" className="w-full h-full" style={{ objectFit: "contain", objectPosition: "center" }} />
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-6" style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
borderRadius: "0 0 8px 8px",
}}>
{error && (
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: "#ef4444", color: "white" }}>
{error}
</div>
)}
{/* Username & Email */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="username" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("username")}
</label>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.username && <p className="text-xs text-red-500">{errors.username}</p>}
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="mail" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("email")}
</label>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.mail && <p className="text-xs text-red-500">{errors.mail}</p>}
</fieldset>
</div>
</div>
{/* Password & Confirm */}
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="password" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("password")}
</label>
<input
id="password"
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.password && <p className="text-xs text-red-500">{errors.password}</p>}
</fieldset>
</div>
<div className="w-full md:w-1/2">
<fieldset className="flex flex-col w-full gap-1">
<label htmlFor="password_confirmation" className="block font-semibold text-sm" style={{ color: "var(--color-text)" }}>
{t("confirmPassword")}
</label>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
className="focus:ring-0 border-4 rounded w-full text-sm px-3 py-2 mt-1"
style={{
backgroundColor: "var(--color-background)",
color: "var(--color-text)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.password_confirmation && <p className="text-xs text-red-500">{errors.password_confirmation}</p>}
</fieldset>
</div>
</div>
{/* Avatar Selection */}
<div className="w-full rounded-md p-4" style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}>
<legend className="text-sm font-semibold mb-3" style={{ color: "var(--color-text)" }}>
{t("selectOutfit")}
</legend>
<AvatarCarousel
figures={initialFigures}
imager={imager}
onSelect={setSelectedFigure}
/>
</div>
{/* Terms */}
<div className="rounded-md p-3 flex flex-col gap-3" style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
}}>
<fieldset className="flex flex-wrap items-center w-full gap-3 text-sm">
<input
id="terms"
name="terms"
type="checkbox"
className="w-4 h-4 border-gray-300 rounded focus:ring-0"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)" }}
required
onChange={handleChange}
/>
<label htmlFor="terms" className="font-semibold" style={{ color: "var(--color-text)" }}>
<a href="/help-center/rules" target="_blank" rel="noopener noreferrer" className="hover:underline" style={{ color: "var(--color-text)" }}>
{t("termsAccept", { hotel: hotelName })}
</a>
</label>
</fieldset>
{errors.terms && <p className="text-xs text-red-500 italic">{errors.terms}</p>}
</div>
{/* Captcha */}
{showCaptcha && captcha.provider === "turnstile" && (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
)}
{showCaptcha && captcha.provider === "recaptcha" && (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
)}
{/* Submit */}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded p-2 text-white font-semibold transition-all duration-200 text-base"
style={{
backgroundColor: "#27a44d",
border: "2px solid #34c661",
}}
>
{isSubmitting ? t("creatingAccount") : t("createAccount")}
</button>
{/* Social Login - optional */}
{false && (
<div className="pt-4 border-t" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 15%, transparent)" }}>
<p className="text-center text-sm mb-3" style={{ color: "var(--color-text-muted)" }}>
Or register with
</p>
<div className="flex flex-col gap-2">
{/* Google, Discord, GitHub buttons would go here */}
</div>
</div>
)}
<div className="text-center">
<Link href="/login" className="text-sm font-semibold hover:underline" style={{ color: "var(--color-primary)" }}>
{t("alreadyHaveAccount")}
</Link>
</div>
</form>
</div>
);
}
+73 -27
View File
@@ -2,44 +2,90 @@
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useTransition, useState, useRef, useEffect } from "react";
const LOCALES: { code: string; label: string }[] = [
{ code: "en", label: "EN" },
{ code: "it", label: "IT" },
const LOCALES: { code: string; label: string; lang: string }[] = [
{ code: "nl", label: "Nederlands", lang: "nl" },
{ code: "en", label: "English", lang: "en" },
{ code: "de", label: "Deutsch", lang: "de" },
{ code: "fr", label: "Français", lang: "fr" },
{ code: "es", label: "Español", lang: "es" },
{ code: "it", label: "Italiano", lang: "it" },
];
/**
* Locale picker. Writes the chosen locale to the `NEXT_LOCALE` cookie (read by
* src/i18n/request.ts on the server) and refreshes the route so server
* components re-render with the new messages. No URL change — matches the
* cookie-based, routing-free i18n setup.
*/
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = LOCALES.find((l) => l.code === locale) ?? LOCALES[0];
function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
const next = e.target.value;
document.cookie = `NEXT_LOCALE=${next};path=/;max-age=31536000;samesite=lax`;
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function switchLocale(code: string) {
if (code === locale) return;
document.cookie = `NEXT_LOCALE=${code};path=/;max-age=31536000;samesite=lax`;
startTransition(() => router.refresh());
setOpen(false);
}
return (
<select
value={locale}
onChange={onChange}
disabled={pending}
aria-label="Language"
className="nav-item cursor-pointer bg-transparent text-[13px]"
style={{ border: "none", padding: "0 0.25rem" }}
>
{LOCALES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
disabled={pending}
className="nav-item flex items-center gap-1.5 bg-transparent text-[13px]"
style={{ border: "none", cursor: "pointer", padding: "0 0.25rem" }}
>
<img
src={`/assets/images/icons/flags/${current.code}.png`}
alt={current.lang}
className="inline-block h-4 w-auto"
/>
<span>{current.code.toUpperCase()}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div
className="absolute right-0 mt-1 min-w-[160px] rounded-md shadow-lg z-50 py-1"
style={{ backgroundColor: "var(--color-dropdown)" }}
>
{LOCALES.map((l) => (
<button
key={l.code}
type="button"
onClick={() => switchLocale(l.code)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm font-semibold transition-colors ${
l.code === locale ? "opacity-100" : "opacity-70 hover:opacity-100"
}`}
style={{
color: "var(--color-text)",
background: "none",
border: "none",
cursor: "pointer",
}}
>
<img
src={`/assets/images/icons/flags/${l.code}.png`}
alt={l.lang}
className="inline-block h-4 w-auto"
/>
{l.label}
</button>
))}
</div>
)}
</div>
);
}
+39 -55
View File
@@ -11,98 +11,82 @@ export async function Navigation() {
return (
<nav className="relative shadow-sm" style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="max-w-7xl min-h-[60px] px-4 flex items-center gap-x-6 md:gap-x-8 mx-auto flex-wrap">
<div className="mx-auto flex min-h-[60px] max-w-7xl flex-wrap items-center px-4 gap-x-1 md:gap-x-2">
<Link href="/" className="nav-item">
<i className="navigation-icon home mr-1 hidden lg:inline-flex" />
<img src="/assets/images/icons/navigation/me.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
{t("home")}
</Link>
<details className="relative">
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
<i className="navigation-icon community mr-1 hidden lg:inline-flex" />
<img src="/assets/images/icons/navigation/community.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
{t("community")}
</summary>
<div
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
style={{ backgroundColor: "var(--color-dropdown)" }}
>
<Link href="/news" className="dropdown-item">
{t("articles")}
</Link>
<Link href="/photos" className="dropdown-item">
{t("photos")}
</Link>
<Link href="/staff" className="dropdown-item">
{t("staff")}
</Link>
<Link href="/rankings" className="dropdown-item">
{t("rankings")}
</Link>
<Link href="/guilds" className="dropdown-item">
{t("guilds")}
</Link>
<Link href="/news" className="dropdown-item">{t("articles")}</Link>
<Link href="/photos" className="dropdown-item">{t("photos")}</Link>
<Link href="/staff" className="dropdown-item">{t("staff")}</Link>
<Link href="/rankings" className="dropdown-item">{t("rankings")}</Link>
<Link href="/guilds" className="dropdown-item">{t("guilds")}</Link>
</div>
</details>
<details className="relative">
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
<img src="/assets/images/icons/navigation/shop.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
{t("shop")}
</summary>
<div
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
style={{ backgroundColor: "var(--color-dropdown)" }}
>
<Link href="/shop" className="dropdown-item">{t("shop")}</Link>
<Link href="/marketplace" className="dropdown-item">{t("market")}</Link>
<Link href="/rares" className="dropdown-item">{t("rareValues")}</Link>
</div>
</details>
<Link href="/leaderboard" className="nav-item">
<i className="navigation-icon leaderboards mr-1 hidden lg:inline-flex" />
<img src="/assets/images/icons/navigation/leaderboards.png" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
{t("leaderboards")}
</Link>
<Link href="/rares" className="nav-item">
{t("rareValues")}
</Link>
<Link href="/shop" className="nav-item">
<i className="navigation-icon shop mr-1 hidden lg:inline-flex" />
{t("shop")}
</Link>
<Link href="/marketplace" className="nav-item">
{t("market")}
<Link href="/radio" className="nav-item">
{t("radio")}
</Link>
<details className="relative">
<summary className="nav-item list-none cursor-pointer [&::-webkit-details-marker]:hidden">
<i className="navigation-icon rules mr-1 hidden lg:inline-flex" />
<img src="/assets/images/icons/navigation/rules.gif" alt="" className="mr-1 hidden w-5 h-5 lg:inline-block" />
{t("assistance")}
</summary>
<div
className="absolute left-0 mt-1 min-w-[190px] rounded-md shadow-lg z-50 py-1"
style={{ backgroundColor: "var(--color-dropdown)" }}
>
<Link href="/help" className="dropdown-item">
{t("helpCenter")}
</Link>
<Link href="/help" className="dropdown-item">{t("helpCenter")}</Link>
{session?.user ? (
<Link href="/help/tickets" className="dropdown-item">
{t("myTickets")}
</Link>
<Link href="/help/tickets" className="dropdown-item">{t("myTickets")}</Link>
) : null}
<Link href="/badges" className="dropdown-item">
{t("badges")}
</Link>
<Link href="/draw-badge" className="dropdown-item">
{t("drawBadge")}
</Link>
<Link href="/badges" className="dropdown-item">{t("badges")}</Link>
<Link href="/draw-badge" className="dropdown-item">{t("drawBadge")}</Link>
</div>
</details>
<Link href="/radio" className="nav-item">
{t("radio")}
</Link>
{session?.user ? (
<Link href="/friends" className="nav-item">
{t("friends")}
</Link>
) : null}
{session?.user ? (
<Link href="/messages" className="nav-item">
{t("messages")}
</Link>
<>
<Link href="/friends" className="nav-item">{t("friends")}</Link>
<Link href="/messages" className="nav-item">{t("messages")}</Link>
</>
) : null}
{isStaff ? (
<Link href="/admin" className="nav-item">
{t("admin")}
</Link>
<Link href="/admin" className="nav-item">{t("admin")}</Link>
) : null}
<div className="ml-auto flex items-center gap-x-2">
<LanguageSwitcher />
<ThemeSwitcher />
+53 -81
View File
@@ -20,94 +20,66 @@ export async function SiteHeader() {
online = 0;
}
return (
<div
className="relative flex min-h-[13rem] h-52 w-full items-center justify-center"
style={{ background: `url(${header})`, backgroundSize: "cover", backgroundPosition: "center" }}
>
<div className="absolute h-full w-full bg-black/50" />
{session?.user ? (
<div className="relative flex h-full w-full max-w-7xl items-center justify-center pr-10 md:justify-between">
<div className="flex items-center gap-x-4">
<Link href="/" className="ml-7">
if (session?.user) {
return (
<div
className="relative flex min-h-[13rem] h-52 w-full items-center justify-center"
style={{ background: `url(${header})`, backgroundSize: "cover", backgroundPosition: "center" }}
>
<div className="absolute h-full w-full bg-black/50" />
<div className="relative flex items-center justify-center w-full px-4">
<div className="flex flex-col items-center justify-center max-w-[600px]">
<Link href="/" className="transition-all duration-300 hover:scale-110 mb-2.5">
{logo ? (
// biome-ignore lint/performance/noImgElement: external/CDN logo
<img
className="transition duration-300 ease-in-out hover:scale-105"
src={logo}
alt="Hotel logo"
/>
<img className="drop-shadow block" src={logo} alt="Hotel logo" />
) : (
<span className="text-3xl font-extrabold text-white text-shadow">{hotelName}</span>
<span className="text-4xl font-extrabold text-white text-shadow">{hotelName}</span>
)}
</Link>
<div
className="hidden md:flex items-center px-4 rounded-md relative h-[50px]"
style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text)" }}
>
<div
className="absolute -left-1 h-6 w-6 rotate-45"
style={{ backgroundColor: "var(--color-surface)" }}
/>
<span className="relative">
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
</span>
</div>
</div>
<div className="flex flex-col gap-y-2 md:flex-row md:gap-x-4">
<Link href="/client">
<button
type="button"
className="font-bold py-3 px-6 rounded-xl shadow-xl border-2 transition-all duration-300 hover:scale-110 hover:shadow-2xl text-base tracking-wide"
style={{
backgroundColor: "var(--button-color)",
color: "var(--button-text-color)",
borderColor: "rgba(255,255,255,0.3)",
}}
>
{t("nitroClient")}
</button>
</Link>
</div>
</div>
) : (
<div className="relative flex justify-center px-4">
<div
className="font-semibold flex flex-col items-center md:w-[600px]"
style={{ color: "#ffffff", textShadow: "0 1px 4px rgba(0,0,0,0.7)" }}
>
<p className="hidden text-center text-xl md:block">{t("tagline")}</p>
<div className="flex flex-col items-center justify-center gap-x-6 gap-y-4 md:mt-6 md:flex-row md:gap-y-0">
<Link href="/login">
<button
type="button"
className="rounded-full border-2 px-8 py-2 uppercase transition duration-200 ease-in-out"
style={{
borderColor: "var(--color-primary)",
backgroundColor: "var(--color-primary)",
color: "var(--button-text-color)",
}}
>
{t("login")}
</button>
</Link>
<p className="text-sm uppercase">{t("or")}</p>
<Link href="/register">
<button
type="button"
className="uppercase px-8 py-2.5 rounded-full transition ease-in-out duration-200"
style={{ backgroundColor: "var(--color-accent)", color: "#ffffff" }}
>
{t("createAccount")}
</button>
</Link>
<div className="relative flex items-center bg-white dark:bg-gray-800 rounded-md h-[50px] px-4 mt-2">
<div className="absolute w-6 h-6 bg-white dark:bg-gray-800 rotate-45 -translate-x-1/2 left-1/2 -top-3 z-0" aria-hidden="true" />
<div className="flex items-center gap-x-2 relative z-10">
<img src="/assets/images/user-online.png" alt="Online Icon" className="p-1" style={{ marginTop: "-2px" }} />
<span className="whitespace-nowrap text-gray-800 dark:text-white">
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}
return (
<div
className="relative flex items-center justify-center w-full h-52"
style={{
backgroundImage: `url(/assets/images/mybobba_banner.png)`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<div className="absolute w-full h-full bg-black/50" />
<div className="relative flex flex-col items-center justify-center px-4">
<Link href="/" className="transition-all duration-300 hover:scale-110 mb-2.5">
{logo ? (
<img className="drop-shadow block" src={logo} alt="Hotel logo" />
) : (
<span className="text-4xl font-extrabold text-white text-shadow">{hotelName}</span>
)}
</Link>
<div className="relative flex items-center bg-white dark:bg-gray-800 rounded-md h-[50px] px-4 mt-2">
<div className="absolute w-6 h-6 bg-white dark:bg-gray-800 rotate-45 -translate-x-1/2 left-1/2 -top-3 z-0" aria-hidden="true" />
<div className="flex items-center gap-x-2 relative z-10">
<img src="/assets/images/user-online.png" alt="Online Icon" className="p-1" style={{ marginTop: "-2px" }} />
<span className="whitespace-nowrap text-gray-800 dark:text-white">
{t("online", { count: online, hotel: hotelName ?? "Atom" })}
</span>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { getRequestConfig } from "next-intl/server";
// 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 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 const DEFAULT_LOCALE: AppLocale = "en";
-39
View File
@@ -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()}`;
}
+2 -2
View File
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/prisma";
// 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",
habbo_imaging_url: "https://epicnabbo.nl/imaging/",
logo_url: "",
nitro_path: "",
nitro_client_url: "",
};
/**
+645
View File
@@ -0,0 +1,645 @@
{
"nav": {
"home": "Start",
"community": "Community",
"articles": "Artikel",
"photos": "Fotos",
"staff": "Team",
"rankings": "Ranglisten",
"guilds": "Gilden",
"leaderboards": "Bestenlisten",
"rareValues": "Seltene Werte",
"shop": "Shop",
"market": "Markt",
"assistance": "Hilfe",
"helpCenter": "Hilfecenter",
"myTickets": "Meine Tickets",
"badges": "Abzeichen",
"radio": "Radio",
"friends": "Freunde",
"messages": "Nachrichten",
"admin": "Admin",
"drawBadge": "Abzeichen zeichnen"
},
"header": {
"online": "{count} {hotel} online",
"nitroClient": "Nitro Client",
"tagline": "Eine Online-Welt, in der du deinen eigenen Avatar erstellen, Freunde finden, chatten, Räume bauen und vieles mehr kannst!",
"login": "Anmelden",
"or": "Oder",
"createAccount": "Konto erstellen"
},
"footer": {
"copyright": "© {year} - {hotel} ist ein nicht-kommerzielles Bildungsprojekt"
},
"common": {
"language": "Sprache",
"lightMode": "Heller Modus",
"darkMode": "Dunkler Modus"
},
"pages": {
"staff": {
"title": "Team",
"subtitle": "Das Team hinter dem Hotel",
"ranksTitle": "Ränge",
"ranksSubtitle": "Wofür jeder Teil des Teams zuständig ist",
"membersTitle": "Teammitglieder",
"membersEmptySubtitle": "Im Moment gibt es niemanden zu zeigen",
"membersSubtitle": "{count, plural, one {# Mitglied hält den Betrieb aufrecht} other {# Mitglieder halten den Betrieb aufrecht}}",
"emptyState": "Keine Teammitglieder zu sehen.",
"colUsername": "Benutzername",
"colRank": "Rang",
"colMotto": "Motto"
},
"leaderboard": {
"title": "Bestenliste",
"subtitle": "Top 20 Spieler nach {currency}",
"tab_credits": "Credits",
"tab_diamonds": "Diamanten",
"tab_duckets": "Duckets",
"emptyState": "Noch keine Ranglisten zu sehen.",
"colAvatar": "Avatar",
"colPlayer": "Spieler"
},
"friends": {
"title": "Freunde",
"emptySubtitle": "Du hast noch keine Freunde hinzugefügt",
"subtitle": "{count, plural, one {Du hast # Freund} other {Du hast # Freunde}}",
"emptyState": "Füge im Hotel Freunde hinzu und sie werden hier angezeigt.",
"noMotto": "Kein Motto"
},
"messages": {
"title": "Nachrichten",
"subtitle": "Freundschaftsanfragen und Offline-Nachrichten warten auf dich",
"requestsTitle": "Freundschaftsanfragen",
"requestsEmptySubtitle": "Niemand hat noch gefragt, ob er dein Freund sein darf",
"requestsSubtitle": "{count, plural, one {# ausstehende Anfrage} other {# ausstehende Anfragen}}",
"requestsEmptyState": "Keine ausstehenden Freundschaftsanfragen.",
"wantsToBeFriend": "möchte dein Freund sein.",
"accept": "Akzeptieren",
"offlineTitle": "Offline-Nachrichten",
"offlineEmptySubtitle": "Dein Posteingang ist leer",
"offlineSubtitle": "{count, plural, one {# Nachricht erhalten} other {# Nachrichten erhalten}}",
"offlineEmptyState": "Du hast keine Offline-Nachrichten.",
"unknownUser": "Benutzer #{id}"
},
"profile": {
"noMotto": "Kein Motto",
"statRank": "Rang",
"statCredits": "Credits",
"statRegistered": "Registriert",
"badgesTitle": "Abzeichen",
"badgesEmpty": "Noch keine Abzeichen.",
"photosTitle": "Fotos",
"photosEmpty": "Noch keine Fotos.",
"guestbookTitle": "Gästebuch",
"guestbookSubtitle": "Nachrichten auf {username}s Pinnwand",
"leaveMessage": "Nachricht hinterlassen",
"guestbookPlaceholder": "Schreibe etwas in {username}s Gästebuch…",
"post": "Posten",
"loginToPost": "Melde dich an, um eine Gästebuch-Nachricht zu hinterlassen.",
"guestbookEmpty": "Noch keine Gästebucheinträge.",
"unknownUser": "Benutzer #{id}",
"statDuckets": "Duckets",
"statDiamonds": "Diamanten",
"walletTitle": "Geldbörse",
"friendsTitle": "Freunde",
"friendsSubtitle": "Leute im Hotel, mit denen sie abhängen",
"friendsEmpty": "Keine Freunde zu sehen.",
"roomsTitle": "Räume",
"roomsSubtitle": "Räume, die sie besitzen",
"roomsEmpty": "Keine Räume zu sehen.",
"roomUntitled": "Unbenannter Raum"
},
"rankings": {
"title": "Ranglisten",
"subtitle": "Die Top 12 Spieler nach Credits geordnet",
"emptyState": "Noch keine Spieler zu ordnen."
},
"home": {
"welcome": "Willkommen bei {hotelName}",
"welcomeSubtitle": "Dein Retro-Hotel, jetzt mit Next.js",
"welcomeBody": "Abhängen, Möbel sammeln und Freunde treffen.",
"enterHotel": "Hotel betreten",
"register": "JETZT REGISTRIEREN",
"joinNow": "Werde noch heute Mitglied bei {hotelName}!",
"recentUsers": "Aktuelle Benutzer",
"recentPhotos": "Aktuelle Fotos",
"latestNews": "Neueste Nachrichten",
"newsTitle": "Neueste Nachrichten",
"newsSubtitle": "Frisch aus dem Hotel",
"allNews": "Alle Nachrichten →",
"noArticles": "Noch keine Artikel.",
"noPhotos": "Noch keine Fotos.",
"noUsers": "Keine Benutzer online"
},
"news": {
"title": "Nachrichten",
"subtitle": "Die neuesten Ankündigungen, Events und Updates aus dem Hotel",
"noArticles": "Noch keine Artikel."
},
"article": {
"backToNews": "← Nachrichten",
"reactionsTitle": "Reaktionen",
"reactionsSubtitle": "Sag uns, was du denkst",
"reaction": {
"like": "Gefällt mir",
"love": "Liebe es",
"wow": "Wow"
},
"removeReaction": "Klicke, um deine Reaktion zu entfernen",
"reactWith": "Reagiere mit {reaction}",
"noReactions": "Noch keine Reaktionen.",
"logIn": "Anmelden",
"toReact": "um zu reagieren.",
"commentsTitle": "Kommentare",
"commentsSubtitle": "Mach mit bei der Unterhaltung",
"commentsCount": "{count} bisher",
"noComments": "Noch keine Kommentare. Sei der Erste, der kommentiert.",
"userNumber": "Benutzer #{id}",
"addComment": "Kommentar hinzufügen",
"commentPlaceholder": "Schreibe deinen Kommentar…",
"postComment": "Kommentar posten",
"toComment": "um einen Kommentar zu hinterlassen."
},
"photos": {
"title": "Fotos",
"subtitle": "Die neuesten Schnappschüsse aus dem Hotel",
"noPhotos": "Noch keine Fotos.",
"photoAlt": "Foto von Benutzer #{id}",
"byUser": "von Benutzer #{id}"
},
"badges": {
"title": "Abzeichen",
"subtitle": "Die Abzeichen, die du verdienen und im Hotel zeigen kannst",
"noBadges": "Noch keine Abzeichen verfügbar."
},
"community": {
"title": "Community",
"subtitle": "Alles, was rund um das Hotel passiert, an einem Ort",
"open": "Öffnen →",
"links": {
"rankings": {
"title": "Ranglisten",
"text": "Sieh dir die reichsten Spieler im Hotel an, geordnet nach Credits."
},
"staff": {
"title": "Team",
"text": "Lerne das Team kennen, das das Hotel täglich am Laufen hält."
},
"news": {
"title": "Nachrichten",
"text": "Die neuesten Ankündigungen, Events und Updates."
},
"photos": {
"title": "Fotos",
"text": "Schnappschüsse, die von der Community aus dem Hotel geteilt wurden."
},
"guilds": {
"title": "Gilden",
"text": "Durchstöbere die Gruppen, die Spieler erstellt und denen sie beigetreten sind."
},
"leaderboards": {
"title": "Bestenlisten",
"text": "Wer ist diese Saison in den Hotel-Statistiken an der Spitze."
}
}
},
"guilds": {
"title": "Gilden",
"subtitle": "Durchstöbere die Gruppen, die Spieler erstellt und denen sie beigetreten sind",
"empty": "Noch keine Gilden.",
"unnamedGuild": "Unbenannte Gilde",
"noDescription": "Keine Beschreibung"
},
"guild": {
"allGuilds": "← Alle Gilden",
"unnamedGuild": "Unbenannte Gilde",
"noDescription": "Keine Beschreibung",
"memberSingular": "Mitglied",
"memberPlural": "Mitglieder",
"founded": "Gegründet",
"forumLink": "Forum →",
"discussion": "Diskussion",
"membersTitle": "Mitglieder",
"memberLowerSingular": "Mitglied",
"memberLowerPlural": "Mitglieder",
"noMembers": "Keine Mitglieder.",
"owner": "Besitzer",
"noMotto": "Kein Motto"
},
"guildForum": {
"backToGuild": "← Zurück zur Gilde",
"unnamedGuild": "Unbenannte Gilde",
"forumTitle": "{guild} — Forum",
"threadSingular": "Thema",
"threadPlural": "Themen",
"newThread": "+ Neues Thema",
"empty": "Noch keine Themen in diesem Forum.",
"colSubject": "Betreff",
"colAuthor": "Autor",
"colPosts": "Beiträge",
"colDate": "Datum",
"noSubject": "(kein Betreff)",
"pinned": "Angepinnt",
"locked": "Gesperrt",
"userNumber": "Benutzer #{id}",
"unknown": "Unbekannt"
},
"guildForumNew": {
"backToForum": "← Zurück zum Forum",
"unnamedGuild": "Unbenannte Gilde",
"title": "Neues Thema",
"subtitle": "im {guild} Forum",
"subjectLabel": "Betreff",
"subjectPlaceholder": "Thema",
"messageLabel": "Nachricht",
"messagePlaceholder": "Schreibe deinen Eröffnungsbeitrag…",
"postThread": "Thema erstellen",
"cancel": "Abbrechen"
},
"shop": {
"title": "Shop",
"subtitle": "Lade dein Konto auf und sichere dir exklusive Pakete, Währungen und Abzeichen.",
"browsingCategory": "Durchstöbere {category} Pakete.",
"all": "Alle",
"allPackages": "Alle Pakete",
"packagesAvailable": "{count, plural, one {# Paket verfügbar} other {# Pakete verfügbar}}",
"empty": "Derzeit keine Pakete verfügbar.",
"buy": "Kaufen"
},
"topup": {
"title": "Aufladen mit PayPal",
"subtitle": "Sicher mit PayPal bezahlen und deine Credits werden sofort gutgeschrieben, sobald die Zahlung abgeschlossen ist.",
"cancelled": "Bezahlung abgebrochen — es wurde kein Betrag abgebucht.",
"chooseAmount": "Betrag wählen",
"notConfiguredPre": "PayPal ist noch nicht konfiguriert. Ein Administrator muss",
"notConfiguredAnd": "und",
"notConfiguredOptional": "(und optional",
"notConfiguredPost": "für Sandbox vs. Live).",
"balanceTitle": "Dein Guthaben",
"signedInAs": "Angemeldet als {username}",
"credits": "Credits",
"creditsPerUnit": "Credits pro 1,00 {currency}",
"rateLine": "Du erhältst <strong>{amount}</strong> Credits pro 1,00 {currency}. Credits erscheinen im Spiel, sobald du dich das nächste Mal einloggst."
},
"marketplace": {
"title": "Marktplatz",
"subtitle": "Durchstöbere Möbel und Seltenheiten, die von Spielern angeboten werden. Angebote werden im Spiel erstellt und live aktualisiert — geh ins Hotel, um ein Angebot zu machen.",
"activeOffers": "Aktive Angebote",
"sellers": "Verkäufer",
"totalValue": "Gesamtwert",
"activeListings": "Aktive Einträge",
"mostRecentFirst": "Neueste zuerst",
"empty": "Derzeit gibt es keine aktiven Marktplatz-Angebote. Schau später nochmal vorbei.",
"colOffer": "Angebot",
"colItem": "Gegenstand",
"colSeller": "Verkäufer",
"colPrice": "Preis",
"colListed": "Eingestellt",
"itemLabel": "Gegenstand #{id}",
"userLabel": "Benutzer #{id}",
"justNow": "gerade eben",
"minutesAgo": "vor {count}m",
"hoursAgo": "vor {count}h",
"daysAgo": "vor {count}d"
},
"rares": {
"title": "Seltene Werte",
"subtitle": "Erhalte eine Übersicht aller Seltenheiten im Hotel, gruppiert nach Kategorie.",
"empty": "Wir haben derzeit keine Seltenheiten gelistet.",
"rareCount": "{count, plural, one {# Seltenheit} other {# Seltenheiten}}",
"viewRares": "Seltenheiten ansehen →"
},
"rareCategory": {
"rareCount": "{count, plural, one {# Seltenheit in dieser Kategorie} other {# Seltenheiten in dieser Kategorie}}",
"allRares": "← Alle Seltenheiten",
"empty": "Noch keine Seltenheiten in dieser Kategorie.",
"colRare": "Seltenheit",
"colCredits": "Credits",
"colCurrency": "Währung",
"creditsValue": "{value} Credits",
"duckets": "Duckets",
"diamonds": "Diamanten",
"credits": "Credits"
},
"redeem": {
"title": "Gutschein einlösen",
"subtitle": "Hast du einen Gutscheincode? Gib ihn unten ein, um die Belohnung deinem Konto hinzuzufügen.",
"enterCode": "Code eingeben",
"oncePerAccount": "Jeder Gutschein kann nur einmal pro Konto eingelöst werden.",
"balanceTitle": "Dein Guthaben",
"signedInAs": "Angemeldet als {username}",
"credits": "Credits",
"instantCredit": "Eingelöste Belohnungen werden sofort gutgeschrieben und erscheinen im Spiel, sobald du dich das nächste Mal einloggst."
},
"help": {
"title": "Hilfecenter",
"subtitle": "Finde Anleitungen, Support-Themen und die Hotelregeln — alles, was du brauchst, um Hilfe zu bekommen.",
"myTickets": "Meine Tickets →",
"topicsTitle": "Support-Themen",
"topicsSubtitle": "Durchstöbere Anleitungen und Antworten auf häufige Fragen",
"topicsEmpty": "Noch keine Hilfethemen.",
"rulesTitle": "Hotelregeln",
"rulesSubtitle": "Bitte lies diese, bevor du dich im Hotel aufhältst",
"rulesEmpty": "Noch keine Regeln veröffentlicht.",
"categoryEmpty": "Keine Regeln in dieser Kategorie."
},
"helpCategory": {
"back": "← Hilfecenter",
"topicSubtitle": "Hilfecenter-Thema",
"moreTitle": "Weitere Hilfethemen",
"moreSubtitle": "Zu einem anderen Support-Thema springen"
},
"helpTickets": {
"title": "Hilfe-Tickets",
"subtitle": "Reiche ein Ticket beim Team ein und verfolge deine offenen Anfragen.",
"statTotal": "Tickets gesamt",
"statOpen": "Offen",
"statClosed": "Geschlossen",
"newTitle": "Neues Ticket",
"newSubtitle": "Beschreibe dein Problem und das Team wird sich darum kümmern",
"subjectPlaceholder": "Betreff",
"contentPlaceholder": "Beschreibe dein Problem…",
"submit": "Ticket einreichen",
"listTitle": "Deine Tickets",
"listSubtitle": "Jede Anfrage, die du an das Team gesendet hast",
"listEmpty": "Du hast noch keine Tickets.",
"colId": "ID",
"colSubject": "Betreff",
"colStatus": "Status",
"colCreated": "Erstellt",
"statusOpen": "Offen",
"statusClosed": "Geschlossen"
},
"applyStaff": {
"title": "Bewirb dich als {hotel} Team",
"subtitle": "Wir öffnen ab und zu Bewerbungen. Wenn du eine Position siehst, die perfekt zu dir passt, zögere nicht, dich zu bewerben.",
"noPositionsTitle": "Keine offenen Positionen",
"noPositionsBody": "Derzeit gibt es keine offenen Positionen. Bitte komm später nochmal vorbei, um zu prüfen, ob es neue Stellen gibt. Vielen Dank für dein Interesse.",
"noDeadline": "Keine Frist gesetzt",
"deadline": "Bewerbungsfrist: {date}",
"alreadyApplied": "Du hast dich bereits für {rank} beworben",
"aboutYou": "Über dich",
"aboutPlaceholder": "Erzähl uns, warum du ein großartiger {rank} wärst…",
"applyFor": "Bewirb dich für {rank}"
},
"applyTeam": {
"title": "Bewirb dich für das {hotel} Team",
"subtitle": "Wir öffnen regelmäßig Teambewerbungen. Wenn du ein Team siehst, das zu dir passt, zögere nicht, dich zu bewerben!",
"noPositionsTitle": "Keine offenen Team-Positionen",
"noPositionsBody": "Derzeit gibt es keine offenen Team-Positionen. Bitte komm später nochmal vorbei, um nach neuen Stellen zu sehen. Vielen Dank!",
"pending": "Deine Bewerbung ist in Bearbeitung",
"aboutYou": "Über dich",
"aboutPlaceholder": "Erzähl uns, warum du eine großartige Ergänzung für {rank} wärst…",
"applyFor": "Bewirb dich für {rank}"
},
"radio": {
"title": "Radio",
"subtitle": "Schalte das Hotelradio ein — Live-DJ-Sets, Wünsche und Shoutouts.",
"nowPlaying": "Jetzt läuft",
"nothingPlaying": "Momentan läuft nichts.",
"onAir": "On Air: {dj}",
"noDj": "Kein DJ on Air.",
"audioUnsupported": "Dein Browser unterstützt das Audio-Element nicht.",
"streamOffline": "Der Stream ist derzeit offline.",
"exploreTitle": "Entdecke das Radio",
"exploreSubtitle": "Programm, Shouts, Wünsche und mehr",
"linkSchedule": "Programm",
"linkScheduleText": "Unser wöchentliches Programm mit DJs und Shows.",
"linkShouts": "Shouts",
"linkShoutsText": "Shoutouts und Wünsche aus der Community.",
"linkRequests": "Wünsche",
"linkRequestsText": "Bitte den DJ, deinen Lieblingssong zu spielen.",
"linkApply": "Als DJ bewerben",
"linkApplyText": "Werde Teammitglied und geh auf Sendung.",
"linkLeaderboard": "Bestenliste",
"linkLeaderboardText": "Top-Hörer nach verdienten Punkten.",
"todayTitle": "Heutiges Programm",
"todayTitleWithDay": "Heutiges Programm · {day}",
"todaySubtitle": "Was heute auf Sendung ist",
"fullWeek": "Ganze Woche →",
"noShowsToday": "Heute sind keine Sendungen geplant.",
"colTime": "Zeit",
"colShow": "Sendung",
"colDj": "DJ",
"untitledShow": "Unbenannte Sendung",
"userNumber": "Benutzer #{id}"
},
"radioSchedule": {
"title": "Radiosendungplan",
"subtitle": "Unser wöchentliches Programm mit DJs und Shows",
"empty": "Noch keine Sendungen geplant.",
"colDay": "Tag",
"colTime": "Zeit",
"colDj": "DJ",
"colShow": "Sendung",
"noShows": "Keine Sendungen"
},
"radioShouts": {
"title": "Radio Shouts",
"subtitle": "Shoutouts und Wünsche aus der Community",
"postTitle": "Einen Shout posten",
"postSubtitle": "Sende eine Nachricht an den DJ, der on Air ist",
"messageLabel": "Deine Nachricht",
"messagePlaceholder": "Sende einen Shout an den DJ…",
"shoutButton": "Shout",
"loginToPost": "Melde dich an, um einen Shout zu posten.",
"latestTitle": "Neueste Shouts",
"latestSubtitle": "Die aktuellsten Nachrichten von Hörern",
"empty": "Noch keine Shouts. Sei der Erste!"
},
"radioContests": {
"title": "Radio-Wettbewerbe",
"subtitle": "Schalte ein und nimm an unseren neuesten On-Air-Wettbewerben teil",
"empty": "Noch keine Wettbewerbe.",
"contestLabel": "Wettbewerb #{id}",
"dateUnknown": "Datum unbekannt"
},
"radioGiveaways": {
"title": "Radio-Gewinnspiele",
"subtitle": "Hör zu und gewinne bei unseren Radio-Gewinnspielen",
"empty": "Noch keine Gewinnspiele.",
"giveawayLabel": "Gewinnspiel #{id}",
"dateUnknown": "Datum unbekannt"
},
"radioLeaderboard": {
"title": "Radio-Hörer-Bestenliste",
"subtitle": "Top 50 Hörer nach Punkten, die sie durchs Radiohören verdient haben",
"empty": "Noch keine Hörerpunkte zu sehen.",
"colRank": "Rang",
"colAvatar": "Avatar",
"colListener": "Hörer",
"colPoints": "Punkte"
},
"radioApply": {
"title": "Bewirb dich als Radio-DJ",
"subtitle": "Erzähl uns von dir und warum du eine großartige Bereicherung für das Radioteam wärst",
"latestTitle": "Deine letzte Bewerbung",
"statusLabel": "Status:",
"submittedOn": "· eingereicht am {date}",
"submitAgain": "Du kannst unten erneut einreichen, wenn sich deine Angaben geändert haben.",
"formTitle": "Bewerbungsformular",
"formSubtitle": "Alle mit * markierten Felder sind Pflichtfelder",
"realNameLabel": "Vollständiger Name",
"realNamePlaceholder": "Dein vollständiger Name",
"ageLabel": "Alter",
"availabilityLabel": "Verfügbarkeit",
"availabilityPlaceholder": "An welchen Tagen und zu welchen Zeiten kannst du senden?",
"musicStyleLabel": "Musikrichtung",
"musicStylePlaceholder": "z.B. House, Pop, Hip-Hop",
"experienceLabel": "Erfahrung",
"optional": "(optional)",
"experiencePlaceholder": "Frühere DJ- oder Moderationserfahrung?",
"motivationLabel": "Motivation",
"motivationPlaceholder": "Warum möchtest du dem Radioteam beitreten?",
"submitButton": "Bewerbung einreichen"
},
"settings": {
"accountSettings": "Kontoeinstellungen",
"accountSettingsSubtitle": "Verwalte dein Profil, deine Sicherheit und verbundene Konten",
"publicProfile": "Dein öffentliches Profil",
"noMotto": "Kein Motto",
"noEmail": "Keine E-Mail hinterlegt",
"changeMotto": "Motto ändern",
"changeMottoSubtitle": "Aktualisiert sich sofort im Spiel, wenn du online bist",
"mottoPlaceholder": "Dein Motto",
"saveMotto": "Motto speichern",
"securityTitle": "Sicherheit",
"securitySubtitle": "Zwei-Faktor-Authentifizierung",
"securityDescription": "Füge deinem Konto eine zweite Sicherheitsebene hinzu.",
"twoFactorLink": "Zwei-Faktor-Authentifizierung",
"connectionsTitle": "Verbundene Konten",
"connectionsSubtitle": "Mit einem Klick anmelden",
"connectionsDescription": "Verbinde Discord oder Google mit deinem Hotelkonto.",
"manageConnections": "Verbindungen verwalten"
},
"settings2fa": {
"backToSettings": "← Einstellungen",
"title": "Zwei-Faktor-Authentifizierung",
"subtitle": "Füge eine zweite Sicherheitsebene mit einer Authenticator-App hinzu",
"nowEnabled": "2FA ist jetzt aktiviert. 🔒",
"disabledNotice": "2FA wurde deaktiviert.",
"badCode": "Dieser Code war nicht gültig — versuche es erneut.",
"noAppKeyPrefix": "2FA ist nicht verfügbar, bis",
"noAppKeyMiddle": "konfiguriert ist (derselbe Laravel",
"noAppKeySuffix": "wie deine AtomCMS-Installation, damit vorhandene Geheimnisse lesbar bleiben).",
"isEnabled": "2FA ist aktiviert",
"onYourAccount": "auf deinem Konto.",
"disable": "2FA deaktivieren",
"scanOrEnter": "Scanne oder gib diesen Schlüssel ein",
"scanInstructions": "Füge dies zu Google Authenticator / Authy hinzu und gib dann den 6-stelligen Code zur Bestätigung ein.",
"manualKey": "Manueller Schlüssel:",
"codePlaceholder": "6-stelliger Code",
"confirm": "Bestätigen",
"enableIntro": "Füge eine zweite Sicherheitsebene mit einer Authenticator-App hinzu.",
"enable": "2FA aktivieren"
},
"connections": {
"backToSettings": "← Einstellungen",
"title": "Verbundene Konten",
"subtitle": "Verknüpfe ein Social-Media-Konto, damit du dich mit einem Klick anmelden kannst",
"matchByEmail": "Wir gleichen es mit deinem Hotelkonto per E-Mail ab. Verwende daher die hinterlegte Adresse:",
"lookupFailed": "Wir konnten deine verknüpften Konten momentan nicht auslesen. Du kannst dich unten trotzdem verbinden — dein Status wird angezeigt, sobald er verfügbar ist.",
"statusUnavailable": "In diesem Hotel nicht verfügbar.",
"statusConnected": "Verbunden — mit diesem Konto anmelden.",
"statusNotConnected": "Nicht verbunden.",
"connected": "Verbunden",
"connectProvider": "{provider} verbinden",
"unavailable": "Nicht verfügbar",
"footerNote": "Beim Verbinden wirst du zum Anbieter weitergeleitet, um zu autorisieren, und dann hierher zurückgebracht. Wenn die E-Mail deines Social-Media-Kontos nicht mit einem Hotelkonto übereinstimmt, wirst du mit einem Hinweis zum Anmeldebildschirm zurückgeleitet."
},
"login": {
"title": "Anmelden",
"subtitle": "Willkommen zurück — melde dich an, um das Hotel zu betreten.",
"subtitle2fa": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"usernamePlaceholder": "Benutzername",
"passwordPlaceholder": "Passwort",
"codePlaceholder": "2FA-Code",
"pleaseWait": "Bitte warten…",
"verify": "Verifizieren",
"signIn": "Anmelden",
"or": "oder",
"continueWithDiscord": "Weiter mit Discord",
"continueWithGoogle": "Weiter mit Google",
"noAccount": "Noch kein Konto?",
"createOne": "Konto erstellen",
"forgotPassword": "Passwort vergessen?",
"errorInvalidCredentials": "Ungültiger Benutzername oder Passwort",
"errorInvalid2fa": "Ungültiger 2FA-Code"
},
"register": {
"title": "Konto erstellen",
"subtitle": "Werde Mitglied im Hotel — es dauert nur einen Moment.",
"username": "Benutzername",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort wiederholen",
"usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail",
"passwordPlaceholder": "Passwort (min. 6 Zeichen)",
"confirmPasswordPlaceholder": "Passwort wiederholen",
"selectOutfit": "Wähle dein Outfit",
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
"createAccount": "Konto erstellen",
"creatingAccount": "Konto wird erstellt...",
"alreadyHaveAccount": "Bereits ein Konto? Anmelden!",
"register": "Registrieren",
"termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
"usernameError": "Benutzername ist erforderlich.",
"emailError": "Eine gültige E-Mail ist erforderlich.",
"passwordError": "Das Passwort muss mindestens 6 Zeichen lang sein.",
"confirmPasswordError": "Passwörter stimmen nicht überein.",
"termsRequired": "Du musst die AGB & Regeln akzeptieren.",
"usernameRequired": "Benutzername ist erforderlich.",
"emailRequired": "E-Mail ist erforderlich.",
"passwordRequired": "Passwort ist erforderlich.",
"passwordsDontMatch": "Passwörter stimmen nicht überein."
},
"forgot": {
"title": "Passwort zurücksetzen",
"subtitle": "Wir senden dir einen sicheren Link, um ein neues Passwort zu erstellen.",
"sentNotice": "Wenn diese E-Mail registriert ist, wurde ein Reset-Link gesendet. Überprüfe deinen Posteingang.",
"emailPlaceholder": "Deine E-Mail",
"sendResetLink": "Reset-Link senden",
"backToLogin": "Zurück zur Anmeldung"
},
"reset": {
"title": "Neues Passwort festlegen",
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.",
"passwordPlaceholder": "Neues Passwort (min. 6 Zeichen)",
"resetPassword": "Passwort zurücksetzen",
"backToLogin": "Zurück zur Anmeldung"
},
"verify": {
"verifiedTitle": "Du bist startklar",
"verifiedSubtitle": "E-Mail verifiziert",
"verifiedBody": "Deine E-Mail-Adresse wurde verifiziert. Du kannst dich jetzt anmelden und spielen.",
"goToLogin": "Zur Anmeldung",
"alreadyTitle": "Bereits verifiziert",
"alreadySubtitle": "Nichts zu tun",
"alreadyBody": "Diese E-Mail-Adresse wurde bereits bestätigt.",
"unavailableTitle": "Später nochmal versuchen",
"unavailableSubtitle": "Server nicht erreichbar",
"unavailableBody": "Wir konnten den Server nicht erreichen, um deine E-Mail zu verifizieren. Bitte versuche den Link später erneut.",
"invalidTitle": "E-Mail-Verifizierung",
"invalidSubtitle": "Ungültiger oder abgelaufener Link",
"invalidBody": "Dieser Verifizierungslink ist nicht gültig. Stelle sicher, dass du den vollständigen Link aus deiner E-Mail geöffnet hast, oder fordere einen neuen an.",
"backToLogin": "Zurück zur Anmeldung"
},
"banned": {
"title": "Du bist gebannt",
"subtitle": "Konto gesperrt",
"body": "Dein Konto wurde vom Hotel gesperrt.",
"reasonLabel": "Grund:",
"permanent": "Dieser Bann ist permanent.",
"expires": "Läuft ab am {date}.",
"contactStaff": "Wenn du glaubst, dass dies ein Fehler ist, wende dich an das Team."
},
"maintenance": {
"title": "{hotel} wird gewartet",
"subtitle": "Wir sind bald zurück",
"staffCanLogIn": "Teammitglieder können sich weiterhin anmelden."
}
}
}
+27 -3
View File
@@ -121,11 +121,17 @@
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Enter hotel",
"register": "REGISTER NOW",
"joinNow": "Join {hotelName} today!",
"recentUsers": "Recent Users",
"recentPhotos": "Recent Photos",
"latestNews": "Latest news",
"newsTitle": "Latest news",
"newsSubtitle": "Fresh from the hotel",
"allNews": "All news →",
"noArticles": "No articles yet."
"noArticles": "No articles yet.",
"noPhotos": "No photos yet.",
"noUsers": "No users online"
},
"news": {
"title": "News",
@@ -565,12 +571,30 @@
"register": {
"title": "Create account",
"subtitle": "Join the hotel — it only takes a moment.",
"username": "Username",
"email": "Email",
"password": "Password",
"confirmPassword": "Repeat password",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email",
"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",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in"
"termsError": "You must accept the terms & rules to register.",
"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": {
"title": "Reset password",
+645
View File
@@ -0,0 +1,645 @@
{
"nav": {
"home": "Inicio",
"community": "Comunidad",
"articles": "Artículos",
"photos": "Fotos",
"staff": "Equipo",
"rankings": "Clasificaciones",
"guilds": "Grupos",
"leaderboards": "Tablas de clasificación",
"rareValues": "Valores raros",
"shop": "Tienda",
"market": "Mercado",
"assistance": "Asistencia",
"helpCenter": "Centro de ayuda",
"myTickets": "Mis tickets",
"badges": "Insignias",
"radio": "Radio",
"friends": "Amigos",
"messages": "Mensajes",
"admin": "Admin",
"drawBadge": "Diseñar insignia"
},
"header": {
"online": "{count} {hotel} en línea",
"nitroClient": "Cliente Nitro",
"tagline": "¡Un mundo virtual en línea donde puedes crear tu propio avatar, hacer amigos, chatear, crear salas y mucho más!",
"login": "Iniciar sesión",
"or": "O",
"createAccount": "Crear una cuenta"
},
"footer": {
"copyright": "© {year} - {hotel} es un proyecto educativo sin fines de lucro"
},
"common": {
"language": "Idioma",
"lightMode": "Modo claro",
"darkMode": "Modo oscuro"
},
"pages": {
"staff": {
"title": "Equipo",
"subtitle": "El equipo detrás del hotel",
"ranksTitle": "Rangos",
"ranksSubtitle": "De qué se encarga cada parte del equipo",
"membersTitle": "Miembros del equipo",
"membersEmptySubtitle": "Nadie que mostrar ahora mismo",
"membersSubtitle": "{count, plural, one {# miembro manteniendo todo en marcha} other {# miembros manteniendo todo en marcha}}",
"emptyState": "No hay miembros del equipo que mostrar.",
"colUsername": "Usuario",
"colRank": "Rango",
"colMotto": "Lema"
},
"leaderboard": {
"title": "Tabla de clasificación",
"subtitle": "Top 20 jugadores por {currency}",
"tab_credits": "Créditos",
"tab_diamonds": "Diamantes",
"tab_duckets": "Duckets",
"emptyState": "Aún no hay clasificaciones que mostrar.",
"colAvatar": "Avatar",
"colPlayer": "Jugador"
},
"friends": {
"title": "Amigos",
"emptySubtitle": "Aún no has añadido amigos",
"subtitle": "{count, plural, one {Tienes # amigo} other {Tienes # amigos}}",
"emptyState": "Añade amigos en el hotel y aparecerán aquí.",
"noMotto": "Sin lema"
},
"messages": {
"title": "Mensajes",
"subtitle": "Solicitudes de amistad y mensajes fuera de línea esperándote",
"requestsTitle": "Solicitudes de amistad",
"requestsEmptySubtitle": "Nadie te ha enviado una solicitud de amistad aún",
"requestsSubtitle": "{count, plural, one {# solicitud pendiente} other {# solicitudes pendientes}}",
"requestsEmptyState": "No hay solicitudes de amistad pendientes.",
"wantsToBeFriend": "quiere ser tu amigo.",
"accept": "Aceptar",
"offlineTitle": "Mensajes fuera de línea",
"offlineEmptySubtitle": "Tu bandeja de entrada está vacía",
"offlineSubtitle": "{count, plural, one {# mensaje recibido} other {# mensajes recibidos}}",
"offlineEmptyState": "No tienes mensajes fuera de línea.",
"unknownUser": "Usuario #{id}"
},
"profile": {
"noMotto": "Sin lema",
"statRank": "Rango",
"statCredits": "Créditos",
"statRegistered": "Registrado",
"badgesTitle": "Insignias",
"badgesEmpty": "Aún no hay insignias.",
"photosTitle": "Fotos",
"photosEmpty": "Aún no hay fotos.",
"guestbookTitle": "Libro de visitas",
"guestbookSubtitle": "Mensajes en el muro de {username}",
"leaveMessage": "Dejar un mensaje",
"guestbookPlaceholder": "Escribe algo en el libro de visitas de {username}…",
"post": "Publicar",
"loginToPost": "Inicia sesión para dejar un mensaje en el libro de visitas.",
"guestbookEmpty": "Aún no hay entradas en el libro de visitas.",
"unknownUser": "Usuario #{id}",
"statDuckets": "Duckets",
"statDiamonds": "Diamantes",
"walletTitle": "Billetera",
"friendsTitle": "Amigos",
"friendsSubtitle": "Personas del hotel con las que pasan el tiempo",
"friendsEmpty": "No hay amigos que mostrar.",
"roomsTitle": "Salas",
"roomsSubtitle": "Salas que poseen",
"roomsEmpty": "No hay salas que mostrar.",
"roomUntitled": "Sala sin título"
},
"rankings": {
"title": "Clasificaciones",
"subtitle": "Los 12 mejores jugadores clasificados por créditos",
"emptyState": "Aún no hay jugadores que clasificar."
},
"home": {
"welcome": "Bienvenido a {hotelName}",
"welcomeSubtitle": "Tu hotel retro, ahora impulsado por Next.js",
"welcomeBody": "Pasa el rato, colecciona furni y conoce amigos.",
"enterHotel": "Entrar al hotel",
"register": "REGÍSTRATE AHORA",
"joinNow": "¡Únete a {hotelName} hoy!",
"recentUsers": "Usuarios recientes",
"recentPhotos": "Fotos recientes",
"latestNews": "Últimas noticias",
"newsTitle": "Últimas noticias",
"newsSubtitle": "Lo último del hotel",
"allNews": "Todas las noticias →",
"noArticles": "Aún no hay artículos.",
"noPhotos": "Aún no hay fotos.",
"noUsers": "No hay usuarios en línea"
},
"news": {
"title": "Noticias",
"subtitle": "Los últimos anuncios, eventos y actualizaciones del hotel",
"noArticles": "Aún no hay artículos."
},
"article": {
"backToNews": "← Noticias",
"reactionsTitle": "Reacciones",
"reactionsSubtitle": "Dinos lo que piensas",
"reaction": {
"like": "Me gusta",
"love": "Me encanta",
"wow": "Vaya"
},
"removeReaction": "Haz clic para eliminar tu reacción",
"reactWith": "Reaccionar con {reaction}",
"noReactions": "Aún no hay reacciones.",
"logIn": "Inicia sesión",
"toReact": "para reaccionar.",
"commentsTitle": "Comentarios",
"commentsSubtitle": "Únete a la conversación",
"commentsCount": "{count} hasta ahora",
"noComments": "Aún no hay comentarios. Sé el primero en comentar.",
"userNumber": "Usuario #{id}",
"addComment": "Añadir un comentario",
"commentPlaceholder": "Escribe tu comentario…",
"postComment": "Publicar comentario",
"toComment": "para dejar un comentario."
},
"photos": {
"title": "Fotos",
"subtitle": "Las últimas instantáneas tomadas en el hotel",
"noPhotos": "Aún no hay fotos.",
"photoAlt": "Foto del usuario #{id}",
"byUser": "por el usuario #{id}"
},
"badges": {
"title": "Insignias",
"subtitle": "Las insignias que puedes ganar y lucir en el hotel",
"noBadges": "Aún no hay insignias disponibles."
},
"community": {
"title": "Comunidad",
"subtitle": "Todo lo que sucede en el hotel en un solo lugar",
"open": "Abrir →",
"links": {
"rankings": {
"title": "Clasificaciones",
"text": "Ve los jugadores más ricos del hotel, clasificados por créditos."
},
"staff": {
"title": "Equipo",
"text": "Conoce al equipo que mantiene el hotel en funcionamiento cada día."
},
"news": {
"title": "Noticias",
"text": "Los últimos anuncios, eventos y actualizaciones."
},
"photos": {
"title": "Fotos",
"text": "Instantáneas compartidas por la comunidad de todo el hotel."
},
"guilds": {
"title": "Grupos",
"text": "Explora los grupos que los jugadores han creado y a los que se han unido."
},
"leaderboards": {
"title": "Tablas de clasificación",
"text": "Quién está en la cima esta temporada en las estadísticas del hotel."
}
}
},
"guilds": {
"title": "Grupos",
"subtitle": "Explora los grupos que los jugadores han creado y a los que se han unido",
"empty": "Aún no hay grupos.",
"unnamedGuild": "Grupo sin nombre",
"noDescription": "Sin descripción"
},
"guild": {
"allGuilds": "← Todos los grupos",
"unnamedGuild": "Grupo sin nombre",
"noDescription": "Sin descripción",
"memberSingular": "Miembro",
"memberPlural": "Miembros",
"founded": "Fundado",
"forumLink": "Foro →",
"discussion": "Discusión",
"membersTitle": "Miembros",
"memberLowerSingular": "miembro",
"memberLowerPlural": "miembros",
"noMembers": "No hay miembros.",
"owner": "Dueño",
"noMotto": "Sin lema"
},
"guildForum": {
"backToGuild": "← Volver al grupo",
"unnamedGuild": "Grupo sin nombre",
"forumTitle": "{guild} — Foro",
"threadSingular": "hilo",
"threadPlural": "hilos",
"newThread": "+ Nuevo hilo",
"empty": "Aún no hay hilos en este foro.",
"colSubject": "Asunto",
"colAuthor": "Autor",
"colPosts": "Mensajes",
"colDate": "Fecha",
"noSubject": "(sin asunto)",
"pinned": "Fijado",
"locked": "Bloqueado",
"userNumber": "Usuario #{id}",
"unknown": "Desconocido"
},
"guildForumNew": {
"backToForum": "← Volver al foro",
"unnamedGuild": "Grupo sin nombre",
"title": "Nuevo hilo",
"subtitle": "en el foro de {guild}",
"subjectLabel": "Asunto",
"subjectPlaceholder": "Asunto del hilo",
"messageLabel": "Mensaje",
"messagePlaceholder": "Escribe tu mensaje inicial…",
"postThread": "Publicar hilo",
"cancel": "Cancelar"
},
"shop": {
"title": "Tienda",
"subtitle": "Recarga tu cuenta y obtén paquetes exclusivos, moneda e insignias.",
"browsingCategory": "Explorando los paquetes de {category}.",
"all": "Todos",
"allPackages": "Todos los paquetes",
"packagesAvailable": "{count, plural, one {# paquete disponible} other {# paquetes disponibles}}",
"empty": "No hay paquetes disponibles ahora mismo.",
"buy": "Comprar"
},
"topup": {
"title": "Recargar con PayPal",
"subtitle": "Paga de forma segura con PayPal y tus créditos se añaden en cuanto se procesa el pago.",
"cancelled": "El pago fue cancelado — no se realizó ningún cobro.",
"chooseAmount": "Elige un monto",
"notConfiguredPre": "PayPal aún no está configurado. Un administrador debe configurar",
"notConfiguredAnd": "y",
"notConfiguredOptional": "(y opcionalmente",
"notConfiguredPost": "para sandbox vs. producción).",
"balanceTitle": "Tu saldo",
"signedInAs": "Conectado como {username}",
"credits": "Créditos",
"creditsPerUnit": "Créditos por 1.00 {currency}",
"rateLine": "Recibes <strong>{amount}</strong> créditos por 1.00 {currency}. Los créditos aparecen en el juego la próxima vez que inicies sesión."
},
"marketplace": {
"title": "Mercado",
"subtitle": "Explora muebles y rarezas listados por jugadores. Los listados se colocan en el juego y se actualizan en vivo — entra al hotel para hacer una oferta.",
"activeOffers": "Ofertas activas",
"sellers": "Vendedores",
"totalValue": "Valor total",
"activeListings": "Listados activos",
"mostRecentFirst": "Más recientes primero",
"empty": "No hay ofertas activas en el mercado ahora mismo. Vuelve más tarde.",
"colOffer": "Oferta",
"colItem": "Artículo",
"colSeller": "Vendedor",
"colPrice": "Precio",
"colListed": "Publicado",
"itemLabel": "Artículo #{id}",
"userLabel": "Usuario #{id}",
"justNow": "ahora mismo",
"minutesAgo": "hace {count}m",
"hoursAgo": "hace {count}h",
"daysAgo": "hace {count}d"
},
"rares": {
"title": "Valores raros",
"subtitle": "Obtén una visión general de todas las rarezas del hotel, agrupadas por categoría.",
"empty": "Actualmente no tenemos rarezas listadas aquí.",
"rareCount": "{count, plural, one {# rareza} other {# rarezas}}",
"viewRares": "Ver rarezas →"
},
"rareCategory": {
"rareCount": "{count, plural, one {# rareza en esta categoría} other {# rarezas en esta categoría}}",
"allRares": "← Todas las rarezas",
"empty": "Aún no hay rarezas listadas en esta categoría.",
"colRare": "Rareza",
"colCredits": "Créditos",
"colCurrency": "Moneda",
"creditsValue": "{value} créditos",
"duckets": "Duckets",
"diamonds": "Diamantes",
"credits": "Créditos"
},
"redeem": {
"title": "Canjear un vale",
"subtitle": "¿Tienes un código de vale? Introdúcelo abajo para añadir la recompensa a tu cuenta.",
"enterCode": "Ingresa tu código",
"oncePerAccount": "Cada vale solo puede canjearse una vez por cuenta.",
"balanceTitle": "Tu saldo",
"signedInAs": "Conectado como {username}",
"credits": "Créditos",
"instantCredit": "Las recompensas canjeadas se acreditan al instante y aparecen en el juego la próxima vez que inicies sesión."
},
"help": {
"title": "Centro de ayuda",
"subtitle": "Encuentra guías, temas de soporte y las reglas del hotel — todo lo que necesitas para obtener ayuda.",
"myTickets": "Mis tickets →",
"topicsTitle": "Temas de soporte",
"topicsSubtitle": "Explora guías y respuestas a preguntas frecuentes",
"topicsEmpty": "Aún no hay temas de ayuda.",
"rulesTitle": "Reglas del hotel",
"rulesSubtitle": "Por favor, léelas antes de pasar el rato en el hotel",
"rulesEmpty": "Aún no hay reglas publicadas.",
"categoryEmpty": "No hay reglas en esta categoría."
},
"helpCategory": {
"back": "← Centro de ayuda",
"topicSubtitle": "Tema del centro de ayuda",
"moreTitle": "Más temas de ayuda",
"moreSubtitle": "Ir a otro tema de soporte"
},
"helpTickets": {
"title": "Tickets de ayuda",
"subtitle": "Envía un ticket al equipo y sigue tus solicitudes abiertas.",
"statTotal": "Total de tickets",
"statOpen": "Abiertos",
"statClosed": "Cerrados",
"newTitle": "Nuevo ticket",
"newSubtitle": "Describe tu problema y el equipo hará seguimiento",
"subjectPlaceholder": "Asunto",
"contentPlaceholder": "Describe tu problema…",
"submit": "Enviar ticket",
"listTitle": "Tus tickets",
"listSubtitle": "Todas las solicitudes que has enviado al equipo",
"listEmpty": "Aún no tienes tickets.",
"colId": "ID",
"colSubject": "Asunto",
"colStatus": "Estado",
"colCreated": "Creado",
"statusOpen": "Abierto",
"statusClosed": "Cerrado"
},
"applyStaff": {
"title": "Solicitar unirte al equipo de {hotel}",
"subtitle": "Abrimos solicitudes de personal de vez en cuando. Si encuentras un puesto en el que crees que encajarías perfectamente, no dudes en solicitarlo.",
"noPositionsTitle": "No hay puestos abiertos",
"noPositionsBody": "Actualmente no hay puestos abiertos. Vuelve más tarde para comprobar si tenemos alguna vacante. Gracias por tu interés.",
"noDeadline": "Sin fecha límite",
"deadline": "Fecha límite de solicitud: {date}",
"alreadyApplied": "Ya has solicitado {rank}",
"aboutYou": "Sobre ti",
"aboutPlaceholder": "Cuéntanos por qué serías un gran {rank}…",
"applyFor": "Solicitar {rank}"
},
"applyTeam": {
"title": "Solicitar unirte al equipo de {hotel}",
"subtitle": "Abrimos solicitudes de equipo periódicamente. ¡Si ves un equipo al que encajas, no dudes en solicitarlo!",
"noPositionsTitle": "No hay puestos de equipo abiertos",
"noPositionsBody": "Actualmente no hay puestos de equipo abiertos. Vuelve más tarde para comprobar si hay nuevas vacantes. ¡Gracias!",
"pending": "Tu solicitud está pendiente",
"aboutYou": "Sobre ti",
"aboutPlaceholder": "Cuéntanos por qué serías ideal para {rank}…",
"applyFor": "Solicitar {rank}"
},
"radio": {
"title": "Radio",
"subtitle": "Sintoniza la radio del hotel — sesiones de DJ en vivo, solicitudes y saludos.",
"nowPlaying": "Reproduciendo ahora",
"nothingPlaying": "No se está reproduciendo nada ahora mismo.",
"onAir": "Al aire: {dj}",
"noDj": "No hay DJ al aire.",
"audioUnsupported": "Tu navegador no soporta el elemento de audio.",
"streamOffline": "La transmisión está actualmente fuera de línea.",
"exploreTitle": "Explora la radio",
"exploreSubtitle": "Horario, saludos, solicitudes y más",
"linkSchedule": "Horario",
"linkScheduleText": "Nuestra programación semanal de DJs y programas.",
"linkShouts": "Saludos",
"linkShoutsText": "Saludos y solicitudes de la comunidad.",
"linkRequests": "Solicitudes",
"linkRequestsText": "Pídele al DJ que ponga tu canción favorita.",
"linkApply": "Solicitar ser DJ",
"linkApplyText": "Únete al equipo y sal al aire.",
"linkLeaderboard": "Tabla de clasificación",
"linkLeaderboardText": "Los mejores oyentes clasificados por puntos obtenidos.",
"todayTitle": "Horario de hoy",
"todayTitleWithDay": "Horario de hoy · {day}",
"todaySubtitle": "Lo que hay al aire hoy",
"fullWeek": "Semana completa →",
"noShowsToday": "No hay programas programados para hoy.",
"colTime": "Hora",
"colShow": "Programa",
"colDj": "DJ",
"untitledShow": "Programa sin título",
"userNumber": "Usuario #{id}"
},
"radioSchedule": {
"title": "Horario de radio",
"subtitle": "Nuestra programación semanal de DJs y programas",
"empty": "Aún no hay programas programados.",
"colDay": "Día",
"colTime": "Hora",
"colDj": "DJ",
"colShow": "Programa",
"noShows": "Sin programas"
},
"radioShouts": {
"title": "Saludos de radio",
"subtitle": "Saludos y solicitudes de la comunidad",
"postTitle": "Publicar un saludo",
"postSubtitle": "Envía un mensaje al DJ al aire",
"messageLabel": "Tu mensaje",
"messagePlaceholder": "Envía un saludo al DJ…",
"shoutButton": "Saludar",
"loginToPost": "Inicia sesión para publicar un saludo.",
"latestTitle": "Últimos saludos",
"latestSubtitle": "Los mensajes más recientes de los oyentes",
"empty": "Aún no hay saludos. ¡Sé el primero!"
},
"radioContests": {
"title": "Concursos de radio",
"subtitle": "Sintoniza y participa en nuestros últimos concursos al aire",
"empty": "Aún no hay concursos.",
"contestLabel": "Concurso #{id}",
"dateUnknown": "Fecha desconocida"
},
"radioGiveaways": {
"title": "Sorteos de radio",
"subtitle": "Escucha para tener la oportunidad de ganar en nuestros sorteos de radio",
"empty": "Aún no hay sorteos.",
"giveawayLabel": "Sorteo #{id}",
"dateUnknown": "Fecha desconocida"
},
"radioLeaderboard": {
"title": "Tabla de clasificación de oyentes",
"subtitle": "Top 50 oyentes por puntos obtenidos sintonizando la radio",
"empty": "Aún no hay puntos de oyente que mostrar.",
"colRank": "Rango",
"colAvatar": "Avatar",
"colListener": "Oyente",
"colPoints": "Puntos"
},
"radioApply": {
"title": "Solicitar ser DJ de radio",
"subtitle": "Cuéntanos sobre ti y por qué serías ideal para la radio",
"latestTitle": "Tu última solicitud",
"statusLabel": "Estado:",
"submittedOn": "· enviada el {date}",
"submitAgain": "Puedes enviarla de nuevo abajo si tus datos han cambiado.",
"formTitle": "Formulario de solicitud",
"formSubtitle": "Todos los campos marcados como obligatorios deben completarse",
"realNameLabel": "Nombre real",
"realNamePlaceholder": "Tu nombre completo",
"ageLabel": "Edad",
"availabilityLabel": "Disponibilidad",
"availabilityPlaceholder": "¿Qué días y horarios puedes transmitir?",
"musicStyleLabel": "Estilo musical",
"musicStylePlaceholder": "ej. House, Pop, Hip-Hop",
"experienceLabel": "Experiencia",
"optional": "(opcional)",
"experiencePlaceholder": "¿Tienes experiencia previa como DJ o presentador?",
"motivationLabel": "Motivación",
"motivationPlaceholder": "¿Por qué quieres unirte al equipo de radio?",
"submitButton": "Enviar solicitud"
},
"settings": {
"accountSettings": "Configuración de la cuenta",
"accountSettingsSubtitle": "Gestiona tu perfil, seguridad y cuentas conectadas",
"publicProfile": "Tu perfil público",
"noMotto": "Sin lema",
"noEmail": "Sin correo electrónico registrado",
"changeMotto": "Cambiar lema",
"changeMottoSubtitle": "Se actualiza al instante en el juego si estás en línea",
"mottoPlaceholder": "Tu lema",
"saveMotto": "Guardar lema",
"securityTitle": "Seguridad",
"securitySubtitle": "Autenticación de dos factores",
"securityDescription": "Añade una segunda capa de protección a tu cuenta.",
"twoFactorLink": "Autenticación de dos factores",
"connectionsTitle": "Cuentas conectadas",
"connectionsSubtitle": "Inicia sesión con un solo clic",
"connectionsDescription": "Vincula Discord o Google a tu cuenta del hotel.",
"manageConnections": "Gestionar conexiones"
},
"settings2fa": {
"backToSettings": "← Configuración",
"title": "Autenticación de dos factores",
"subtitle": "Añade una segunda capa de seguridad con una aplicación de autenticación",
"nowEnabled": "2FA está ahora activado. 🔒",
"disabledNotice": "2FA ha sido desactivado.",
"badCode": "Ese código no era válido — inténtalo de nuevo.",
"noAppKeyPrefix": "2FA no está disponible hasta que",
"noAppKeyMiddle": "esté configurada (la misma",
"noAppKeySuffix": "que en tu instalación de AtomCMS, para que los secretos existentes sigan siendo legibles).",
"isEnabled": "2FA está activado",
"onYourAccount": "en tu cuenta.",
"disable": "Desactivar 2FA",
"scanOrEnter": "Escanea o ingresa esta clave",
"scanInstructions": "Añade esto a Google Authenticator / Authy, luego ingresa el código de 6 dígitos para confirmar.",
"manualKey": "Clave manual:",
"codePlaceholder": "Código de 6 dígitos",
"confirm": "Confirmar",
"enableIntro": "Añade una segunda capa de seguridad con una aplicación de autenticación.",
"enable": "Activar 2FA"
},
"connections": {
"backToSettings": "← Configuración",
"title": "Cuentas conectadas",
"subtitle": "Vincula una cuenta social para iniciar sesión con un clic",
"matchByEmail": "La vinculamos a tu cuenta del hotel por correo electrónico, así que usa la dirección registrada:",
"lookupFailed": "No pudimos leer tus cuentas vinculadas ahora mismo. Aún puedes conectar abajo — tu estado se mostrará cuando esté disponible.",
"statusUnavailable": "No disponible en este hotel.",
"statusConnected": "Conectado — inicia sesión con esta cuenta.",
"statusNotConnected": "No conectado.",
"connected": "Conectado",
"connectProvider": "Conectar {provider}",
"unavailable": "No disponible",
"footerNote": "Conectar te lleva al proveedor para autorizar, y luego de vuelta aquí. Si el correo electrónico de tu cuenta social no coincide con una cuenta del hotel, serás redirigido a la pantalla de inicio de sesión con una nota."
},
"login": {
"title": "Iniciar sesión",
"subtitle": "Bienvenido de nuevo — inicia sesión para entrar al hotel.",
"subtitle2fa": "Ingresa el código de 6 dígitos de tu autenticador.",
"usernamePlaceholder": "Usuario",
"passwordPlaceholder": "Contraseña",
"codePlaceholder": "Código 2FA",
"pleaseWait": "Por favor espera…",
"verify": "Verificar",
"signIn": "Iniciar sesión",
"or": "o",
"continueWithDiscord": "Continuar con Discord",
"continueWithGoogle": "Continuar con Google",
"noAccount": "¿No tienes cuenta?",
"createOne": "Crea una",
"forgotPassword": "¿Olvidaste tu contraseña?",
"errorInvalidCredentials": "Usuario o contraseña inválidos",
"errorInvalid2fa": "Código 2FA inválido"
},
"register": {
"title": "Crear cuenta",
"subtitle": "Únete al hotel — solo toma un momento.",
"username": "Usuario",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Repetir contraseña",
"usernamePlaceholder": "Usuario",
"emailPlaceholder": "Correo electrónico",
"passwordPlaceholder": "Contraseña (mín. 6 caracteres)",
"confirmPasswordPlaceholder": "Repetir contraseña",
"selectOutfit": "Selecciona tu atuendo",
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.",
"createAccount": "Crear cuenta",
"creatingAccount": "Creando cuenta...",
"alreadyHaveAccount": "¿Ya tienes una cuenta? ¡Inicia sesión!",
"register": "Registrarse",
"termsError": "Debes aceptar los términos y reglas para registrarte.",
"usernameError": "El usuario es obligatorio.",
"emailError": "Se requiere un correo electrónico válido.",
"passwordError": "La contraseña debe tener al menos 6 caracteres.",
"confirmPasswordError": "Las contraseñas no coinciden.",
"termsRequired": "Debes aceptar los términos y reglas.",
"usernameRequired": "El usuario es obligatorio.",
"emailRequired": "El correo electrónico es obligatorio.",
"passwordRequired": "La contraseña es obligatoria.",
"passwordsDontMatch": "Las contraseñas no coinciden."
},
"forgot": {
"title": "Restablecer contraseña",
"subtitle": "Te enviaremos por correo electrónico un enlace seguro para establecer una nueva contraseña.",
"sentNotice": "Si ese correo está registrado, se ha enviado un enlace de restablecimiento. Revisa tu bandeja de entrada.",
"emailPlaceholder": "Tu correo electrónico",
"sendResetLink": "Enviar enlace de restablecimiento",
"backToLogin": "Volver al inicio de sesión"
},
"reset": {
"title": "Establecer una nueva contraseña",
"subtitle": "Elige una contraseña segura que no uses en otros sitios.",
"passwordPlaceholder": "Nueva contraseña (mín. 6 caracteres)",
"resetPassword": "Restablecer contraseña",
"backToLogin": "Volver al inicio de sesión"
},
"verify": {
"verifiedTitle": "Todo está listo",
"verifiedSubtitle": "Correo electrónico verificado",
"verifiedBody": "Tu dirección de correo electrónico ha sido verificada. Ya puedes iniciar sesión y jugar.",
"goToLogin": "Ir al inicio de sesión",
"alreadyTitle": "Ya verificado",
"alreadySubtitle": "Nada más que hacer",
"alreadyBody": "Esta dirección de correo electrónico ya ha sido confirmada.",
"unavailableTitle": "Inténtalo de nuevo en breve",
"unavailableSubtitle": "Servidor inaccesible",
"unavailableBody": "No pudimos contactar al servidor para verificar tu correo. Por favor, intenta el enlace de nuevo en un momento.",
"invalidTitle": "Verificación de correo electrónico",
"invalidSubtitle": "Enlace inválido o expirado",
"invalidBody": "Este enlace de verificación no es válido. Asegúrate de haber abierto el enlace completo de tu correo, o solicita uno nuevo.",
"backToLogin": "Volver al inicio de sesión"
},
"banned": {
"title": "Has sido baneado",
"subtitle": "Cuenta suspendida",
"body": "Tu cuenta ha sido suspendida del hotel.",
"reasonLabel": "Motivo:",
"permanent": "Este baneo es permanente.",
"expires": "Expira el {date}.",
"contactStaff": "Si crees que esto es un error, contacta al equipo."
},
"maintenance": {
"title": "{hotel} está en mantenimiento",
"subtitle": "Volveremos en breve",
"staffCanLogIn": "El equipo aún puede iniciar sesión."
}
}
}
+645
View File
@@ -0,0 +1,645 @@
{
"nav": {
"home": "Accueil",
"community": "Communauté",
"articles": "Articles",
"photos": "Photos",
"staff": "Équipe",
"rankings": "Classements",
"guilds": "Groupes",
"leaderboards": "Meilleurs scores",
"rareValues": "Valeurs rares",
"shop": "Boutique",
"market": "Marché",
"assistance": "Assistance",
"helpCenter": "Centre d'aide",
"myTickets": "Mes tickets",
"badges": "Badges",
"radio": "Radio",
"friends": "Amis",
"messages": "Messages",
"admin": "Admin",
"drawBadge": "Dessiner un badge"
},
"header": {
"online": "{count} {hotel} en ligne",
"nitroClient": "Client Nitro",
"tagline": "Un monde virtuel en ligne où vous pouvez créer votre propre avatar, vous faire des amis, discuter, créer des salons et bien plus encore !",
"login": "Connexion",
"or": "Ou",
"createAccount": "Créer un compte"
},
"footer": {
"copyright": "© {year} - {hotel} est un projet éducatif à but non lucratif"
},
"common": {
"language": "Langue",
"lightMode": "Mode clair",
"darkMode": "Mode sombre"
},
"pages": {
"staff": {
"title": "Équipe",
"subtitle": "L'équipe derrière l'hôtel",
"ranksTitle": "Rangs",
"ranksSubtitle": "Ce dont chaque membre de l'équipe s'occupe",
"membersTitle": "Membres de l'équipe",
"membersEmptySubtitle": "Personne à afficher pour le moment",
"membersSubtitle": "{count, plural, one {# membre qui fait tourner les choses} other {# membres qui font tourner les choses}}",
"emptyState": "Aucun membre du personnel à afficher.",
"colUsername": "Nom d'utilisateur",
"colRank": "Rang",
"colMotto": "Devise"
},
"leaderboard": {
"title": "Classement",
"subtitle": "Top 20 des joueurs par {currency}",
"tab_credits": "Crédits",
"tab_diamonds": "Diamants",
"tab_duckets": "Duckets",
"emptyState": "Aucun classement à afficher pour le moment.",
"colAvatar": "Avatar",
"colPlayer": "Joueur"
},
"friends": {
"title": "Amis",
"emptySubtitle": "Vous n'avez encore aucun ami ajouté",
"subtitle": "{count, plural, one {Vous avez # ami} other {Vous avez # amis}}",
"emptyState": "Ajoutez des amis dans l'hôtel et ils apparaîtront ici.",
"noMotto": "Aucune devise"
},
"messages": {
"title": "Messages",
"subtitle": "Demandes d'amis et messages hors ligne qui vous attendent",
"requestsTitle": "Demandes d'amis",
"requestsEmptySubtitle": "Personne n'a encore demandé à être votre ami",
"requestsSubtitle": "{count, plural, one {# demande en attente} other {# demandes en attente}}",
"requestsEmptyState": "Aucune demande d'ami en attente.",
"wantsToBeFriend": "veut être votre ami.",
"accept": "Accepter",
"offlineTitle": "Messages hors ligne",
"offlineEmptySubtitle": "Votre boîte de réception est vide",
"offlineSubtitle": "{count, plural, one {# message reçu} other {# messages reçus}}",
"offlineEmptyState": "Vous n'avez aucun message hors ligne.",
"unknownUser": "Utilisateur #{id}"
},
"profile": {
"noMotto": "Aucune devise",
"statRank": "Rang",
"statCredits": "Crédits",
"statRegistered": "Inscrit",
"badgesTitle": "Badges",
"badgesEmpty": "Pas encore de badges.",
"photosTitle": "Photos",
"photosEmpty": "Pas encore de photos.",
"guestbookTitle": "Livre d'or",
"guestbookSubtitle": "Messages sur le mur de {username}",
"leaveMessage": "Laisser un message",
"guestbookPlaceholder": "Écrivez quelque chose sur le livre d'or de {username}…",
"post": "Publier",
"loginToPost": "Connectez-vous pour laisser un message dans le livre d'or.",
"guestbookEmpty": "Pas encore d'entrées dans le livre d'or.",
"unknownUser": "Utilisateur #{id}",
"statDuckets": "Duckets",
"statDiamonds": "Diamants",
"walletTitle": "Portefeuille",
"friendsTitle": "Amis",
"friendsSubtitle": "Les personnes de l'hôtel avec qui il/elle traîne",
"friendsEmpty": "Aucun ami à afficher.",
"roomsTitle": "Salons",
"roomsSubtitle": "Salons qu'il/elle possède",
"roomsEmpty": "Aucun salon à afficher.",
"roomUntitled": "Salon sans titre"
},
"rankings": {
"title": "Classements",
"subtitle": "Les 12 meilleurs joueurs classés par crédits",
"emptyState": "Aucun joueur à classer pour le moment."
},
"home": {
"welcome": "Bienvenue sur {hotelName}",
"welcomeSubtitle": "Votre hôtel rétro, désormais propulsé par Next.js",
"welcomeBody": "Traînez, collectionnez des meubles et rencontrez des amis.",
"enterHotel": "Entrer dans l'hôtel",
"register": "S'INSCRIRE MAINTENANT",
"joinNow": "Rejoignez {hotelName} dès aujourd'hui !",
"recentUsers": "Utilisateurs récents",
"recentPhotos": "Photos récentes",
"latestNews": "Dernières actualités",
"newsTitle": "Dernières actualités",
"newsSubtitle": "Fraîchement sorties de l'hôtel",
"allNews": "Toutes les actualités →",
"noArticles": "Pas encore d'articles.",
"noPhotos": "Pas encore de photos.",
"noUsers": "Aucun utilisateur en ligne"
},
"news": {
"title": "Actualités",
"subtitle": "Les dernières annonces, événements et mises à jour de l'hôtel",
"noArticles": "Pas encore d'articles."
},
"article": {
"backToNews": "← Actualités",
"reactionsTitle": "Réactions",
"reactionsSubtitle": "Dites-nous ce que vous en pensez",
"reaction": {
"like": "J'aime",
"love": "J'adore",
"wow": "Waouh"
},
"removeReaction": "Cliquez pour retirer votre réaction",
"reactWith": "Réagir avec {reaction}",
"noReactions": "Pas encore de réactions.",
"logIn": "Connectez-vous",
"toReact": "pour réagir.",
"commentsTitle": "Commentaires",
"commentsSubtitle": "Participez à la conversation",
"commentsCount": "{count} jusqu'à présent",
"noComments": "Pas encore de commentaires. Soyez le premier à commenter.",
"userNumber": "Utilisateur #{id}",
"addComment": "Ajouter un commentaire",
"commentPlaceholder": "Écrivez votre commentaire…",
"postComment": "Publier le commentaire",
"toComment": "pour laisser un commentaire."
},
"photos": {
"title": "Photos",
"subtitle": "Les dernières captures d'écran prises dans l'hôtel",
"noPhotos": "Pas encore de photos.",
"photoAlt": "Photo de l'utilisateur #{id}",
"byUser": "par l'utilisateur #{id}"
},
"badges": {
"title": "Badges",
"subtitle": "Les badges que vous pouvez gagner et afficher dans l'hôtel",
"noBadges": "Aucun badge disponible pour le moment."
},
"community": {
"title": "Communauté",
"subtitle": "Tout ce qui se passe dans l'hôtel en un seul endroit",
"open": "Ouvrir →",
"links": {
"rankings": {
"title": "Classements",
"text": "Voir les joueurs les plus riches de l'hôtel, classés par crédits."
},
"staff": {
"title": "Équipe",
"text": "Rencontrez l'équipe qui fait tourner l'hôtel chaque jour."
},
"news": {
"title": "Actualités",
"text": "Les dernières annonces, événements et mises à jour."
},
"photos": {
"title": "Photos",
"text": "Les captures d'écran partagées par la communauté dans tout l'hôtel."
},
"guilds": {
"title": "Groupes",
"text": "Parcourez les groupes que les joueurs ont créés et rejoints."
},
"leaderboards": {
"title": "Meilleurs scores",
"text": "Qui est en tête cette saison dans les statistiques de l'hôtel."
}
}
},
"guilds": {
"title": "Groupes",
"subtitle": "Parcourez les groupes que les joueurs ont créés et rejoints",
"empty": "Pas encore de groupes.",
"unnamedGuild": "Groupe sans nom",
"noDescription": "Aucune description"
},
"guild": {
"allGuilds": "← Tous les groupes",
"unnamedGuild": "Groupe sans nom",
"noDescription": "Aucune description",
"memberSingular": "Membre",
"memberPlural": "Membres",
"founded": "Fondé",
"forumLink": "Forum →",
"discussion": "Discussion",
"membersTitle": "Membres",
"memberLowerSingular": "membre",
"memberLowerPlural": "membres",
"noMembers": "Aucun membre.",
"owner": "Propriétaire",
"noMotto": "Aucune devise"
},
"guildForum": {
"backToGuild": "← Retour au groupe",
"unnamedGuild": "Groupe sans nom",
"forumTitle": "{guild} — Forum",
"threadSingular": "sujet",
"threadPlural": "sujets",
"newThread": "+ Nouveau sujet",
"empty": "Aucun sujet dans ce forum pour le moment.",
"colSubject": "Sujet",
"colAuthor": "Auteur",
"colPosts": "Messages",
"colDate": "Date",
"noSubject": "(sans sujet)",
"pinned": "Épinglé",
"locked": "Verrouillé",
"userNumber": "Utilisateur #{id}",
"unknown": "Inconnu"
},
"guildForumNew": {
"backToForum": "← Retour au forum",
"unnamedGuild": "Groupe sans nom",
"title": "Nouveau sujet",
"subtitle": "dans le forum {guild}",
"subjectLabel": "Sujet",
"subjectPlaceholder": "Sujet du fil",
"messageLabel": "Message",
"messagePlaceholder": "Écrivez votre premier message…",
"postThread": "Publier le sujet",
"cancel": "Annuler"
},
"shop": {
"title": "Boutique",
"subtitle": "Rechargez votre compte et obtenez des packs exclusifs, de la monnaie et des badges.",
"browsingCategory": "Parcourir les packs {category}.",
"all": "Tout",
"allPackages": "Tous les packs",
"packagesAvailable": "{count, plural, one {# pack disponible} other {# packs disponibles}}",
"empty": "Aucun pack disponible pour le moment.",
"buy": "Acheter"
},
"topup": {
"title": "Recharger avec PayPal",
"subtitle": "Payez en toute sécurité avec PayPal et vos crédits sont ajoutés dès que le paiement est confirmé.",
"cancelled": "Le paiement a été annulé — aucun montant n'a été prélevé.",
"chooseAmount": "Choisissez un montant",
"notConfiguredPre": "PayPal n'est pas encore configuré. Un administrateur doit définir",
"notConfiguredAnd": "et",
"notConfiguredOptional": "(et éventuellement",
"notConfiguredPost": "pour le bac à sable ou le mode réel).",
"balanceTitle": "Votre solde",
"signedInAs": "Connecté en tant que {username}",
"credits": "Crédits",
"creditsPerUnit": "Crédits pour 1,00 {currency}",
"rateLine": "Vous recevez <strong>{amount}</strong> crédits pour 1,00 {currency}. Les crédits apparaissent dans le jeu la prochaine fois que vous vous connectez."
},
"marketplace": {
"title": "Marché",
"subtitle": "Parcourez les meubles et les rares listés par les joueurs. Les annonces sont placées dans le jeu et mises à jour en direct — rendez-vous dans l'hôtel pour faire une offre.",
"activeOffers": "Offres actives",
"sellers": "Vendeurs",
"totalValue": "Valeur totale",
"activeListings": "Annonces actives",
"mostRecentFirst": "Les plus récentes en premier",
"empty": "Il n'y a aucune offre active sur le marché pour le moment. Revenez plus tard.",
"colOffer": "Offre",
"colItem": "Objet",
"colSeller": "Vendeur",
"colPrice": "Prix",
"colListed": "Publié",
"itemLabel": "Objet #{id}",
"userLabel": "Utilisateur #{id}",
"justNow": "à l'instant",
"minutesAgo": "il y a {count}m",
"hoursAgo": "il y a {count}h",
"daysAgo": "il y a {count}j"
},
"rares": {
"title": "Valeurs rares",
"subtitle": "Obtenez un aperçu de tous les rares de l'hôtel, regroupés par catégorie.",
"empty": "Nous n'avons actuellement aucun rare répertorié ici.",
"rareCount": "{count, plural, one {# rare} other {# rares}}",
"viewRares": "Voir les rares →"
},
"rareCategory": {
"rareCount": "{count, plural, one {# rare dans cette catégorie} other {# rares dans cette catégorie}}",
"allRares": "← Tous les rares",
"empty": "Aucun rare répertorié dans cette catégorie pour le moment.",
"colRare": "Rare",
"colCredits": "Crédits",
"colCurrency": "Monnaie",
"creditsValue": "{value} crédits",
"duckets": "Duckets",
"diamonds": "Diamants",
"credits": "Crédits"
},
"redeem": {
"title": "Échanger un bon",
"subtitle": "Vous avez un code de bon ? Saisissez-le ci-dessous pour ajouter la récompense à votre compte.",
"enterCode": "Entrez votre code",
"oncePerAccount": "Chaque bon ne peut être échangé qu'une seule fois par compte.",
"balanceTitle": "Votre solde",
"signedInAs": "Connecté en tant que {username}",
"credits": "Crédits",
"instantCredit": "Les récompenses échangées sont créditées instantanément et apparaissent dans le jeu la prochaine fois que vous vous connectez."
},
"help": {
"title": "Centre d'aide",
"subtitle": "Trouvez des guides, des sujets d'assistance et les règles de l'hôtel — tout ce dont vous avez besoin pour obtenir de l'aide.",
"myTickets": "Mes tickets →",
"topicsTitle": "Sujets d'assistance",
"topicsSubtitle": "Parcourez les guides et les réponses aux questions courantes",
"topicsEmpty": "Pas encore de sujets d'aide.",
"rulesTitle": "Règles de l'hôtel",
"rulesSubtitle": "Veuillez les lire avant de traîner dans l'hôtel",
"rulesEmpty": "Aucune règle publiée pour le moment.",
"categoryEmpty": "Aucune règle dans cette catégorie."
},
"helpCategory": {
"back": "← Centre d'aide",
"topicSubtitle": "Sujet du centre d'aide",
"moreTitle": "Plus de sujets d'aide",
"moreSubtitle": "Aller à un autre sujet d'assistance"
},
"helpTickets": {
"title": "Tickets d'aide",
"subtitle": "Soumettez un ticket au personnel et suivez vos demandes en cours.",
"statTotal": "Total des tickets",
"statOpen": "Ouverts",
"statClosed": "Fermés",
"newTitle": "Nouveau ticket",
"newSubtitle": "Décrivez votre problème et le personnel vous répondra",
"subjectPlaceholder": "Sujet",
"contentPlaceholder": "Décrivez votre problème…",
"submit": "Soumettre le ticket",
"listTitle": "Vos tickets",
"listSubtitle": "Toutes les demandes que vous avez envoyées au personnel",
"listEmpty": "Vous n'avez pas encore de tickets.",
"colId": "ID",
"colSubject": "Sujet",
"colStatus": "Statut",
"colCreated": "Créé",
"statusOpen": "Ouvert",
"statusClosed": "Fermé"
},
"applyStaff": {
"title": "Postuler pour {hotel}",
"subtitle": "Nous ouvrons des candidatures pour le personnel de temps en temps. Si vous tombez sur un poste dans lequel vous pensez correspondre parfaitement, n'hésitez pas à postuler.",
"noPositionsTitle": "Aucun poste ouvert",
"noPositionsBody": "Il n'y a actuellement aucun poste ouvert. Revenez plus tard pour vérifier si de nouveaux postes sont disponibles. Merci de votre intérêt.",
"noDeadline": "Aucune date limite fixée",
"deadline": "Date limite de candidature : {date}",
"alreadyApplied": "Vous avez déjà postulé pour {rank}",
"aboutYou": "À propos de vous",
"aboutPlaceholder": "Dites-nous pourquoi vous seriez un excellent {rank}…",
"applyFor": "Postuler pour {rank}"
},
"applyTeam": {
"title": "Postuler pour rejoindre l'équipe {hotel}",
"subtitle": "Nous ouvrons les candidatures pour l'équipe périodiquement. Si vous voyez une équipe qui vous correspond, n'hésitez pas à postuler !",
"noPositionsTitle": "Aucun poste dans l'équipe ouvert",
"noPositionsBody": "Il n'y a actuellement aucun poste ouvert dans l'équipe. Revenez plus tard pour vérifier les nouvelles offres. Merci !",
"pending": "Votre candidature est en attente",
"aboutYou": "À propos de vous",
"aboutPlaceholder": "Dites-nous pourquoi vous seriez un excellent choix pour {rank}…",
"applyFor": "Postuler pour {rank}"
},
"radio": {
"title": "Radio",
"subtitle": "Écoutez la radio de l'hôtel — sessions live de DJ, demandes et dédicaces.",
"nowPlaying": "En cours de lecture",
"nothingPlaying": "Rien ne joue pour le moment.",
"onAir": "À l'antenne : {dj}",
"noDj": "Aucun DJ à l'antenne.",
"audioUnsupported": "Votre navigateur ne prend pas en charge l'élément audio.",
"streamOffline": "Le flux est actuellement hors ligne.",
"exploreTitle": "Explorer la radio",
"exploreSubtitle": "Programmation, dédicaces, demandes et plus",
"linkSchedule": "Programmation",
"linkScheduleText": "Notre programme hebdomadaire de DJ et d'émissions.",
"linkShouts": "Dédicaces",
"linkShoutsText": "Dédicaces et demandes de la communauté.",
"linkRequests": "Demandes",
"linkRequestsText": "Demandez au DJ de passer votre morceau préféré.",
"linkApply": "Postuler comme DJ",
"linkApplyText": "Rejoignez l'équipe et prenez le micro.",
"linkLeaderboard": "Classement",
"linkLeaderboardText": "Meilleurs auditeurs classés par points gagnés.",
"todayTitle": "Programmation du jour",
"todayTitleWithDay": "Programmation du jour · {day}",
"todaySubtitle": "Ce qui passe à l'antenne aujourd'hui",
"fullWeek": "Toute la semaine →",
"noShowsToday": "Aucune émission prévue aujourd'hui.",
"colTime": "Horaire",
"colShow": "Émission",
"colDj": "DJ",
"untitledShow": "Émission sans titre",
"userNumber": "Utilisateur #{id}"
},
"radioSchedule": {
"title": "Programmation radio",
"subtitle": "Notre programme hebdomadaire de DJ et d'émissions",
"empty": "Aucune émission prévue pour le moment.",
"colDay": "Jour",
"colTime": "Horaire",
"colDj": "DJ",
"colShow": "Émission",
"noShows": "Aucune émission"
},
"radioShouts": {
"title": "Dédicaces radio",
"subtitle": "Dédicaces et demandes de la communauté",
"postTitle": "Publier une dédicace",
"postSubtitle": "Envoyez un message au DJ à l'antenne",
"messageLabel": "Votre message",
"messagePlaceholder": "Envoyez une dédicace au DJ…",
"shoutButton": "Dédicacer",
"loginToPost": "Connectez-vous pour publier une dédicace.",
"latestTitle": "Dernières dédicaces",
"latestSubtitle": "Les messages les plus récents des auditeurs",
"empty": "Pas encore de dédicaces. Soyez le premier !"
},
"radioContests": {
"title": "Concours radio",
"subtitle": "Écoutez et participez à nos derniers concours en direct",
"empty": "Pas encore de concours.",
"contestLabel": "Concours #{id}",
"dateUnknown": "Date inconnue"
},
"radioGiveaways": {
"title": "Concours radio",
"subtitle": "Écoutez pour tenter votre chance dans nos concours radio",
"empty": "Pas encore de concours.",
"giveawayLabel": "Concours #{id}",
"dateUnknown": "Date inconnue"
},
"radioLeaderboard": {
"title": "Classement des auditeurs radio",
"subtitle": "Top 50 des auditeurs par points gagnés à l'écoute de la radio",
"empty": "Aucun point d'auditeur à afficher pour le moment.",
"colRank": "Rang",
"colAvatar": "Avatar",
"colListener": "Auditeur",
"colPoints": "Points"
},
"radioApply": {
"title": "Postuler pour devenir DJ radio",
"subtitle": "Parlez-nous de vous et pourquoi vous seriez parfait pour l'antenne",
"latestTitle": "Votre dernière candidature",
"statusLabel": "Statut :",
"submittedOn": "· soumise le {date}",
"submitAgain": "Vous pouvez soumettre à nouveau ci-dessous si vos informations ont changé.",
"formTitle": "Formulaire de candidature",
"formSubtitle": "Tous les champs marqués comme requis doivent être remplis",
"realNameLabel": "Nom réel",
"realNamePlaceholder": "Votre nom complet",
"ageLabel": "Âge",
"availabilityLabel": "Disponibilité",
"availabilityPlaceholder": "Quels jours et à quelles heures pouvez-vous diffuser ?",
"musicStyleLabel": "Style de musique",
"musicStylePlaceholder": "ex. House, Pop, Hip-Hop",
"experienceLabel": "Expérience",
"optional": "(facultatif)",
"experiencePlaceholder": "Une expérience préalable en tant que DJ ou animateur ?",
"motivationLabel": "Motivation",
"motivationPlaceholder": "Pourquoi voulez-vous rejoindre l'équipe radio ?",
"submitButton": "Soumettre la candidature"
},
"settings": {
"accountSettings": "Paramètres du compte",
"accountSettingsSubtitle": "Gérez votre profil, votre sécurité et vos comptes connectés",
"publicProfile": "Votre profil public",
"noMotto": "Aucune devise",
"noEmail": "Aucun e-mail enregistré",
"changeMotto": "Changer la devise",
"changeMottoSubtitle": "Se met à jour instantanément dans le jeu si vous êtes en ligne",
"mottoPlaceholder": "Votre devise",
"saveMotto": "Enregistrer la devise",
"securityTitle": "Sécurité",
"securitySubtitle": "Authentification à deux facteurs",
"securityDescription": "Ajoutez une deuxième couche de protection à votre compte.",
"twoFactorLink": "Authentification à deux facteurs",
"connectionsTitle": "Comptes connectés",
"connectionsSubtitle": "Connectez-vous en un clic",
"connectionsDescription": "Associez Discord ou Google à votre compte hôtel.",
"manageConnections": "Gérer les connexions"
},
"settings2fa": {
"backToSettings": "← Paramètres",
"title": "Authentification à deux facteurs",
"subtitle": "Ajoutez une deuxième couche de sécurité avec une application d'authentification",
"nowEnabled": "L'A2F est maintenant activée. 🔒",
"disabledNotice": "L'A2F a été désactivée.",
"badCode": "Ce code n'était pas valide — réessayez.",
"noAppKeyPrefix": "L'A2F est indisponible tant que",
"noAppKeyMiddle": "n'est pas configuré (la même clé Laravel",
"noAppKeySuffix": "que votre installation AtomCMS, pour que les secrets existants restent lisibles).",
"isEnabled": "L'A2F est activée",
"onYourAccount": "sur votre compte.",
"disable": "Désactiver l'A2F",
"scanOrEnter": "Scannez ou saisissez cette clé",
"scanInstructions": "Ajoutez-la à Google Authenticator / Authy, puis saisissez le code à 6 chiffres pour confirmer.",
"manualKey": "Clé manuelle :",
"codePlaceholder": "Code à 6 chiffres",
"confirm": "Confirmer",
"enableIntro": "Ajoutez une deuxième couche de sécurité avec une application d'authentification.",
"enable": "Activer l'A2F"
},
"connections": {
"backToSettings": "← Paramètres",
"title": "Comptes connectés",
"subtitle": "Associez un compte social pour vous connecter en un clic",
"matchByEmail": "Nous le faisons correspondre à votre compte hôtel par e-mail, donc utilisez l'adresse enregistrée :",
"lookupFailed": "Nous n'avons pas pu lire vos comptes liés pour le moment. Vous pouvez toujours vous connecter ci-dessous — votre statut apparaîtra dès qu'il sera disponible.",
"statusUnavailable": "Non disponible sur cet hôtel.",
"statusConnected": "Connecté — connectez-vous avec ce compte.",
"statusNotConnected": "Non connecté.",
"connected": "Connecté",
"connectProvider": "Connecter {provider}",
"unavailable": "Indisponible",
"footerNote": "La connexion vous redirige vers le fournisseur pour autoriser, puis vous revient ici. Si l'e-mail de votre compte social ne correspond pas à un compte hôtel, vous serez redirigé vers l'écran de connexion avec une note."
},
"login": {
"title": "Connexion",
"subtitle": "Bon retour — connectez-vous pour entrer dans l'hôtel.",
"subtitle2fa": "Saisissez le code à 6 chiffres de votre application d'authentification.",
"usernamePlaceholder": "Nom d'utilisateur",
"passwordPlaceholder": "Mot de passe",
"codePlaceholder": "Code A2F",
"pleaseWait": "Veuillez patienter…",
"verify": "Vérifier",
"signIn": "Se connecter",
"or": "ou",
"continueWithDiscord": "Continuer avec Discord",
"continueWithGoogle": "Continuer avec Google",
"noAccount": "Pas de compte ?",
"createOne": "Créer un compte",
"forgotPassword": "Mot de passe oublié ?",
"errorInvalidCredentials": "Nom d'utilisateur ou mot de passe invalide",
"errorInvalid2fa": "Code A2F invalide"
},
"register": {
"title": "Créer un compte",
"subtitle": "Rejoignez l'hôtel — cela ne prend qu'un instant.",
"username": "Nom d'utilisateur",
"email": "E-mail",
"password": "Mot de passe",
"confirmPassword": "Répéter le mot de passe",
"usernamePlaceholder": "Nom d'utilisateur",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Mot de passe (min 6 car.)",
"confirmPasswordPlaceholder": "Répéter le mot de passe",
"selectOutfit": "Sélectionnez votre tenue",
"termsAccept": "J'ai 18 ans ou plus et j'accepte les conditions et règles de {hotel}.",
"createAccount": "Créer un compte",
"creatingAccount": "Création du compte...",
"alreadyHaveAccount": "Vous avez déjà un compte ? Connectez-vous !",
"register": "S'inscrire",
"termsError": "Vous devez accepter les conditions et règles pour vous inscrire.",
"usernameError": "Le nom d'utilisateur est requis.",
"emailError": "Un e-mail valide est requis.",
"passwordError": "Le mot de passe doit contenir au moins 6 caractères.",
"confirmPasswordError": "Les mots de passe ne correspondent pas.",
"termsRequired": "Vous devez accepter les conditions et règles.",
"usernameRequired": "Le nom d'utilisateur est requis.",
"emailRequired": "L'e-mail est requis.",
"passwordRequired": "Le mot de passe est requis.",
"passwordsDontMatch": "Les mots de passe ne correspondent pas."
},
"forgot": {
"title": "Réinitialiser le mot de passe",
"subtitle": "Nous vous enverrons par e-mail un lien sécurisé pour définir un nouveau mot de passe.",
"sentNotice": "Si cet e-mail est enregistré, un lien de réinitialisation a été envoyé. Vérifiez votre boîte de réception.",
"emailPlaceholder": "Votre e-mail",
"sendResetLink": "Envoyer le lien de réinitialisation",
"backToLogin": "Retour à la connexion"
},
"reset": {
"title": "Définir un nouveau mot de passe",
"subtitle": "Choisissez un mot de passe fort que vous n'utilisez pas ailleurs.",
"passwordPlaceholder": "Nouveau mot de passe (min 6 car.)",
"resetPassword": "Réinitialiser le mot de passe",
"backToLogin": "Retour à la connexion"
},
"verify": {
"verifiedTitle": "Tout est prêt",
"verifiedSubtitle": "E-mail vérifié",
"verifiedBody": "Votre adresse e-mail a été vérifiée. Vous pouvez maintenant vous connecter et jouer.",
"goToLogin": "Aller à la connexion",
"alreadyTitle": "Déjà vérifié",
"alreadySubtitle": "Rien à faire",
"alreadyBody": "Cette adresse e-mail a déjà été confirmée.",
"unavailableTitle": "Réessayez dans un instant",
"unavailableSubtitle": "Serveur inaccessible",
"unavailableBody": "Nous n'avons pas pu joindre le serveur pour vérifier votre e-mail. Veuillez réessayer le lien dans un instant.",
"invalidTitle": "Vérification d'e-mail",
"invalidSubtitle": "Lien invalide ou expiré",
"invalidBody": "Ce lien de vérification n'est pas valide. Assurez-vous d'avoir ouvert le lien complet depuis votre e-mail, ou demandez-en un nouveau.",
"backToLogin": "Retour à la connexion"
},
"banned": {
"title": "Vous êtes banni",
"subtitle": "Compte suspendu",
"body": "Votre compte a été suspendu de l'hôtel.",
"reasonLabel": "Raison :",
"permanent": "Ce bannissement est permanent.",
"expires": "Expire le {date}.",
"contactStaff": "Si vous pensez qu'il s'agit d'une erreur, contactez l'équipe du personnel."
},
"maintenance": {
"title": "{hotel} est en maintenance",
"subtitle": "Nous serons bientôt de retour",
"staffCanLogIn": "Le personnel peut toujours se connecter."
}
}
}
+27 -3
View File
@@ -121,11 +121,17 @@
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
"enterHotel": "Entra nell'hotel",
"register": "REGISTRATI ORA",
"joinNow": "Unisciti a {hotelName} oggi!",
"recentUsers": "Utenti recenti",
"recentPhotos": "Foto recenti",
"latestNews": "Ultime notizie",
"newsTitle": "Ultime notizie",
"newsSubtitle": "Appena arrivate dall'hotel",
"allNews": "Tutte le notizie →",
"noArticles": "Ancora nessun articolo."
"noArticles": "Ancora nessun articolo.",
"noPhotos": "Ancora nessuna foto.",
"noUsers": "Nessun utente online"
},
"news": {
"title": "Notizie",
@@ -565,12 +571,30 @@
"register": {
"title": "Crea un account",
"subtitle": "Entra nell'hotel — bastano pochi istanti.",
"username": "Nome utente",
"email": "Email",
"password": "Password",
"confirmPassword": "Ripeti la password",
"usernamePlaceholder": "Nome utente",
"emailPlaceholder": "Email",
"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",
"alreadyHaveAccount": "Hai già un account?",
"signIn": "Accedi"
"termsError": "Devi accettare i termini e regole per registrarti.",
"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": {
"title": "Reimposta password",
+645
View File
@@ -0,0 +1,645 @@
{
"nav": {
"home": "Home",
"community": "Community",
"articles": "Artikelen",
"photos": "Foto's",
"staff": "Team",
"rankings": "Rankings",
"guilds": "Gildes",
"leaderboards": "Klassementen",
"rareValues": "Zeldzame waarden",
"shop": "Winkel",
"market": "Markt",
"assistance": "Assistentie",
"helpCenter": "Helpcentrum",
"myTickets": "Mijn tickets",
"badges": "Badges",
"radio": "Radio",
"friends": "Vrienden",
"messages": "Berichten",
"admin": "Admin",
"drawBadge": "Badge tekenen"
},
"header": {
"online": "{count} {hotel} online",
"nitroClient": "Nitro client",
"tagline": "Een online virtuele wereld waar je je eigen avatar kunt maken, vrienden kunt maken, kunt chatten, kamers kunt maken en nog veel meer!",
"login": "Inloggen",
"or": "Of",
"createAccount": "Account aanmaken"
},
"footer": {
"copyright": "© {year} - {hotel} is een niet-commercieel educatief project"
},
"common": {
"language": "Taal",
"lightMode": "Lichte modus",
"darkMode": "Donkere modus"
},
"pages": {
"staff": {
"title": "Team",
"subtitle": "Het team achter het hotel",
"ranksTitle": "Rangen",
"ranksSubtitle": "Waar elk deel van het team voor zorgt",
"membersTitle": "Teamleden",
"membersEmptySubtitle": "Momenteel niemand om te laten zien",
"membersSubtitle": "{count, plural, one {# lid houdt de boel draaiende} other {# leden houden de boel draaiende}}",
"emptyState": "Geen teamleden om te tonen.",
"colUsername": "Gebruikersnaam",
"colRank": "Rang",
"colMotto": "Motto"
},
"leaderboard": {
"title": "Klassement",
"subtitle": "Top 20 spelers op {currency}",
"tab_credits": "Credits",
"tab_diamonds": "Diamanten",
"tab_duckets": "Duckets",
"emptyState": "Nog geen klassementen om te tonen.",
"colAvatar": "Avatar",
"colPlayer": "Speler"
},
"friends": {
"title": "Vrienden",
"emptySubtitle": "Je hebt nog geen vrienden toegevoegd",
"subtitle": "{count, plural, one {Je hebt # vriend} other {Je hebt # vrienden}}",
"emptyState": "Voeg vrienden toe in het hotel en ze verschijnen hier.",
"noMotto": "Geen motto"
},
"messages": {
"title": "Berichten",
"subtitle": "Vriendschapsverzoeken en offline berichten die op je wachten",
"requestsTitle": "Vriendschapsverzoeken",
"requestsEmptySubtitle": "Niemand heeft nog gevraagd om je vriend te zijn",
"requestsSubtitle": "{count, plural, one {# openstaand verzoek} other {# openstaande verzoeken}}",
"requestsEmptyState": "Geen openstaande vriendschapsverzoeken.",
"wantsToBeFriend": "wil je vriend zijn.",
"accept": "Accepteren",
"offlineTitle": "Offline berichten",
"offlineEmptySubtitle": "Je inbox is leeg",
"offlineSubtitle": "{count, plural, one {# bericht ontvangen} other {# berichten ontvangen}}",
"offlineEmptyState": "Je hebt geen offline berichten.",
"unknownUser": "Gebruiker #{id}"
},
"profile": {
"noMotto": "Geen motto",
"statRank": "Rang",
"statCredits": "Credits",
"statRegistered": "Geregistreerd",
"badgesTitle": "Badges",
"badgesEmpty": "Nog geen badges.",
"photosTitle": "Foto's",
"photosEmpty": "Nog geen foto's.",
"guestbookTitle": "Gastenboek",
"guestbookSubtitle": "Berichten op {username}'s muur",
"leaveMessage": "Bericht achterlaten",
"guestbookPlaceholder": "Schrijf iets op {username}'s gastenboek…",
"post": "Plaatsen",
"loginToPost": "Log in om een gastenboekbericht achter te laten.",
"guestbookEmpty": "Nog geen gastenboekberichten.",
"unknownUser": "Gebruiker #{id}",
"statDuckets": "Duckets",
"statDiamonds": "Diamanten",
"walletTitle": "Portemonnee",
"friendsTitle": "Vrienden",
"friendsSubtitle": "Mensen in het hotel waar ze mee omgaan",
"friendsEmpty": "Geen vrienden om te tonen.",
"roomsTitle": "Kamers",
"roomsSubtitle": "Kamers die ze bezitten",
"roomsEmpty": "Geen kamers om te tonen.",
"roomUntitled": "Naamloze kamer"
},
"rankings": {
"title": "Rankings",
"subtitle": "De top 12 spelers gerangschikt op credits",
"emptyState": "Nog geen spelers om te rangschikken."
},
"home": {
"welcome": "Welkom bij {hotelName}",
"welcomeSubtitle": "Je retro hotel, nu mogelijk gemaakt door Next.js",
"welcomeBody": "Rondhangen, meubels verzamelen en vrienden ontmoeten.",
"enterHotel": "Hotel betreden",
"register": "NU REGISTREREN",
"joinNow": "Word vandaag nog lid van {hotelName}!",
"recentUsers": "Recente gebruikers",
"recentPhotos": "Recente foto's",
"latestNews": "Laatste nieuws",
"newsTitle": "Laatste nieuws",
"newsSubtitle": "Vers van het hotel",
"allNews": "Al het nieuws →",
"noArticles": "Nog geen artikelen.",
"noPhotos": "Nog geen foto's.",
"noUsers": "Geen gebruikers online"
},
"news": {
"title": "Nieuws",
"subtitle": "De laatste aankondigingen, evenementen en updates van het hotel",
"noArticles": "Nog geen artikelen."
},
"article": {
"backToNews": "← Nieuws",
"reactionsTitle": "Reacties",
"reactionsSubtitle": "Laat ons weten wat je ervan vindt",
"reaction": {
"like": "Vind ik leuk",
"love": "Hou ervan",
"wow": "Wauw"
},
"removeReaction": "Klik om je reactie te verwijderen",
"reactWith": "Reageer met {reaction}",
"noReactions": "Nog geen reacties.",
"logIn": "Log in",
"toReact": "om te reageren.",
"commentsTitle": "Reacties",
"commentsSubtitle": "Doe mee met het gesprek",
"commentsCount": "{count} tot nu toe",
"noComments": "Nog geen reacties. Wees de eerste om te reageren.",
"userNumber": "Gebruiker #{id}",
"addComment": "Reactie toevoegen",
"commentPlaceholder": "Schrijf je reactie…",
"postComment": "Reactie plaatsen",
"toComment": "om een reactie achter te laten."
},
"photos": {
"title": "Foto's",
"subtitle": "De laatste snapshots gemaakt in het hotel",
"noPhotos": "Nog geen foto's.",
"photoAlt": "Foto door gebruiker #{id}",
"byUser": "door gebruiker #{id}"
},
"badges": {
"title": "Badges",
"subtitle": "De badges die je kunt verdienen en showen in het hotel",
"noBadges": "Nog geen badges beschikbaar."
},
"community": {
"title": "Community",
"subtitle": "Alles wat er gebeurt in het hotel op één plek",
"open": "Open →",
"links": {
"rankings": {
"title": "Rankings",
"text": "Bekijk de rijkste spelers in het hotel, gerangschikt op credits."
},
"staff": {
"title": "Team",
"text": "Ontmoet het team dat het hotel elke dag draaiende houdt."
},
"news": {
"title": "Nieuws",
"text": "De laatste aankondigingen, evenementen en updates."
},
"photos": {
"title": "Foto's",
"text": "Snapshots gedeeld door de community van overal in het hotel."
},
"guilds": {
"title": "Gildes",
"text": "Bekijk de groepen die spelers hebben gemaakt en waar ze lid van zijn."
},
"leaderboards": {
"title": "Klassementen",
"text": "Wie staat er bovenaan dit seizoen in de hotelstatistieken."
}
}
},
"guilds": {
"title": "Gildes",
"subtitle": "Bekijk de groepen die spelers hebben gemaakt en waar ze lid van zijn",
"empty": "Nog geen gildes.",
"unnamedGuild": "Naamloos gilde",
"noDescription": "Geen beschrijving"
},
"guild": {
"allGuilds": "← Alle gildes",
"unnamedGuild": "Naamloos gilde",
"noDescription": "Geen beschrijving",
"memberSingular": "Lid",
"memberPlural": "Leden",
"founded": "Opgericht",
"forumLink": "Forum →",
"discussion": "Discussie",
"membersTitle": "Leden",
"memberLowerSingular": "lid",
"memberLowerPlural": "leden",
"noMembers": "Geen leden.",
"owner": "Eigenaar",
"noMotto": "Geen motto"
},
"guildForum": {
"backToGuild": "← Terug naar gilde",
"unnamedGuild": "Naamloos gilde",
"forumTitle": "{guild} — Forum",
"threadSingular": "discussie",
"threadPlural": "discussies",
"newThread": "+ Nieuwe discussie",
"empty": "Nog geen discussies in dit forum.",
"colSubject": "Onderwerp",
"colAuthor": "Auteur",
"colPosts": "Berichten",
"colDate": "Datum",
"noSubject": "(geen onderwerp)",
"pinned": "Vastgepind",
"locked": "Gesloten",
"userNumber": "Gebruiker #{id}",
"unknown": "Onbekend"
},
"guildForumNew": {
"backToForum": "← Terug naar forum",
"unnamedGuild": "Naamloos gilde",
"title": "Nieuwe discussie",
"subtitle": "in {guild} forum",
"subjectLabel": "Onderwerp",
"subjectPlaceholder": "Discussieonderwerp",
"messageLabel": "Bericht",
"messagePlaceholder": "Schrijf je openingsbericht…",
"postThread": "Discussie plaatsen",
"cancel": "Annuleren"
},
"shop": {
"title": "Winkel",
"subtitle": "Vul je account op en pak exclusieve pakketten, valuta en badges.",
"browsingCategory": "Bladeren door {category} pakketten.",
"all": "Alles",
"allPackages": "Alle pakketten",
"packagesAvailable": "{count, plural, one {# pakket beschikbaar} other {# pakketten beschikbaar}}",
"empty": "Momenteel geen pakketten beschikbaar.",
"buy": "Kopen"
},
"topup": {
"title": "Opwaarderen met PayPal",
"subtitle": "Veilig betalen met PayPal en je credits worden toegevoegd zodra de betaling is verwerkt.",
"cancelled": "Betaling geannuleerd — er is geen bedrag afgeschreven.",
"chooseAmount": "Kies een bedrag",
"notConfiguredPre": "PayPal is nog niet geconfigureerd. Een beheerder moet",
"notConfiguredAnd": "en",
"notConfiguredOptional": "(en optioneel",
"notConfiguredPost": "voor sandbox vs. live).",
"balanceTitle": "Jouw saldo",
"signedInAs": "Ingelogd als {username}",
"credits": "Credits",
"creditsPerUnit": "Credits per 1,00 {currency}",
"rateLine": "Je ontvangt <strong>{amount}</strong> credits per 1,00 {currency}. Credits verschijnen in-game de volgende keer dat je inlogt."
},
"marketplace": {
"title": "Marktplaats",
"subtitle": "Blader door meubels en zeldzaamheden die door spelers zijn aangeboden. Aanbiedingen worden in-game geplaatst en live bijgewerkt — ga naar het hotel om een bod te doen.",
"activeOffers": "Actieve aanbiedingen",
"sellers": "Verkopers",
"totalValue": "Totale waarde",
"activeListings": "Actieve aanbiedingen",
"mostRecentFirst": "Meest recent eerst",
"empty": "Er zijn momenteel geen actieve marktplaatsaanbiedingen. Kom later terug.",
"colOffer": "Aanbod",
"colItem": "Item",
"colSeller": "Verkoper",
"colPrice": "Prijs",
"colListed": "Geplaatst",
"itemLabel": "Item #{id}",
"userLabel": "Gebruiker #{id}",
"justNow": "zonet",
"minutesAgo": "{count}m geleden",
"hoursAgo": "{count}u geleden",
"daysAgo": "{count}d geleden"
},
"rares": {
"title": "Zeldzame waarden",
"subtitle": "Bekijk een overzicht van alle zeldzaamheden in het hotel, gegroepeerd per categorie.",
"empty": "We hebben momenteel geen zeldzaamheden vermeld.",
"rareCount": "{count, plural, one {# zeldzaamheid} other {# zeldzaamheden}}",
"viewRares": "Bekijk zeldzaamheden →"
},
"rareCategory": {
"rareCount": "{count, plural, one {# zeldzaamheid in deze categorie} other {# zeldzaamheden in deze categorie}}",
"allRares": "← Alle zeldzaamheden",
"empty": "Nog geen zeldzaamheden in deze categorie.",
"colRare": "Zeldzaamheid",
"colCredits": "Credits",
"colCurrency": "Valuta",
"creditsValue": "{value} credits",
"duckets": "Duckets",
"diamonds": "Diamanten",
"credits": "Credits"
},
"redeem": {
"title": "Voucher inwisselen",
"subtitle": "Heb je een vouchercode? Voer deze hieronder in om de beloning aan je account toe te voegen.",
"enterCode": "Voer je code in",
"oncePerAccount": "Elke voucher kan maar één keer per account worden ingewisseld.",
"balanceTitle": "Jouw saldo",
"signedInAs": "Ingelogd als {username}",
"credits": "Credits",
"instantCredit": "Ingewisselde beloningen worden direct bijgeschreven en verschijnen in-game de volgende keer dat je inlogt."
},
"help": {
"title": "Helpcentrum",
"subtitle": "Vind handleidingen, onderwerp en de hotelregels — alles wat je nodig hebt om hulp te krijgen.",
"myTickets": "Mijn tickets →",
"topicsTitle": "Onderwerpen",
"topicsSubtitle": "Blader door handleidingen en antwoorden op veelgestelde vragen",
"topicsEmpty": "Nog geen hulp onderwerpen.",
"rulesTitle": "Hotelregels",
"rulesSubtitle": "Lees deze voordat je in het hotel rondhangt",
"rulesEmpty": "Nog geen regels gepubliceerd.",
"categoryEmpty": "Geen regels in deze categorie."
},
"helpCategory": {
"back": "← Helpcentrum",
"topicSubtitle": "Helpcentrum onderwerp",
"moreTitle": "Meer hulp onderwerpen",
"moreSubtitle": "Ga naar een ander onderwerp"
},
"helpTickets": {
"title": "Help tickets",
"subtitle": "Dien een ticket in bij het team en volg je openstaande verzoeken.",
"statTotal": "Totaal tickets",
"statOpen": "Open",
"statClosed": "Gesloten",
"newTitle": "Nieuw ticket",
"newSubtitle": "Beschrijf je probleem en het team zal opvolgen",
"subjectPlaceholder": "Onderwerp",
"contentPlaceholder": "Beschrijf je probleem…",
"submit": "Ticket indienen",
"listTitle": "Jouw tickets",
"listSubtitle": "Elk verzoek dat je naar het team hebt gestuurd",
"listEmpty": "Je hebt nog geen tickets.",
"colId": "ID",
"colSubject": "Onderwerp",
"colStatus": "Status",
"colCreated": "Aangemaakt",
"statusOpen": "Open",
"statusClosed": "Gesloten"
},
"applyStaff": {
"title": "Solliciteer voor {hotel} team",
"subtitle": "We openen af en toe sollicitaties. Als je een positie ziet die perfect bij je past, aarzel dan niet om te solliciteren.",
"noPositionsTitle": "Geen open posities",
"noPositionsBody": "Er zijn momenteel geen open posities. Kom later terug om te controleren of er nieuwe posities zijn. Bedankt voor je interesse.",
"noDeadline": "Geen deadline ingesteld",
"deadline": "Sollicitatiedeadline: {date}",
"alreadyApplied": "Je hebt al gesolliciteerd voor {rank}",
"aboutYou": "Over jou",
"aboutPlaceholder": "Vertel ons waarom jij een geweldige {rank} zou zijn…",
"applyFor": "Solliciteer voor {rank}"
},
"applyTeam": {
"title": "Solliciteer voor het {hotel} team",
"subtitle": "We openen periodiek teamsollicitaties. Als je een team ziet dat bij je past, aarzel dan niet om te solliciteren!",
"noPositionsTitle": "Geen open team posities",
"noPositionsBody": "Er zijn momenteel geen open team posities. Kom later terug om te controleren op nieuwe posities. Bedankt!",
"pending": "Je sollicitatie is in behandeling",
"aboutYou": "Over jou",
"aboutPlaceholder": "Vertel ons waarom jij een geweldige aanwinst zou zijn voor {rank}…",
"applyFor": "Solliciteer voor {rank}"
},
"radio": {
"title": "Radio",
"subtitle": "Luister naar de hotelradio — live DJ-sets, verzoekjes en shout-outs.",
"nowPlaying": "Nu te beluisteren",
"nothingPlaying": "Er wordt momenteel niets afgespeeld.",
"onAir": "Live: {dj}",
"noDj": "Geen DJ live.",
"audioUnsupported": "Je browser ondersteunt geen audio.",
"streamOffline": "De stream is momenteel offline.",
"exploreTitle": "Ontdek de radio",
"exploreSubtitle": "Programma, shout-outs, verzoekjes en meer",
"linkSchedule": "Programma",
"linkScheduleText": "Onze wekelijkse line-up van DJ's en shows.",
"linkShouts": "Shout-outs",
"linkShoutsText": "Shout-outs en verzoekjes van de community.",
"linkRequests": "Verzoekjes",
"linkRequestsText": "Vraag de DJ om je favoriete nummer te draaien.",
"linkApply": "Solliciteer als DJ",
"linkApplyText": "Word lid van het team en ga de ether in.",
"linkLeaderboard": "Klassement",
"linkLeaderboardText": "Top luisteraars gerangschikt op verdiende punten.",
"todayTitle": "Programma van vandaag",
"todayTitleWithDay": "Programma van vandaag · {day}",
"todaySubtitle": "Wat er vandaag te horen is",
"fullWeek": "Hele week →",
"noShowsToday": "Geen shows gepland voor vandaag.",
"colTime": "Tijd",
"colShow": "Show",
"colDj": "DJ",
"untitledShow": "Naamloze show",
"userNumber": "Gebruiker #{id}"
},
"radioSchedule": {
"title": "Radioschema",
"subtitle": "Onze wekelijkse line-up van DJ's en shows",
"empty": "Nog geen shows gepland.",
"colDay": "Dag",
"colTime": "Tijd",
"colDj": "DJ",
"colShow": "Show",
"noShows": "Geen shows"
},
"radioShouts": {
"title": "Radio Shout-outs",
"subtitle": "Shout-outs en verzoekjes van de community",
"postTitle": "Plaats een shout",
"postSubtitle": "Stuur een bericht naar de DJ die live is",
"messageLabel": "Je bericht",
"messagePlaceholder": "Stuur een shout naar de DJ…",
"shoutButton": "Shout",
"loginToPost": "Log in om een shout te plaatsen.",
"latestTitle": "Laatste shouts",
"latestSubtitle": "De meest recente berichten van luisteraars",
"empty": "Nog geen shouts. Wees de eerste!"
},
"radioContests": {
"title": "Radiowedstrijden",
"subtitle": "Luister mee en doe mee aan onze laatste live radiowedstrijden",
"empty": "Nog geen wedstrijden.",
"contestLabel": "Wedstrijd #{id}",
"dateUnknown": "Datum onbekend"
},
"radioGiveaways": {
"title": "Radiogiveaways",
"subtitle": "Luister mee voor een kans om te winnen in onze radiogiveaways",
"empty": "Nog geen giveaways.",
"giveawayLabel": "Giveaway #{id}",
"dateUnknown": "Datum onbekend"
},
"radioLeaderboard": {
"title": "Radio Luisteraars Klassement",
"subtitle": "Top 50 luisteraars op punten verdiend door te luisteren naar de radio",
"empty": "Nog geen luisteraarspunten om te tonen.",
"colRank": "Rang",
"colAvatar": "Avatar",
"colListener": "Luisteraar",
"colPoints": "Punten"
},
"radioApply": {
"title": "Solliciteer als Radio DJ",
"subtitle": "Vertel ons over jezelf en waarom je een geweldige aanwinst zou zijn voor de radio",
"latestTitle": "Je laatste sollicitatie",
"statusLabel": "Status:",
"submittedOn": "· ingediend op {date}",
"submitAgain": "Je kunt hieronder opnieuw solliciteren als je gegevens zijn gewijzigd.",
"formTitle": "Sollicitatieformulier",
"formSubtitle": "Alle gemarkeerde velden zijn verplicht",
"realNameLabel": "Echte naam",
"realNamePlaceholder": "Je volledige naam",
"ageLabel": "Leeftijd",
"availabilityLabel": "Beschikbaarheid",
"availabilityPlaceholder": "Welke dagen en tijden kun je uitzenden?",
"musicStyleLabel": "Muziekstijl",
"musicStylePlaceholder": "Bijv. House, Pop, Hip-Hop",
"experienceLabel": "Ervaring",
"optional": "(optioneel)",
"experiencePlaceholder": "Eerdere DJ- of presentatie-ervaring?",
"motivationLabel": "Motivatie",
"motivationPlaceholder": "Waarom wil je lid worden van het radioteam?",
"submitButton": "Sollicitatie indienen"
},
"settings": {
"accountSettings": "Accountinstellingen",
"accountSettingsSubtitle": "Beheer je profiel, beveiliging en gekoppelde accounts",
"publicProfile": "Je openbare profiel",
"noMotto": "Geen motto",
"noEmail": "Geen e-mail op bestand",
"changeMotto": "Motto wijzigen",
"changeMottoSubtitle": "Wordt direct bijgewerkt in-game als je online bent",
"mottoPlaceholder": "Je motto",
"saveMotto": "Motto opslaan",
"securityTitle": "Beveiliging",
"securitySubtitle": "Twee factor authenticatie",
"securityDescription": "Voeg een extra beveiligingslaag toe aan je account.",
"twoFactorLink": "Twee factor authenticatie",
"connectionsTitle": "Gekoppelde accounts",
"connectionsSubtitle": "Inloggen met één klik",
"connectionsDescription": "Koppel Discord of Google aan je hotelaccount.",
"manageConnections": "Koppelingen beheren"
},
"settings2fa": {
"backToSettings": "← Instellingen",
"title": "Twee factor authenticatie",
"subtitle": "Voeg een extra beveiligingslaag toe met een authenticator-app",
"nowEnabled": "2FA is nu ingeschakeld. 🔒",
"disabledNotice": "2FA is uitgeschakeld.",
"badCode": "Die code was niet geldig — probeer opnieuw.",
"noAppKeyPrefix": "2FA is niet beschikbaar totdat",
"noAppKeyMiddle": "is geconfigureerd (dezelfde Laravel",
"noAppKeySuffix": "als je AtomCMS-installatie, zodat bestaande geheimen leesbaar blijven).",
"isEnabled": "2FA is ingeschakeld",
"onYourAccount": "op je account.",
"disable": "2FA uitschakelen",
"scanOrEnter": "Scan of voer deze sleutel in",
"scanInstructions": "Voeg dit toe aan Google Authenticator / Authy en voer vervolgens de 6-cijferige code in om te bevestigen.",
"manualKey": "Handmatige sleutel:",
"codePlaceholder": "6-cijferige code",
"confirm": "Bevestigen",
"enableIntro": "Voeg een extra beveiligingslaag toe met een authenticator-app.",
"enable": "2FA inschakelen"
},
"connections": {
"backToSettings": "← Instellingen",
"title": "Gekoppelde accounts",
"subtitle": "Koppel een sociaal account zodat je met één klik kunt inloggen",
"matchByEmail": "We koppelen het aan je hotelaccount op basis van e-mail, gebruik dus het volgende adres:",
"lookupFailed": "We konden je gekoppelde accounts nu niet uitlezen. Je kunt hieronder nog steeds koppelen — je status wordt getoond zodra deze beschikbaar is.",
"statusUnavailable": "Niet beschikbaar in dit hotel.",
"statusConnected": "Gekoppeld — log in met dit account.",
"statusNotConnected": "Niet gekoppeld.",
"connected": "Gekoppeld",
"connectProvider": "{provider} koppelen",
"unavailable": "Niet beschikbaar",
"footerNote": "Koppelen brengt je naar de provider om te autoriseren en daarna terug hierheen. Als het e-mailadres van je sociale account niet overeenkomt met een hotelaccount, word je teruggestuurd naar het inlogscherm met een melding."
},
"login": {
"title": "Inloggen",
"subtitle": "Welkom terug — log in om het hotel te betreden.",
"subtitle2fa": "Voer de 6-cijferige code uit je authenticator-app in.",
"usernamePlaceholder": "Gebruikersnaam",
"passwordPlaceholder": "Wachtwoord",
"codePlaceholder": "2FA-code",
"pleaseWait": "Een moment geduld…",
"verify": "Verifiëren",
"signIn": "Inloggen",
"or": "of",
"continueWithDiscord": "Doorgaan met Discord",
"continueWithGoogle": "Doorgaan met Google",
"noAccount": "Nog geen account?",
"createOne": "Account aanmaken",
"forgotPassword": "Wachtwoord vergeten?",
"errorInvalidCredentials": "Ongeldige gebruikersnaam of wachtwoord",
"errorInvalid2fa": "Ongeldige 2FA-code"
},
"register": {
"title": "Account aanmaken",
"subtitle": "Word lid van het hotel — het duurt maar even.",
"username": "Gebruikersnaam",
"email": "E-mail",
"password": "Wachtwoord",
"confirmPassword": "Wachtwoord herhalen",
"usernamePlaceholder": "Gebruikersnaam",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Wachtwoord (min 6 tekens)",
"confirmPasswordPlaceholder": "Wachtwoord herhalen",
"selectOutfit": "Kies je outfit",
"termsAccept": "Ik ben 18+ en accepteer de {hotel} voorwaarden & regels.",
"createAccount": "Account aanmaken",
"creatingAccount": "Account wordt aangemaakt...",
"alreadyHaveAccount": "Al een account? Inloggen!",
"register": "Registreren",
"termsError": "Je moet de voorwaarden & regels accepteren om te registreren.",
"usernameError": "Gebruikersnaam is verplicht.",
"emailError": "Een geldig e-mailadres is verplicht.",
"passwordError": "Wachtwoord moet minimaal 6 tekens bevatten.",
"confirmPasswordError": "Wachtwoorden komen niet overeen.",
"termsRequired": "Je moet de voorwaarden & regels accepteren.",
"usernameRequired": "Gebruikersnaam is verplicht.",
"emailRequired": "E-mail is verplicht.",
"passwordRequired": "Wachtwoord is verplicht.",
"passwordsDontMatch": "Wachtwoorden komen niet overeen."
},
"forgot": {
"title": "Wachtwoord resetten",
"subtitle": "We sturen je een veilige link om een nieuw wachtwoord in te stellen.",
"sentNotice": "Als dat e-mailadres geregistreerd is, is er een resetlink verzonden. Controleer je inbox.",
"emailPlaceholder": "Je e-mailadres",
"sendResetLink": "Resetlink verzenden",
"backToLogin": "Terug naar inloggen"
},
"reset": {
"title": "Nieuw wachtwoord instellen",
"subtitle": "Kies een sterk wachtwoord dat je nergens anders gebruikt.",
"passwordPlaceholder": "Nieuw wachtwoord (min 6 tekens)",
"resetPassword": "Wachtwoord resetten",
"backToLogin": "Terug naar inloggen"
},
"verify": {
"verifiedTitle": "Je bent helemaal klaar",
"verifiedSubtitle": "E-mail geverifieerd",
"verifiedBody": "Je e-mailadres is geverifieerd. Je kunt nu inloggen en spelen.",
"goToLogin": "Ga naar inloggen",
"alreadyTitle": "Al geverifieerd",
"alreadySubtitle": "Niets meer te doen",
"alreadyBody": "Dit e-mailadres is al bevestigd.",
"unavailableTitle": "Probeer het later opnieuw",
"unavailableSubtitle": "Server niet bereikbaar",
"unavailableBody": "We konden de server niet bereiken om je e-mail te verifiëren. Probeer de link later opnieuw.",
"invalidTitle": "E-mailverificatie",
"invalidSubtitle": "Ongeldige of verlopen link",
"invalidBody": "Deze verificatielink is niet geldig. Zorg ervoor dat je de volledige link uit je e-mail hebt geopend of vraag een nieuwe aan.",
"backToLogin": "Terug naar inloggen"
},
"banned": {
"title": "Je bent verbannen",
"subtitle": "Account geschorst",
"body": "Je account is geschorst van het hotel.",
"reasonLabel": "Reden:",
"permanent": "Deze ban is permanent.",
"expires": "Verloopt op {date}.",
"contactStaff": "Als je denkt dat dit een fout is, neem dan contact op met het team."
},
"maintenance": {
"title": "{hotel} is in onderhoud",
"subtitle": "We zijn zo terug",
"staffCanLogIn": "Team kan nog steeds inloggen."
}
}
}