feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ed7db6e048
commit
803e8f36c1
47 files changed
+2793
-358
No files matched your search
@@ -2,10 +2,13 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
@@ -70,7 +73,35 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
|
||||
export async function deleteAd(formData: FormData): Promise<void> {
|
||||
const deleteAdInput = z.object({
|
||||
id: z
|
||||
.union([z.string(), z.number(), z.bigint()])
|
||||
.transform((v) => BigInt(String(v))),
|
||||
});
|
||||
|
||||
export const deleteAd = adminAction(
|
||||
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
|
||||
async (ctx) => {
|
||||
const id = ctx.data.id;
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
} catch {
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: Number(ctx.session.user.id),
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
revalidatePath("/admin/ads");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
|
||||
export async function deleteAdForm(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
+112
-64
@@ -1,98 +1,146 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// AtomCMS validates the application body with `min:10`. Mirror that floor and
|
||||
// cap the write defensively (column is TEXT, but we keep applications sane).
|
||||
const CONTENT_MIN = 10;
|
||||
const CONTENT_MAX = 5000;
|
||||
|
||||
type ApplyOutcome =
|
||||
| "submitted"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "duplicate"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function staffRedirect(outcome: ApplyOutcome): never {
|
||||
if (outcome === "submitted") redirect("/apply/staff?submitted=1");
|
||||
redirect(`/apply/staff?error=${outcome}`);
|
||||
}
|
||||
|
||||
function teamRedirect(outcome: ApplyOutcome): never {
|
||||
if (outcome === "submitted") redirect("/apply/team?submitted=1");
|
||||
redirect(`/apply/team?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a STAFF application for an open position.
|
||||
*
|
||||
* Faithful to AtomCMS's StaffApplicationsController@store:
|
||||
* - the applicant (user_id) is re-read from the session via auth() and is
|
||||
* NEVER trusted from the submitted FormData;
|
||||
* - rank_id is the open position's permission id (the rank being applied for);
|
||||
* - a user may only apply once per rank (idempotency guard);
|
||||
* - content must be at least 10 characters.
|
||||
*/
|
||||
export async function applyStaff(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// rank_id comes from the open position's permission_id (an Int in the schema).
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
let outcome: ApplyOutcome = "error";
|
||||
|
||||
try {
|
||||
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`apply-staff:${userId}`, 3, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
outcome = "duplicate";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/apply/staff");
|
||||
staffRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a TEAM application.
|
||||
*
|
||||
* The Prisma `website_staff_applications` slice has no dedicated team column, so
|
||||
* (per the conversion brief) team applications REUSE the staff-applications
|
||||
* table with the team acting as the rank: rank_id carries the team id. The
|
||||
* applicant is re-read from the session, never trusted from the form, and a user
|
||||
* may only apply once per team.
|
||||
*/
|
||||
export async function applyTeam(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
|
||||
// id is the application's rank flag.
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
let outcome: ApplyOutcome = "error";
|
||||
|
||||
try {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`apply-team:${userId}`, 3, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
outcome = "duplicate";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/apply/team");
|
||||
teamRedirect(outcome);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -9,91 +10,106 @@ import { prisma } from "@/lib/prisma";
|
||||
// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx.
|
||||
const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
|
||||
|
||||
type ReactionOutcome = "updated" | "invalid" | "not_found" | "error";
|
||||
|
||||
function reactionRedirect(slug: string, outcome: ReactionOutcome): never {
|
||||
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
|
||||
if (outcome === "updated") redirect(`${path}?reaction=1`);
|
||||
redirect(`${path}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the SIGNED-IN user's reaction on a news article.
|
||||
*
|
||||
* The voter id is read from the session (re-fetched via auth()), never from the
|
||||
* submitted FormData, so a crafted form cannot vote as another account. A user
|
||||
* has at most one ACTIVE reaction per article:
|
||||
* - clicking the reaction they already have active -> deactivates it (un-vote)
|
||||
* - clicking a different reaction -> that reaction becomes active and any other
|
||||
* reaction rows for this user/article are deactivated
|
||||
* - first-ever reaction of a type -> a new active row is created
|
||||
*
|
||||
* Rows are toggled (active flag) rather than deleted so a user's history of
|
||||
* reaction types is preserved. website_article_reactions has no composite
|
||||
* unique key, so we resolve the existing row with findFirst rather than upsert.
|
||||
*/
|
||||
export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
const slugHint = String(formData.get("slug") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
let outcome: ReactionOutcome = "error";
|
||||
let slug = slugHint;
|
||||
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
let slug: string | null;
|
||||
try {
|
||||
// Confirm the article exists (and grab its slug for revalidation).
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) return;
|
||||
slug = article.slug;
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// The user's current row for THIS reaction on THIS article, if any.
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
|
||||
if (existing?.active) {
|
||||
// Already reacting with this exact reaction -> un-vote (deactivate it).
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
// Switching to (or first-time picking) this reaction: clear any other
|
||||
// active reaction by this user on this article, then activate this one.
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
const articleId = BigInt(articleIdRaw);
|
||||
|
||||
const article = await prisma.websiteArticles.findUnique({
|
||||
where: { id: articleId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!article) {
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
slug = article.slug;
|
||||
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
|
||||
if (existing?.active) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
outcome = "updated";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
if (slug && outcome !== "error") revalidatePath(`/news/${slug}`);
|
||||
reactionRedirect(slug, outcome);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -55,10 +56,14 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||
|
||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
||||
let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail";
|
||||
let boughtCode = "";
|
||||
|
||||
try {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
@@ -115,6 +120,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
outcome = "fail";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
|
||||
const ticketSchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
@@ -20,11 +22,32 @@ type TicketOutcome =
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type TicketDetailOutcome =
|
||||
| "replied"
|
||||
| "closed"
|
||||
| "invalid"
|
||||
| "not_found"
|
||||
| "closed_ticket"
|
||||
| "moderated"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function ticketsRedirect(outcome: TicketOutcome): never {
|
||||
if (outcome === "created") redirect("/help/tickets?created=1");
|
||||
redirect(`/help/tickets?error=${outcome}`);
|
||||
}
|
||||
|
||||
function ticketDetailRedirect(
|
||||
ticketId: bigint | null,
|
||||
outcome: TicketDetailOutcome,
|
||||
): never {
|
||||
if (!ticketId) redirect("/help/tickets?error=invalid");
|
||||
const base = `/help/tickets/${ticketId}`;
|
||||
if (outcome === "replied") redirect(`${base}?replied=1`);
|
||||
if (outcome === "closed") redirect(`${base}?closed=1`);
|
||||
redirect(`${base}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
@@ -99,3 +122,149 @@ export async function createTicket(formData: FormData): Promise<void> {
|
||||
revalidatePath("/help/tickets");
|
||||
ticketsRedirect(outcome);
|
||||
}
|
||||
|
||||
const replyContentSchema = z.object({
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export async function replyHelpTicket(formData: FormData): Promise<void> {
|
||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
||||
let outcome: TicketDetailOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket-reply:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const raw = {
|
||||
content: String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
};
|
||||
const parsed = replyContentSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true },
|
||||
});
|
||||
|
||||
if (!ticket || ticket.userId !== userId) {
|
||||
outcome = "not_found";
|
||||
} else if (!ticket.open) {
|
||||
outcome = "closed_ticket";
|
||||
} else {
|
||||
let moderated = false;
|
||||
try {
|
||||
await moderateOrThrow(parsed.data.content);
|
||||
} catch {
|
||||
moderated = true;
|
||||
outcome = "moderated";
|
||||
}
|
||||
|
||||
if (!moderated) {
|
||||
const created = await prisma.$transaction((tx) =>
|
||||
createOwnedTicketReply(
|
||||
{
|
||||
findTicket: (id) =>
|
||||
tx.websiteHelpCenterTickets.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, open: true },
|
||||
}),
|
||||
createReply: (data) =>
|
||||
tx.websiteHelpCenterTicketReplies.create({
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
touchTicket: (id, updatedAt) =>
|
||||
tx.websiteHelpCenterTickets.update({
|
||||
where: { id },
|
||||
data: { updatedAt },
|
||||
select: { id: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
ticketId,
|
||||
userId,
|
||||
content: parsed.data.content,
|
||||
},
|
||||
),
|
||||
);
|
||||
outcome = created ? "replied" : "not_found";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (ticketId) revalidatePath(`/help/tickets/${ticketId}`);
|
||||
revalidatePath("/help/tickets");
|
||||
ticketDetailRedirect(ticketId, outcome);
|
||||
}
|
||||
|
||||
export async function closeHelpTicket(formData: FormData): Promise<void> {
|
||||
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
|
||||
let outcome: TicketDetailOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true, open: true },
|
||||
});
|
||||
|
||||
if (!ticket || ticket.userId !== userId) {
|
||||
outcome = "not_found";
|
||||
} else if (!ticket.open) {
|
||||
outcome = "closed_ticket";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { open: false, updatedAt: now },
|
||||
});
|
||||
outcome = "closed";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (ticketId) revalidatePath(`/help/tickets/${ticketId}`);
|
||||
revalidatePath("/help/tickets");
|
||||
ticketDetailRedirect(ticketId, outcome);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type FriendOutcome =
|
||||
@@ -12,6 +13,15 @@ type FriendOutcome =
|
||||
| "not_found"
|
||||
| "unauthorized"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type SendOutcome =
|
||||
| "sent"
|
||||
| "not_friend"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "rate_limited"
|
||||
| "error";
|
||||
|
||||
function messagesRedirect(outcome: FriendOutcome): never {
|
||||
@@ -25,6 +35,11 @@ function friendsRedirect(outcome: FriendOutcome): never {
|
||||
redirect(`/friends?error=${outcome}`);
|
||||
}
|
||||
|
||||
function sendRedirect(outcome: SendOutcome): never {
|
||||
if (outcome === "sent") redirect("/messages?sent=1");
|
||||
redirect(`/messages?send_error=${outcome}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending friend request as the SIGNED-IN user.
|
||||
*
|
||||
@@ -52,6 +67,10 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
@@ -113,6 +132,7 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
|
||||
if (
|
||||
@@ -148,6 +168,10 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
@@ -167,6 +191,7 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
outcome = "declined";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
@@ -202,6 +227,10 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
@@ -230,6 +259,7 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
|
||||
outcome = deleted > 0 ? "removed" : "not_found";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
@@ -247,3 +277,81 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
revalidatePath("/messages");
|
||||
friendsRedirect(outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an offline messenger message to a friend (Arcturus messenger_offline row).
|
||||
* Only confirmed friendships may receive messages; body is capped at 500 chars.
|
||||
*/
|
||||
export async function sendOfflineMessage(formData: FormData): Promise<void> {
|
||||
let outcome: SendOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!(await rateLimit(`offline-msg:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "rate_limited";
|
||||
} else {
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
const rawMessage = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const message = rawMessage.slice(0, 500);
|
||||
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
} else if (!message) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const friendship = await prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!friendship) {
|
||||
outcome = "not_friend";
|
||||
} else {
|
||||
const recipient = await prisma.user.findUnique({
|
||||
where: { id: friendId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!recipient) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await prisma.messengerOffline.create({
|
||||
data: {
|
||||
userId: friendId,
|
||||
userFromId: meId,
|
||||
message,
|
||||
sendedOn: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
outcome = "sent";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
sendRedirect(outcome);
|
||||
}
|
||||
+68
-39
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// Column bounds from prisma/schema.prisma (radio_applications):
|
||||
// real_name VARCHAR(255); the rest are TEXT. age is an INT.
|
||||
@@ -10,6 +12,27 @@ const NAME_MAX = 255;
|
||||
const TEXT_MAX = 5000;
|
||||
const STYLE_MAX = 5000;
|
||||
|
||||
type ApplyOutcome =
|
||||
| "submitted"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function applyRedirect(outcome: ApplyOutcome): never {
|
||||
if (outcome === "submitted") redirect("/radio/apply?submitted=1");
|
||||
redirect(`/radio/apply?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
function str(form: FormData, key: string, max: number): string {
|
||||
return String(form.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -19,50 +42,56 @@ function str(form: FormData, key: string, max: number): string {
|
||||
|
||||
/**
|
||||
* Submit a radio DJ application.
|
||||
*
|
||||
* The applicant (userId) is ALWAYS re-read from the session via auth() and is
|
||||
* never taken from the submitted FormData, so a crafted form cannot file an
|
||||
* application on behalf of another account. radio_applications.user_id is an
|
||||
* UnsignedBigInt, hence the BigInt() coercion.
|
||||
*/
|
||||
export async function applyDj(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
let outcome: ApplyOutcome = "error";
|
||||
|
||||
const realName = str(formData, "realName", NAME_MAX);
|
||||
const availability = str(formData, "availability", TEXT_MAX);
|
||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||
const experience = str(formData, "experience", TEXT_MAX);
|
||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||
|
||||
const ageRaw = Number(formData.get("age"));
|
||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||
|
||||
// Required fields per the schema (NOT NULL): real_name, age, availability,
|
||||
// motivation. experience + music_style are nullable.
|
||||
if (!realName || !availability || !motivation || age <= 0) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable or duplicate — fail soft; nothing to persist.
|
||||
return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`radio-apply:${userId}`, 2, 300_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const realName = str(formData, "realName", NAME_MAX);
|
||||
const availability = str(formData, "availability", TEXT_MAX);
|
||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||
const experience = str(formData, "experience", TEXT_MAX);
|
||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||
|
||||
const ageRaw = Number(formData.get("age"));
|
||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||
|
||||
if (!realName || !availability || !motivation || age <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "submitted";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/radio/apply");
|
||||
applyRedirect(outcome);
|
||||
}
|
||||
@@ -1,42 +1,80 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SONG_MAX = 255;
|
||||
const ARTIST_MAX = 255;
|
||||
|
||||
type RequestOutcome =
|
||||
| "posted"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function requestsRedirect(outcome: RequestOutcome): never {
|
||||
if (outcome === "posted") redirect("/radio/requests?posted=1");
|
||||
redirect(`/radio/requests?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitRequest(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
let outcome: RequestOutcome = "error";
|
||||
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioSongRequests.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`radio-req:${userId}`, 5, 30_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) {
|
||||
outcome = "empty";
|
||||
} else {
|
||||
const now = new Date();
|
||||
await prisma.radioSongRequests.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
outcome = "posted";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/radio/requests");
|
||||
requestsRedirect(outcome);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use server";
|
||||
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
|
||||
* then end the current browser session too.
|
||||
*/
|
||||
export async function signOutEverywhere(): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { websiteJwtVersion: { increment: 1 } },
|
||||
});
|
||||
} catch {
|
||||
/* still sign out locally */
|
||||
}
|
||||
|
||||
await signOut({ redirectTo: "/login?signedOutAll=1" });
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
|
||||
/**
|
||||
* Credits charged for a package row. AtomCMS stores `costs` in cents (USD display);
|
||||
* we mirror the top-up rate so $1.00 of list price costs creditsPerUnit() credits.
|
||||
*/
|
||||
function creditPriceFromCosts(costs: number): number {
|
||||
const rate = creditsPerUnit();
|
||||
const dollars = costs < 100 ? 1 : costs / 100;
|
||||
return Math.max(1, Math.floor(dollars * rate));
|
||||
}
|
||||
|
||||
function parseBadgeCodes(raw: string | null | undefined): string[] {
|
||||
if (!raw?.trim()) return [];
|
||||
return raw
|
||||
.split(/[,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && s.length <= 32);
|
||||
}
|
||||
|
||||
type BuyOutcome =
|
||||
| "bought"
|
||||
| "invalid"
|
||||
| "credits"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function shopRedirect(
|
||||
categoryId: string,
|
||||
outcome: BuyOutcome,
|
||||
articleName?: string,
|
||||
): never {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId) params.set("category", categoryId);
|
||||
if (outcome === "bought") {
|
||||
params.set("bought", "1");
|
||||
if (articleName) params.set("package", articleName);
|
||||
} else {
|
||||
params.set("error", outcome);
|
||||
}
|
||||
const qs = params.toString();
|
||||
redirect(qs ? `/shop?${qs}` : "/shop");
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
typeof e === "object" &&
|
||||
"digest" in e &&
|
||||
typeof (e as { digest?: unknown }).digest === "string" &&
|
||||
(e as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase a website shop package with in-game credits (top up via /shop/topup first).
|
||||
* The buyer id is always taken from the session, never from FormData.
|
||||
*/
|
||||
export async function buyShopArticle(formData: FormData): Promise<void> {
|
||||
const categoryId = String(formData.get("categoryId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const safeCategory = /^\d+$/.test(categoryId) ? categoryId : "";
|
||||
|
||||
let outcome: BuyOutcome = "error";
|
||||
let packageName = "";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`shop-buy:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const rawId = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawId)) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const article = await prisma.websiteShopArticles.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
costs: true,
|
||||
credits: true,
|
||||
duckets: true,
|
||||
diamonds: true,
|
||||
badges: true,
|
||||
giveRank: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
packageName = article.name;
|
||||
const price = creditPriceFromCosts(article.costs);
|
||||
|
||||
const buyer = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true, rank: true },
|
||||
});
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
const badgeCodes = parseBadgeCodes(article.badges);
|
||||
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
if (price > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
article.giveRank != null &&
|
||||
article.giveRank > 0 &&
|
||||
article.giveRank > buyer.rank
|
||||
) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { rank: article.giveRank },
|
||||
});
|
||||
}
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
"credits",
|
||||
article.credits,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
"duckets",
|
||||
article.duckets,
|
||||
);
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
"diamonds",
|
||||
article.diamonds,
|
||||
);
|
||||
|
||||
for (const code of badgeCodes) {
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
}
|
||||
|
||||
outcome = "bought";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/shop");
|
||||
shopRedirect(safeCategory, outcome, packageName || undefined);
|
||||
}
|
||||
@@ -29,6 +29,14 @@ type ThreadOutcome =
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type ReplyOutcome =
|
||||
| "replied"
|
||||
| "invalid"
|
||||
| "not_found"
|
||||
| "locked"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
function profileRedirect(
|
||||
username: string,
|
||||
outcome: FriendRequestOutcome,
|
||||
@@ -47,6 +55,24 @@ function threadRedirect(guildId: number, outcome: ThreadOutcome): never {
|
||||
redirect(`${base}/new?error=${outcome}`);
|
||||
}
|
||||
|
||||
function threadReplyRedirect(
|
||||
guildId: number,
|
||||
threadId: number,
|
||||
outcome: ReplyOutcome,
|
||||
): never {
|
||||
if (
|
||||
!Number.isInteger(guildId) ||
|
||||
guildId <= 0 ||
|
||||
!Number.isInteger(threadId) ||
|
||||
threadId <= 0
|
||||
) {
|
||||
redirect("/guilds");
|
||||
}
|
||||
const base = `/guilds/${guildId}/forum/${threadId}`;
|
||||
if (outcome === "replied") redirect(`${base}?replied=1`);
|
||||
redirect(`${base}?error=${outcome}`);
|
||||
}
|
||||
|
||||
function isNextRedirect(e: unknown): boolean {
|
||||
return (
|
||||
!!e &&
|
||||
@@ -239,3 +265,102 @@ export async function postThread(formData: FormData): Promise<void> {
|
||||
}
|
||||
threadRedirect(guildId, outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to an existing guild forum thread.
|
||||
*
|
||||
* The AUTHOR (user_id) is re-read from the session via auth() and is never
|
||||
* trusted from the submitted FormData. guildId, threadId, and message come
|
||||
* from the form.
|
||||
*
|
||||
* Appends a row to guilds_forums_comments and bumps the thread's posts_count
|
||||
* and updated_at to match emulator bookkeeping. Locked threads reject replies.
|
||||
*/
|
||||
export async function replyToThread(formData: FormData): Promise<void> {
|
||||
const guildId = Number(formData.get("guildId"));
|
||||
const threadId = Number(formData.get("threadId"));
|
||||
let outcome: ReplyOutcome = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(guildId) ||
|
||||
guildId <= 0 ||
|
||||
!Number.isInteger(threadId) ||
|
||||
threadId <= 0
|
||||
) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`forum-reply:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const thread = await prisma.guildsForumsThreads.findFirst({
|
||||
where: { id: threadId, guildId, state: 0 },
|
||||
select: {
|
||||
id: true,
|
||||
locked: true,
|
||||
postsCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!thread) {
|
||||
outcome = "not_found";
|
||||
} else if (thread.locked) {
|
||||
outcome = "locked";
|
||||
} else {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.guildsForumsComments.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId,
|
||||
message,
|
||||
createdAt: now,
|
||||
state: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.guildsForumsThreads.update({
|
||||
where: { id: thread.id },
|
||||
data: {
|
||||
postsCount: (thread.postsCount ?? 0) + 1,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
outcome = "replied";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isNextRedirect(e)) throw e;
|
||||
outcome = "error";
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isInteger(guildId) &&
|
||||
guildId > 0 &&
|
||||
Number.isInteger(threadId) &&
|
||||
threadId > 0
|
||||
) {
|
||||
revalidatePath(`/guilds/${guildId}/forum`);
|
||||
revalidatePath(`/guilds/${guildId}/forum/${threadId}`);
|
||||
}
|
||||
threadReplyRedirect(guildId, threadId, outcome);
|
||||
}
|
||||
+18
-3
@@ -17,7 +17,12 @@ import {
|
||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||
|
||||
export const createTicket = authAction(
|
||||
{ schema: createTicketSchema },
|
||||
{
|
||||
schema: createTicketSchema,
|
||||
rateLimitKey: "ticket-create",
|
||||
rateLimitMax: 5,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.create({
|
||||
data: {
|
||||
@@ -49,7 +54,12 @@ export const createTicket = authAction(
|
||||
);
|
||||
|
||||
export const userReplyTicket = authAction(
|
||||
{ schema: replyTicketSchema },
|
||||
{
|
||||
schema: replyTicketSchema,
|
||||
rateLimitKey: "ticket-reply",
|
||||
rateLimitMax: 10,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -82,7 +92,12 @@ export const userReplyTicket = authAction(
|
||||
);
|
||||
|
||||
export const closeTicketByUser = authAction(
|
||||
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
||||
{
|
||||
schema: replyTicketSchema.pick({ ticketId: true }),
|
||||
rateLimitKey: "ticket-close",
|
||||
rateLimitMax: 10,
|
||||
rateLimitWindowMs: 60_000,
|
||||
},
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
|
||||
@@ -40,6 +41,14 @@ export async function redeem(
|
||||
};
|
||||
}
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`voucher-redeem:${userId}`, 5, 60_000)).ok) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "You're redeeming too fast. Please wait a moment and try again.",
|
||||
};
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { applyStaff } from "@/actions/applications";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -11,11 +12,45 @@ export const dynamic = "force-dynamic";
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
export default async function ApplyStaffPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ApplyStaffPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ submitted?: string; error?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.applyStaff");
|
||||
const { submitted, error } = await searchParams;
|
||||
|
||||
const errorMessage =
|
||||
error === "empty"
|
||||
? t("errors.empty")
|
||||
: error === "duplicate"
|
||||
? t("errors.duplicate")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
@@ -53,6 +88,17 @@ export default async function ApplyStaffPage() {
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{submitted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.submitted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🛡️"
|
||||
title={t("title", { hotel: hotelName })}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { applyTeam } from "@/actions/applications";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -11,11 +12,45 @@ export const dynamic = "force-dynamic";
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
export default async function ApplyTeamPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ApplyTeamPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ submitted?: string; error?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.applyTeam");
|
||||
const { submitted, error } = await searchParams;
|
||||
|
||||
const errorMessage =
|
||||
error === "empty"
|
||||
? t("errors.empty")
|
||||
: error === "duplicate"
|
||||
? t("errors.duplicate")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
@@ -40,6 +75,17 @@ export default async function ApplyTeamPage() {
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{submitted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.submitted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🤝"
|
||||
title={t("title", { hotel: hotelName })}
|
||||
|
||||
@@ -26,12 +26,14 @@ const BOUGHT_NOTE = (code: string) =>
|
||||
const ERROR_NOTE: Record<string, string> = {
|
||||
invalid: "That badge is no longer available.",
|
||||
credits: "You don't have enough credits to buy this badge.",
|
||||
ratelimit: "You're purchasing too fast. Please wait a moment and try again.",
|
||||
fail: "Something went wrong. Please try again.",
|
||||
};
|
||||
|
||||
function getErrorNote(error: string): string {
|
||||
if (error === "invalid") return ERROR_NOTE.invalid;
|
||||
if (error === "credits") return ERROR_NOTE.credits;
|
||||
if (error === "ratelimit") return ERROR_NOTE.ratelimit;
|
||||
return ERROR_NOTE.fail;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../friends/loading";
|
||||
@@ -0,0 +1,275 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { replyToThread } from "@/actions/social";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type SearchParams = Promise<{ replied?: string; error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function GuildForumThreadPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string; threadId: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.guildForumThread");
|
||||
const { id, threadId: threadIdRaw } = await params;
|
||||
const { replied, error } = await searchParams;
|
||||
const guildId = Number(id);
|
||||
const threadId = Number(threadIdRaw);
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
|
||||
if (!Number.isInteger(threadId) || threadId <= 0) notFound();
|
||||
|
||||
const session = await auth();
|
||||
const viewerId = Number(session?.user?.id);
|
||||
const isLoggedIn = Number.isInteger(viewerId) && viewerId > 0;
|
||||
|
||||
let guild: { id: number; name: string } | null = null;
|
||||
let thread: {
|
||||
id: number;
|
||||
subject: string | null;
|
||||
locked: number | null;
|
||||
pinned: number | null;
|
||||
openerId: number | null;
|
||||
} | null = null;
|
||||
let posts: {
|
||||
id: number;
|
||||
userId: number;
|
||||
message: string;
|
||||
createdAt: number;
|
||||
}[] = [];
|
||||
|
||||
try {
|
||||
[guild, thread] = await Promise.all([
|
||||
prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
prisma.guildsForumsThreads.findFirst({
|
||||
where: { id: threadId, guildId, state: 0 },
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
locked: true,
|
||||
pinned: true,
|
||||
openerId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
guild = null;
|
||||
thread = null;
|
||||
}
|
||||
|
||||
if (!guild || !thread) notFound();
|
||||
|
||||
try {
|
||||
posts = await prisma.guildsForumsComments.findMany({
|
||||
where: { threadId: thread.id, state: 0 },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
message: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
posts = [];
|
||||
}
|
||||
|
||||
const authorIds = Array.from(
|
||||
new Set(posts.map((p) => p.userId).filter((v) => v > 0)),
|
||||
);
|
||||
let users: { id: number; username: string }[] = [];
|
||||
try {
|
||||
users = authorIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
} catch {
|
||||
users = [];
|
||||
}
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const isLocked = !!thread.locked;
|
||||
const canReply = isLoggedIn && !isLocked;
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "locked"
|
||||
? t("errors.locked")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{replied === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.replied")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href={`/guilds/${guild.id}/forum`}>{t("backToForum")}</Link>
|
||||
</p>
|
||||
|
||||
<ContentCard
|
||||
icon="💬"
|
||||
title={thread.subject?.trim() || t("noSubject")}
|
||||
subtitle={t("inGuildForum", {
|
||||
guild: guild.name || t("unnamedGuild"),
|
||||
})}
|
||||
>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||
{thread.pinned ? (
|
||||
<span className="online-badge online">📌 {t("pinned")}</span>
|
||||
) : null}
|
||||
{isLocked ? (
|
||||
<span className="online-badge">🔒 {t("locked")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="📝"
|
||||
title={t("postsTitle")}
|
||||
subtitle={t("postsSubtitle", { count: posts.length })}
|
||||
padded={posts.length === 0}
|
||||
>
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState icon="📝">{t("postsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{posts.map((post) => {
|
||||
const username = usernameById.get(post.userId);
|
||||
return (
|
||||
<li
|
||||
key={post.id}
|
||||
style={{
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border, rgba(0,0,0,0.08))",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.35rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700 }}>
|
||||
{username ? (
|
||||
<Link href={`/u/${username}`}>{username}</Link>
|
||||
) : (
|
||||
<span className="muted">
|
||||
{t("userNumber", { id: post.userId })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{formatTimestamp(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{post.message}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{canReply ? (
|
||||
<ContentCard icon="✍️" title={t("replyTitle")} subtitle={t("replySubtitle")}>
|
||||
<form action={replyToThread}>
|
||||
<input type="hidden" name="guildId" value={String(guild.id)} />
|
||||
<input type="hidden" name="threadId" value={String(thread.id)} />
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
rows={6}
|
||||
placeholder={t("replyPlaceholder")}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("replySubmit")}
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
) : isLocked ? (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t("lockedHint")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t("loginToReply")}{" "}
|
||||
<Link href="/login">{t("loginLink")}</Link>
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function GuildForumLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -156,9 +156,12 @@ export default async function GuildForumPage({
|
||||
return (
|
||||
<tr key={t2.id}>
|
||||
<td style={{ padding: "0.6rem 0.75rem" }}>
|
||||
<span style={{ fontWeight: 700 }}>
|
||||
<Link
|
||||
href={`/guilds/${guild.id}/forum/${t2.id}`}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{t2.subject?.trim() || t("noSubject")}
|
||||
</span>
|
||||
</Link>
|
||||
{t2.pinned ? (
|
||||
<span
|
||||
className="online-badge online"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../friends/loading";
|
||||
@@ -0,0 +1,297 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { closeHelpTicket, replyHelpTicket } from "@/actions/help-tickets";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{
|
||||
replied?: string;
|
||||
closed?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HelpTicketDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.helpTicketDetail");
|
||||
const { id } = await params;
|
||||
const { replied, closed, error } = await searchParams;
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) notFound();
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
let ticket: {
|
||||
id: bigint;
|
||||
userId: number | null;
|
||||
title: string;
|
||||
content: string;
|
||||
open: boolean;
|
||||
createdAt: Date | null;
|
||||
} | null = null;
|
||||
let replies: {
|
||||
id: bigint;
|
||||
userId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
}[] = [];
|
||||
|
||||
try {
|
||||
ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
open: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
ticket = null;
|
||||
}
|
||||
|
||||
if (!ticket || ticket.userId !== userId) notFound();
|
||||
|
||||
try {
|
||||
replies = await prisma.websiteHelpCenterTicketReplies.findMany({
|
||||
where: { ticketId: ticket.id },
|
||||
orderBy: { id: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
replies = [];
|
||||
}
|
||||
|
||||
const authorIds = [
|
||||
...new Set([userId, ...replies.map((r) => r.userId)]),
|
||||
];
|
||||
let users: { id: number; username: string }[] = [];
|
||||
try {
|
||||
users = await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
} catch {
|
||||
users = [];
|
||||
}
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
const openerName =
|
||||
usernameById.get(userId) ?? t("userNumber", { id: userId });
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "not_found"
|
||||
? t("errors.notFound")
|
||||
: error === "closed_ticket"
|
||||
? t("errors.closedTicket")
|
||||
: error === "moderated"
|
||||
? t("errors.moderated")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
type MessageRow = {
|
||||
key: string;
|
||||
authorId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
const messages: MessageRow[] = [
|
||||
{
|
||||
key: "opening",
|
||||
authorId: userId,
|
||||
content: ticket.content,
|
||||
createdAt: ticket.createdAt,
|
||||
},
|
||||
...replies.map((r) => ({
|
||||
key: String(r.id),
|
||||
authorId: r.userId,
|
||||
content: r.content,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{replied === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.replied")}
|
||||
</div>
|
||||
) : null}
|
||||
{closed === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.closed")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link href="/help/tickets">{t("backToList")}</Link>
|
||||
</p>
|
||||
|
||||
<ContentCard icon="🎫" title={ticket.title} subtitle={t("ticketId", { id: String(ticket.id) })}>
|
||||
<span className={`online-badge${ticket.open ? " online" : ""}`}>
|
||||
<span className="online-dot" aria-hidden />
|
||||
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
||||
</span>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="💬"
|
||||
title={t("messagesTitle")}
|
||||
subtitle={t("messagesSubtitle", { count: messages.length })}
|
||||
>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{messages.map((msg, index) => {
|
||||
const username =
|
||||
msg.authorId === userId
|
||||
? openerName
|
||||
: usernameById.get(msg.authorId);
|
||||
return (
|
||||
<li
|
||||
key={msg.key}
|
||||
style={{
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border, rgba(0,0,0,0.08))",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.35rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700 }}>
|
||||
{index === 0 ? t("you") : username ? (
|
||||
<Link href={`/u/${username}`}>{username}</Link>
|
||||
) : (
|
||||
<span className="muted">
|
||||
{t("userNumber", { id: msg.authorId })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{formatDate(msg.createdAt, "datetime", "")}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ContentCard>
|
||||
|
||||
{ticket.open ? (
|
||||
<>
|
||||
<ContentCard
|
||||
icon="✍️"
|
||||
title={t("replyTitle")}
|
||||
subtitle={t("replySubtitle")}
|
||||
>
|
||||
<form action={replyHelpTicket}>
|
||||
<input type="hidden" name="ticketId" value={String(ticket.id)} />
|
||||
<textarea
|
||||
name="content"
|
||||
required
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
placeholder={t("replyPlaceholder")}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("replySubmit")}
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard
|
||||
icon="✅"
|
||||
title={t("closeTitle")}
|
||||
subtitle={t("closeSubtitle")}
|
||||
>
|
||||
<form action={closeHelpTicket}>
|
||||
<input type="hidden" name="ticketId" value={String(ticket.id)} />
|
||||
<button type="submit" className="btn btn-outline">
|
||||
{t("closeSubmit")}
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{t("closedHint")}
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../friends/loading";
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -136,8 +137,12 @@ export default async function HelpTicketsPage({
|
||||
<tbody>
|
||||
{tickets.map((t2) => (
|
||||
<tr key={String(t2.id)}>
|
||||
<td>{String(t2.id)}</td>
|
||||
<td>{t2.title}</td>
|
||||
<td>
|
||||
<Link href={`/help/tickets/${t2.id}`}>{String(t2.id)}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/help/tickets/${t2.id}`}>{t2.title}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`online-badge${t2.open ? " online" : ""}`}>
|
||||
<span className="online-dot" aria-hidden />
|
||||
|
||||
@@ -5,17 +5,20 @@ import RadioPlayerGate from "@/components/public/radio-player-gate";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { TopHeader } from "@/components/top-header";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||
*/
|
||||
export default function SiteLayout({ children }: { children: ReactNode }) {
|
||||
export default async function SiteLayout({ children }: { children: ReactNode }) {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopHeader />
|
||||
<TopHeader session={session} />
|
||||
<SiteHeader />
|
||||
<Navigation />
|
||||
<Navigation session={session} />
|
||||
<main>
|
||||
<div className="mx-auto mt-6 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-6 px-4 py-4 md:mt-0 md:p-6">
|
||||
<div className="col-span-12 min-w-0">
|
||||
|
||||
@@ -2,7 +2,11 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { acceptFriend, declineFriendRequest } from "@/actions/messenger";
|
||||
import {
|
||||
acceptFriend,
|
||||
declineFriendRequest,
|
||||
sendOfflineMessage,
|
||||
} from "@/actions/messenger";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
@@ -14,7 +18,9 @@ export const dynamic = "force-dynamic";
|
||||
type SearchParams = Promise<{
|
||||
accepted?: string;
|
||||
declined?: string;
|
||||
sent?: string;
|
||||
error?: string;
|
||||
send_error?: string;
|
||||
}>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
@@ -49,10 +55,8 @@ export default async function MessagesPage({
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const { accepted, declined, error } = await searchParams;
|
||||
const { accepted, declined, sent, error, send_error } = await searchParams;
|
||||
|
||||
// Pending friend requests addressed to the session user, the most recent
|
||||
// offline messages they've received, and the avatar-imager base URL.
|
||||
let requests: { id: number; userFromId: number }[] = [];
|
||||
let messages: {
|
||||
id: number;
|
||||
@@ -60,10 +64,11 @@ export default async function MessagesPage({
|
||||
message: string;
|
||||
sendedOn: number;
|
||||
}[] = [];
|
||||
let friendships: { userOneId: number; userTwoId: number }[] = [];
|
||||
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||
|
||||
try {
|
||||
const [reqRows, msgRows, base] = await Promise.all([
|
||||
const [reqRows, msgRows, friendRows, base] = await Promise.all([
|
||||
prisma.messengerFriendrequests.findMany({
|
||||
where: { userToId: userId },
|
||||
select: { id: true, userFromId: true },
|
||||
@@ -76,6 +81,12 @@ export default async function MessagesPage({
|
||||
orderBy: { sendedOn: "desc" },
|
||||
take: 50,
|
||||
}),
|
||||
prisma.messengerFriendships.findMany({
|
||||
where: {
|
||||
OR: [{ userOneId: userId }, { userTwoId: userId }],
|
||||
},
|
||||
select: { userOneId: true, userTwoId: true },
|
||||
}),
|
||||
siteSettings.get(
|
||||
"habbo_imaging_url",
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
@@ -83,17 +94,27 @@ export default async function MessagesPage({
|
||||
]);
|
||||
requests = reqRows;
|
||||
messages = msgRows;
|
||||
friendships = friendRows;
|
||||
if (base) imagerBase = base;
|
||||
} catch {
|
||||
requests = [];
|
||||
messages = [];
|
||||
friendships = [];
|
||||
}
|
||||
|
||||
// Resolve usernames/looks for every counterparty referenced above.
|
||||
const friendIds = Array.from(
|
||||
new Set(
|
||||
friendships.map((f) =>
|
||||
f.userOneId === userId ? f.userTwoId : f.userOneId,
|
||||
),
|
||||
),
|
||||
).filter((id) => id > 0 && id !== userId);
|
||||
|
||||
const otherIds = Array.from(
|
||||
new Set([
|
||||
...requests.map((r) => r.userFromId),
|
||||
...messages.map((m) => m.userFromId),
|
||||
...friendIds,
|
||||
]),
|
||||
).filter((id) => id && id > 0);
|
||||
|
||||
@@ -113,6 +134,13 @@ export default async function MessagesPage({
|
||||
}
|
||||
}
|
||||
|
||||
const friendsForSelect = friendIds
|
||||
.map((id) => userById.get(id))
|
||||
.filter((u): u is { id: number; username: string; look: string } =>
|
||||
Boolean(u),
|
||||
)
|
||||
.sort((a, b) => a.username.localeCompare(b.username));
|
||||
|
||||
const errorMessage =
|
||||
error === "not_found"
|
||||
? t("errors.notFound")
|
||||
@@ -124,6 +152,19 @@ export default async function MessagesPage({
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
const sendErrorMessage =
|
||||
send_error === "not_friend"
|
||||
? t("errors.notFriend")
|
||||
: send_error === "empty"
|
||||
? t("errors.emptyMessage")
|
||||
: send_error === "rate_limited"
|
||||
? t("errors.rateLimited")
|
||||
: send_error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: send_error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{accepted === "1" ? (
|
||||
@@ -136,14 +177,71 @@ export default async function MessagesPage({
|
||||
{t("success.declined")}
|
||||
</div>
|
||||
) : null}
|
||||
{sent === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.sent")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{sendErrorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{sendErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="✉️" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard
|
||||
icon="📤"
|
||||
title={t("composeTitle")}
|
||||
subtitle={t("composeSubtitle")}
|
||||
padded={friendsForSelect.length === 0}
|
||||
>
|
||||
{friendsForSelect.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("composeEmptyFriends")}</EmptyState>
|
||||
) : (
|
||||
<form
|
||||
action={sendOfflineMessage}
|
||||
className="grid"
|
||||
style={{ padding: "1rem", gap: "0.75rem", maxWidth: "36rem" }}
|
||||
>
|
||||
<label className="grid" style={{ gap: "0.35rem" }}>
|
||||
<span className="muted">{t("composeFriendLabel")}</span>
|
||||
<select name="friendId" required defaultValue="">
|
||||
<option value="" disabled>
|
||||
{t("composeFriendPlaceholder")}
|
||||
</option>
|
||||
{friendsForSelect.map((friend) => (
|
||||
<option key={friend.id} value={String(friend.id)}>
|
||||
{friend.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid" style={{ gap: "0.35rem" }}>
|
||||
<span className="muted">{t("composeMessageLabel")}</span>
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
placeholder={t("composeMessagePlaceholder")}
|
||||
style={{ resize: "vertical" }}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
{t("composeSend")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Pending friend requests ───────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🙋"
|
||||
|
||||
@@ -16,7 +16,7 @@ import { sanitize } from "@/lib/sanitize";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type SearchParams = Promise<{ comment?: string; error?: string }>;
|
||||
type SearchParams = Promise<{ comment?: string; reaction?: string; error?: string }>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
@@ -87,7 +87,7 @@ export default async function ArticlePage({
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { comment, error } = await searchParams;
|
||||
const { comment, reaction, error } = await searchParams;
|
||||
const t = await getTranslations("pages.article");
|
||||
|
||||
const article = await cacheQuery(
|
||||
@@ -189,6 +189,11 @@ export default async function ArticlePage({
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
{reaction === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.reaction")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
@@ -240,6 +245,7 @@ export default async function ArticlePage({
|
||||
name="articleId"
|
||||
value={String(articleId)}
|
||||
/>
|
||||
<input type="hidden" name="slug" value={article.slug} />
|
||||
<input type="hidden" name="reaction" value={r.key} />
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../friends/loading";
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { applyDj } from "@/actions/radio-apply";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
@@ -7,11 +8,41 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RadioApplyPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RadioApplyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ submitted?: string; error?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const t = await getTranslations("pages.radioApply");
|
||||
const { submitted, error } = await searchParams;
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
@@ -31,6 +62,17 @@ export default async function RadioApplyPage() {
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{submitted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.submitted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{existing ? (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../friends/loading";
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { submitRequest } from "@/actions/radio-requests";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
@@ -7,10 +8,43 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ posted?: string; error?: string }>;
|
||||
|
||||
export default async function RadioRequestsPage() {
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RadioRequestsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.radioRequests");
|
||||
const genericT = await getTranslations("pages.radio");
|
||||
const { posted, error } = await searchParams;
|
||||
|
||||
const errorMessage =
|
||||
error === "empty"
|
||||
? t("errors.empty")
|
||||
: error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
const [requests, session] = await Promise.all([
|
||||
prisma.radioSongRequests
|
||||
@@ -37,6 +71,17 @@ export default async function RadioRequestsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{posted === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.posted")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🎵"
|
||||
title={genericT("linkRequests")}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { signOutEverywhere } from "@/actions/sessions";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Login activity" };
|
||||
|
||||
type SearchParams = Promise<{ signedOutAll?: string }>;
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
|
||||
function device(ua: string | null): string {
|
||||
if (!ua) return "Unknown device";
|
||||
function deviceLabel(ua: string | null, unknown: string): string {
|
||||
if (!ua) return unknown;
|
||||
const browser = /Edg/.test(ua)
|
||||
? "Edge"
|
||||
: /OPR|Opera/.test(ua)
|
||||
@@ -38,10 +41,21 @@ function device(ua: string | null): string {
|
||||
return os ? `${browser} on ${os}` : browser;
|
||||
}
|
||||
|
||||
export default async function SessionsPage() {
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("pages.sessions");
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
export default async function SessionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const t = await getTranslations("pages.sessions");
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
const userId = Number(session.user.id);
|
||||
const { signedOutAll } = await searchParams;
|
||||
|
||||
const logins = await prisma.websiteLoginLogs
|
||||
.findMany({
|
||||
@@ -52,35 +66,57 @@ export default async function SessionsPage() {
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
// Failed attempts on this username (failed_logins is keyless → raw query).
|
||||
type Failed = { ip_address: string; attempted_at: Date | string };
|
||||
type Failed = { id: number; ip_address: string; attempted_at: Date | string };
|
||||
const failed = await prisma.$queryRaw<Failed[]>`
|
||||
SELECT ip_address, attempted_at FROM failed_logins
|
||||
SELECT id, ip_address, attempted_at FROM failed_logins
|
||||
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15
|
||||
`.catch(() => [] as Failed[]);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{signedOutAll === "1" ? (
|
||||
<p
|
||||
role="status"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-primary)",
|
||||
borderLeft: "4px solid var(--color-primary)",
|
||||
}}
|
||||
>
|
||||
{t("successSignedOutAll")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🔐"
|
||||
title="Login activity"
|
||||
subtitle="Recent sign-ins and failed attempts on your account"
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
/>
|
||||
|
||||
<ContentCard
|
||||
icon="✅"
|
||||
title="Recent sign-ins"
|
||||
padded={logins.length === 0}
|
||||
icon="🚪"
|
||||
title={t("signOutEverywhereTitle")}
|
||||
subtitle={t("signOutEverywhereDescription")}
|
||||
>
|
||||
<form action={signOutEverywhere} style={{ padding: "1rem" }}>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("signOutEverywhereButton")}
|
||||
</button>
|
||||
</form>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard icon="✅" title={t("recentTitle")} padded={logins.length === 0}>
|
||||
{logins.length === 0 ? (
|
||||
<EmptyState icon="🔐">No sign-ins recorded yet.</EmptyState>
|
||||
<EmptyState icon="🔐">{t("recentEmpty")}</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>IP</th>
|
||||
<th>Device</th>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colIp")}</th>
|
||||
<th>{t("colDevice")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -90,7 +126,7 @@ export default async function SessionsPage() {
|
||||
{fromDate(l.createdAt)}
|
||||
</td>
|
||||
<td className="muted">{l.ip}</td>
|
||||
<td>{device(l.userAgent)}</td>
|
||||
<td>{deviceLabel(l.userAgent, t("unknownDevice"))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -101,19 +137,19 @@ export default async function SessionsPage() {
|
||||
{failed.length > 0 ? (
|
||||
<ContentCard
|
||||
icon="⚠️"
|
||||
title="Failed attempts"
|
||||
subtitle="Wrong password on your username"
|
||||
title={t("failedTitle")}
|
||||
subtitle={t("failedSubtitle")}
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>IP</th>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colIp")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{failed.map((f) => (
|
||||
<tr key={f.ip_address}>
|
||||
<tr key={f.id}>
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{typeof f.attempted_at === "string"
|
||||
? f.attempted_at.slice(0, 16).replace("T", " ")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function ShopLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
|
||||
>
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-4 rounded-full"
|
||||
style={{
|
||||
borderColor: "var(--color-primary)",
|
||||
borderTopColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { buyShopArticle } from "@/actions/shop";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
export const metadata = { title: "Shop" };
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
|
||||
return {
|
||||
margin: 0,
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
borderLeft: `4px solid ${accent}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Faithful to AtomCMS WebsiteShopArticle::price(): costs are stored in cents,
|
||||
// the displayed price is costs/100 with a floor of 1.
|
||||
function priceLabel(costs: number): string {
|
||||
@@ -15,6 +35,13 @@ function priceLabel(costs: number): string {
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function creditPriceLabel(costs: number): string {
|
||||
const rate = creditsPerUnit();
|
||||
const dollars = costs < 100 ? 1 : costs / 100;
|
||||
const credits = Math.max(1, Math.floor(dollars * rate));
|
||||
return credits.toLocaleString();
|
||||
}
|
||||
|
||||
type ShopArticle = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
@@ -52,13 +79,34 @@ const ARTICLE_SELECT = {
|
||||
export default async function ShopPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ category?: string }>;
|
||||
searchParams: Promise<{ category?: string; bought?: string; error?: string; package?: string }>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.shop");
|
||||
const sp = await searchParams;
|
||||
const rawCategory = (sp.category ?? "").trim();
|
||||
const categoryId = /^\d+$/.test(rawCategory) ? rawCategory : "";
|
||||
|
||||
const session = await auth();
|
||||
const isLoggedIn = Boolean(session?.user?.id);
|
||||
|
||||
const errorMessage =
|
||||
sp.error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: sp.error === "credits"
|
||||
? t("errors.credits")
|
||||
: sp.error === "ratelimit"
|
||||
? t("errors.ratelimit")
|
||||
: sp.error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
const boughtMessage =
|
||||
sp.bought === "1"
|
||||
? sp.package
|
||||
? t("success.boughtNamed", { name: sp.package })
|
||||
: t("success.bought")
|
||||
: null;
|
||||
|
||||
// Categories for the filter bar.
|
||||
let categories: ShopCategory[] = [];
|
||||
try {
|
||||
@@ -110,6 +158,23 @@ export default async function ShopPage({
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{boughtMessage ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{boughtMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
{sp.error === "credits" ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link href="/shop/topup">{t("topUpLink")}</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard
|
||||
icon="🛒"
|
||||
title={t("title")}
|
||||
@@ -119,6 +184,10 @@ export default async function ShopPage({
|
||||
: t("subtitle")
|
||||
}
|
||||
>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
{t("creditsHint")}{" "}
|
||||
<Link href="/shop/topup">{t("topUpLink")}</Link>
|
||||
</p>
|
||||
{categories.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -184,8 +253,6 @@ export default async function ShopPage({
|
||||
}}
|
||||
>
|
||||
{a.iconUrl ? (
|
||||
// Article icons are arbitrary external/Imgur URLs from the
|
||||
// emulator data, so a plain <img> is correct here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.iconUrl}
|
||||
@@ -198,7 +265,12 @@ export default async function ShopPage({
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 style={{ margin: 0 }}>{a.name}</h3>
|
||||
<span className="muted">{priceLabel(a.costs)}</span>
|
||||
<span className="muted">
|
||||
{t("priceCredits", {
|
||||
credits: creditPriceLabel(a.costs),
|
||||
usd: priceLabel(a.costs),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,10 +312,24 @@ export default async function ShopPage({
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 800 }}>{priceLabel(a.costs)}</span>
|
||||
<span className="btn btn-primary" aria-disabled="true">
|
||||
{t("buy")}
|
||||
<span style={{ fontWeight: 800 }}>
|
||||
{t("priceCreditsShort", {
|
||||
credits: creditPriceLabel(a.costs),
|
||||
})}
|
||||
</span>
|
||||
{isLoggedIn ? (
|
||||
<form action={buyShopArticle} style={{ margin: 0 }}>
|
||||
<input type="hidden" name="articleId" value={String(a.id)} />
|
||||
<input type="hidden" name="categoryId" value={categoryId} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("buy")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Link href="/login" className="btn btn-primary">
|
||||
{t("loginToBuy")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { deleteAd, updateAd } from "@/actions/admin-ads";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { updateAd } from "@/actions/admin-ads";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { EditAdDeleteButton } from "../ads-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -26,6 +28,8 @@ export default async function EditAd({
|
||||
}
|
||||
if (!ad) notFound();
|
||||
|
||||
const t = await getTranslations("pages.admin.ads");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -34,10 +38,10 @@ export default async function EditAd({
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
Edit advertisement
|
||||
{t("edit")} #{String(ad.id)}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
<Link href="/admin/ads">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,20 +50,17 @@ export default async function EditAd({
|
||||
<input
|
||||
name="image"
|
||||
defaultValue={ad.image}
|
||||
placeholder="Image URL"
|
||||
placeholder={t("form.imagePlaceholder")}
|
||||
required
|
||||
maxLength={255}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteAd} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete advertisement
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</form>
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<EditAdDeleteButton id={String(ad.id)} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deleteAd } from "@/actions/admin-ads";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export type AdRow = {
|
||||
id: string;
|
||||
image: string;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export function AdsTable({ ads }: { ads: AdRow[] }) {
|
||||
const t = useTranslations("pages.admin.ads");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
async function handleDelete(row: AdRow) {
|
||||
const ok = await confirm({
|
||||
title: t("delete"),
|
||||
description: t("confirmDelete"),
|
||||
confirmLabel: t("delete"),
|
||||
cancelLabel: t("form.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteAd({ id: row.id }), {
|
||||
successMessage: t("deleteSuccess"),
|
||||
errorMessage: t("deleteError"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colPreview")}</th>
|
||||
<th>{t("colImage")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th>{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={ad.id}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ad.image}
|
||||
alt=""
|
||||
style={{ maxHeight: 40, maxWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{ad.createdAt ?? "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm inline-flex items-center gap-1"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete(ad)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("delete")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditAdDeleteButton({ id }: { id: string }) {
|
||||
const t = useTranslations("pages.admin.ads");
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirm({
|
||||
title: t("delete"),
|
||||
description: t("confirmDelete"),
|
||||
confirmLabel: t("delete"),
|
||||
cancelLabel: t("form.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteAd({ id }), {
|
||||
successMessage: t("deleteSuccess"),
|
||||
errorMessage: t("deleteError"),
|
||||
redirectTo: "/admin/ads",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t("form.deleteAd")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+10
-35
@@ -4,6 +4,7 @@ import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { AdsTable } from "./ads-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -51,41 +52,15 @@ export default async function AdminAds() {
|
||||
{ads.length === 0 ? (
|
||||
<div className="admin-empty">{t("noAds")}</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colPreview")}</th>
|
||||
<th>{t("colImage")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ad.image}
|
||||
alt=""
|
||||
style={{ maxHeight: 40, maxWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{ad.createdAt
|
||||
? ad.createdAt.toISOString().slice(0, 10)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<AdsTable
|
||||
ads={ads.map((ad) => ({
|
||||
id: String(ad.id),
|
||||
image: ad.image,
|
||||
createdAt: ad.createdAt
|
||||
? ad.createdAt.toISOString().slice(0, 10)
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { MobileNav } from "@/components/mobile-nav";
|
||||
import { NavDropdown } from "@/components/nav-dropdown";
|
||||
import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export async function Navigation() {
|
||||
const session = await auth();
|
||||
export async function Navigation({ session }: { session: Session | null }) {
|
||||
const t = await getTranslations("nav");
|
||||
const isStaff = (session?.user?.rank ?? 0) >= 7;
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function SiteHeader() {
|
||||
const t = await getTranslations("header");
|
||||
const [_session, hotelName, header, logo] = await Promise.all([
|
||||
auth(),
|
||||
const [hotelName, header, logo] = await Promise.all([
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
siteSettings.get("cms_header", "/assets/images/background.png"),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
import type { Session } from "next-auth";
|
||||
import { signOut } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -37,8 +38,7 @@ function Currency({
|
||||
);
|
||||
}
|
||||
|
||||
export async function TopHeader() {
|
||||
const session = await auth();
|
||||
export async function TopHeader({ session }: { session: Session | null }) {
|
||||
if (!session?.user?.id) return null;
|
||||
const id = Number(session.user.id);
|
||||
|
||||
|
||||
+56
-4
@@ -135,7 +135,12 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return { id: String(user.id), name: user.username, rank: user.rank };
|
||||
return {
|
||||
id: String(user.id),
|
||||
name: user.username,
|
||||
rank: user.rank,
|
||||
jwtVersion: user.websiteJwtVersion,
|
||||
};
|
||||
},
|
||||
}),
|
||||
// OAuth providers — enabled only when both id + secret are configured.
|
||||
@@ -200,6 +205,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
return "/login?error=NoAccount";
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
if (user) {
|
||||
token.jwtVersion =
|
||||
(user as { jwtVersion?: number }).jwtVersion ??
|
||||
token.jwtVersion ??
|
||||
0;
|
||||
}
|
||||
|
||||
if (user && account?.provider === "credentials") {
|
||||
token.rank = (user as { rank?: number }).rank;
|
||||
return token;
|
||||
@@ -228,12 +240,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
if (linked) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(linked.userId) },
|
||||
select: { id: true, rank: true, username: true },
|
||||
select: {
|
||||
id: true,
|
||||
rank: true,
|
||||
username: true,
|
||||
websiteJwtVersion: true,
|
||||
},
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
token.jwtVersion = dbUser.websiteJwtVersion;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -247,22 +265,56 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: { id: true, rank: true, username: true },
|
||||
select: {
|
||||
id: true,
|
||||
rank: true,
|
||||
username: true,
|
||||
websiteJwtVersion: true,
|
||||
},
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
token.jwtVersion = dbUser.websiteJwtVersion;
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
if (token.sub && !token.invalid) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(token.sub) },
|
||||
select: {
|
||||
websiteJwtVersion: true,
|
||||
rank: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!dbUser ||
|
||||
(token.jwtVersion ?? 0) !== dbUser.websiteJwtVersion
|
||||
) {
|
||||
token.invalid = true;
|
||||
delete token.sub;
|
||||
return token;
|
||||
}
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
} catch {
|
||||
/* keep session on transient DB errors */
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (token.sub && session.user) session.user.id = token.sub;
|
||||
if (token.invalid || !token.sub) {
|
||||
return session;
|
||||
}
|
||||
if (session.user) session.user.id = token.sub;
|
||||
if (typeof token.rank === "number" && session.user)
|
||||
session.user.rank = token.rank;
|
||||
return session;
|
||||
|
||||
+156
-10
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"title": "Messages",
|
||||
"subtitle": "Friend requests and offline messages waiting for you",
|
||||
"subtitle": "Friend requests, offline inbox, and messages to friends",
|
||||
"requestsTitle": "Friend requests",
|
||||
"requestsEmptySubtitle": "Nobody's asked to be your friend yet",
|
||||
"requestsSubtitle": "{count, plural, one {# pending request} other {# pending requests}}",
|
||||
@@ -124,18 +124,46 @@
|
||||
"offlineEmptySubtitle": "Your inbox is empty",
|
||||
"offlineSubtitle": "{count, plural, one {# message received} other {# messages received}}",
|
||||
"offlineEmptyState": "You have no offline messages.",
|
||||
"composeTitle": "Send offline message",
|
||||
"composeSubtitle": "Delivered in the hotel messenger when your friend is offline",
|
||||
"composeEmptyFriends": "Add friends in the hotel to send offline messages.",
|
||||
"composeFriendLabel": "Friend",
|
||||
"composeFriendPlaceholder": "Choose a friend",
|
||||
"composeMessageLabel": "Message",
|
||||
"composeMessagePlaceholder": "Write your message (max 500 characters)…",
|
||||
"composeSend": "Send message",
|
||||
"unknownUser": "User #{id}",
|
||||
"success": {
|
||||
"accepted": "Friend request accepted.",
|
||||
"declined": "Friend request declined."
|
||||
"declined": "Friend request declined.",
|
||||
"sent": "Offline message sent."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "That friend request was not found.",
|
||||
"unauthorized": "You can't act on this friend request.",
|
||||
"invalid": "Invalid friend request.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"notFriend": "You can only message friends.",
|
||||
"emptyMessage": "Please write a message.",
|
||||
"rateLimited": "Too many messages. Wait a moment and try again."
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Login activity",
|
||||
"subtitle": "Recent sign-ins and failed attempts on your account",
|
||||
"recentTitle": "Recent sign-ins",
|
||||
"recentEmpty": "No sign-ins recorded yet.",
|
||||
"colWhen": "When",
|
||||
"colIp": "IP",
|
||||
"colDevice": "Device",
|
||||
"failedTitle": "Failed attempts",
|
||||
"failedSubtitle": "Wrong password on your username",
|
||||
"unknownDevice": "Unknown device",
|
||||
"signOutEverywhereTitle": "Sign out everywhere",
|
||||
"signOutEverywhereDescription": "Ends all website sessions on every device. You will need to sign in again here too.",
|
||||
"signOutEverywhereButton": "Sign out all sessions",
|
||||
"successSignedOutAll": "All sessions have been signed out."
|
||||
},
|
||||
"profile": {
|
||||
"noMotto": "No motto",
|
||||
"statRank": "Rank",
|
||||
@@ -237,7 +265,8 @@
|
||||
"postComment": "Post comment",
|
||||
"toComment": "to leave a comment.",
|
||||
"success": {
|
||||
"posted": "Comment posted."
|
||||
"posted": "Comment posted.",
|
||||
"reaction": "Reaction updated."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Please write a comment before posting.",
|
||||
@@ -353,6 +382,35 @@
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"guildForumThread": {
|
||||
"backToForum": "← Back to forum",
|
||||
"unnamedGuild": "Unnamed guild",
|
||||
"noSubject": "(no subject)",
|
||||
"inGuildForum": "{guild} forum",
|
||||
"pinned": "Pinned",
|
||||
"locked": "Locked",
|
||||
"postsTitle": "Posts",
|
||||
"postsSubtitle": "{count, plural, one {# post} other {# posts}}",
|
||||
"postsEmpty": "No posts in this thread.",
|
||||
"userNumber": "User #{id}",
|
||||
"replyTitle": "Reply",
|
||||
"replySubtitle": "Add your message to this thread",
|
||||
"replyPlaceholder": "Write your reply…",
|
||||
"replySubmit": "Post reply",
|
||||
"lockedHint": "This thread is locked — new replies are disabled.",
|
||||
"loginToReply": "Sign in to reply.",
|
||||
"loginLink": "Log in",
|
||||
"success": {
|
||||
"replied": "Reply posted."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Please enter a message.",
|
||||
"notFound": "That thread was not found.",
|
||||
"locked": "This thread is locked.",
|
||||
"ratelimit": "You're posting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
"title": "Shop",
|
||||
"subtitle": "Top up your account and grab exclusive packages, currency and badges.",
|
||||
@@ -361,7 +419,22 @@
|
||||
"allPackages": "All packages",
|
||||
"packagesAvailable": "{count, plural, one {# package available} other {# packages available}}",
|
||||
"empty": "No packages available right now.",
|
||||
"buy": "Buy"
|
||||
"buy": "Buy",
|
||||
"loginToBuy": "Log in to buy",
|
||||
"creditsHint": "Packages are paid with in-game credits. Top up first if you need more.",
|
||||
"topUpLink": "Top up credits",
|
||||
"priceCredits": "{credits} credits (≈ {usd})",
|
||||
"priceCreditsShort": "{credits} cr",
|
||||
"success": {
|
||||
"bought": "Package purchased — rewards have been added to your account.",
|
||||
"boughtNamed": "You purchased \"{name}\" — rewards have been added to your account."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "That package is no longer available.",
|
||||
"credits": "You don't have enough credits for this package.",
|
||||
"ratelimit": "You're purchasing too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"topup": {
|
||||
"title": "Top up with PayPal",
|
||||
@@ -476,6 +549,36 @@
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"helpTicketDetail": {
|
||||
"backToList": "← Your tickets",
|
||||
"ticketId": "Ticket #{id}",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"messagesTitle": "Conversation",
|
||||
"messagesSubtitle": "{count, plural, one {# message} other {# messages}}",
|
||||
"you": "You",
|
||||
"userNumber": "User #{id}",
|
||||
"replyTitle": "Reply",
|
||||
"replySubtitle": "Send a follow-up to staff",
|
||||
"replyPlaceholder": "Write your message…",
|
||||
"replySubmit": "Send reply",
|
||||
"closeTitle": "Close ticket",
|
||||
"closeSubtitle": "Mark this request as resolved when you no longer need help",
|
||||
"closeSubmit": "Close ticket",
|
||||
"closedHint": "This ticket is closed. Open a new ticket if you need more help.",
|
||||
"success": {
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Please enter a message.",
|
||||
"notFound": "That ticket was not found.",
|
||||
"closedTicket": "This ticket is closed.",
|
||||
"moderated": "That message was blocked by moderation.",
|
||||
"ratelimit": "You're submitting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"applyStaff": {
|
||||
"title": "Apply for {hotel} staff",
|
||||
"subtitle": "We open staff applications every now and then. If you come across a position you feel you would fit perfectly into, do not hesitate to apply for it.",
|
||||
@@ -486,7 +589,17 @@
|
||||
"alreadyApplied": "You have already applied for {rank}",
|
||||
"aboutYou": "About you",
|
||||
"aboutPlaceholder": "Tell us why you'd be a great {rank}…",
|
||||
"applyFor": "Apply for {rank}"
|
||||
"applyFor": "Apply for {rank}",
|
||||
"success": {
|
||||
"submitted": "Application submitted — staff will review it soon."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Please write at least 10 characters about yourself.",
|
||||
"invalid": "Invalid application.",
|
||||
"duplicate": "You have already applied for this position.",
|
||||
"ratelimit": "You're submitting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"applyTeam": {
|
||||
"title": "Apply for the {hotel} team",
|
||||
@@ -496,7 +609,17 @@
|
||||
"pending": "Your application is pending",
|
||||
"aboutYou": "About you",
|
||||
"aboutPlaceholder": "Tell us why you'd be a great fit for {rank}…",
|
||||
"applyFor": "Apply for {rank}"
|
||||
"applyFor": "Apply for {rank}",
|
||||
"success": {
|
||||
"submitted": "Application submitted — staff will review it soon."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Please write at least 10 characters about yourself.",
|
||||
"invalid": "Invalid application.",
|
||||
"duplicate": "You have already applied for this team.",
|
||||
"ratelimit": "You're submitting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"title": "Radio",
|
||||
@@ -656,7 +779,16 @@
|
||||
"colStatus": "Status",
|
||||
"statusPending": "Pending",
|
||||
"statusApproved": "Approved",
|
||||
"statusPlayed": "Played"
|
||||
"statusPlayed": "Played",
|
||||
"success": {
|
||||
"posted": "Request submitted."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Please enter a song title or artist.",
|
||||
"invalid": "Invalid request.",
|
||||
"ratelimit": "You're submitting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"title": "Nitro client",
|
||||
@@ -694,7 +826,15 @@
|
||||
"experiencePlaceholder": "Any prior DJ or hosting experience?",
|
||||
"motivationLabel": "Motivation",
|
||||
"motivationPlaceholder": "Why do you want to join the radio team?",
|
||||
"submitButton": "Submit application"
|
||||
"submitButton": "Submit application",
|
||||
"success": {
|
||||
"submitted": "Application submitted — we will get back to you soon."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Please fill in all required fields.",
|
||||
"ratelimit": "You're submitting too fast. Please wait a moment and try again.",
|
||||
"error": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"accountSettings": "Account settings",
|
||||
@@ -1165,7 +1305,13 @@
|
||||
"delete": "Delete",
|
||||
"noAds": "No advertisements yet",
|
||||
"confirmDelete": "Delete this ad?",
|
||||
"colId": "ID",
|
||||
"deleteSuccess": "Advertisement deleted!",
|
||||
"deleteError": "Failed to delete advertisement.",
|
||||
"adsCount": "Total ads",
|
||||
"latest": "Latest",
|
||||
"mostRecentAd": "Most recent ad",
|
||||
"allAds": "All advertisements",
|
||||
"colPreview": "Preview",
|
||||
"colName": "Name",
|
||||
"colImage": "Image",
|
||||
"colUrl": "URL",
|
||||
|
||||
+174
-27
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"title": "Messaggi",
|
||||
"subtitle": "Richieste di amicizia e messaggi offline in attesa per te",
|
||||
"subtitle": "Richieste di amicizia, inbox offline e messaggi agli amici",
|
||||
"requestsTitle": "Richieste di amicizia",
|
||||
"requestsEmptySubtitle": "Nessuno ti ha ancora chiesto l'amicizia",
|
||||
"requestsSubtitle": "{count, plural, one {# richiesta in sospeso} other {# richieste in sospeso}}",
|
||||
@@ -124,18 +124,46 @@
|
||||
"offlineEmptySubtitle": "La tua casella è vuota",
|
||||
"offlineSubtitle": "{count, plural, one {# messaggio ricevuto} other {# messaggi ricevuti}}",
|
||||
"offlineEmptyState": "Non hai messaggi offline.",
|
||||
"composeTitle": "Invia messaggio offline",
|
||||
"composeSubtitle": "Consegnato nel messenger dell'hotel quando l'amico è offline",
|
||||
"composeEmptyFriends": "Aggiungi amici in hotel per inviare messaggi offline.",
|
||||
"composeFriendLabel": "Amico",
|
||||
"composeFriendPlaceholder": "Scegli un amico",
|
||||
"composeMessageLabel": "Messaggio",
|
||||
"composeMessagePlaceholder": "Scrivi il messaggio (max 500 caratteri)…",
|
||||
"composeSend": "Invia messaggio",
|
||||
"unknownUser": "Utente #{id}",
|
||||
"success": {
|
||||
"accepted": "Richiesta di amicizia accettata.",
|
||||
"declined": "Richiesta di amicizia rifiutata."
|
||||
"declined": "Richiesta di amicizia rifiutata.",
|
||||
"sent": "Messaggio offline inviato."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Quella richiesta di amicizia non è stata trovata.",
|
||||
"unauthorized": "Non puoi gestire questa richiesta di amicizia.",
|
||||
"invalid": "Richiesta di amicizia non valida.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
"error": "Qualcosa è andato storto. Riprova.",
|
||||
"notFriend": "Puoi scrivere solo agli amici.",
|
||||
"emptyMessage": "Scrivi un messaggio.",
|
||||
"rateLimited": "Troppi messaggi. Attendi un momento e riprova."
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Attività di accesso",
|
||||
"subtitle": "Accessi recenti e tentativi falliti sul tuo account",
|
||||
"recentTitle": "Accessi recenti",
|
||||
"recentEmpty": "Nessun accesso registrato.",
|
||||
"colWhen": "Quando",
|
||||
"colIp": "IP",
|
||||
"colDevice": "Dispositivo",
|
||||
"failedTitle": "Tentativi falliti",
|
||||
"failedSubtitle": "Password errata per il tuo username",
|
||||
"unknownDevice": "Dispositivo sconosciuto",
|
||||
"signOutEverywhereTitle": "Esci ovunque",
|
||||
"signOutEverywhereDescription": "Termina tutte le sessioni del sito su ogni dispositivo. Dovrai accedere di nuovo anche qui.",
|
||||
"signOutEverywhereButton": "Esci da tutte le sessioni",
|
||||
"successSignedOutAll": "Tutte le sessioni sono state terminate."
|
||||
},
|
||||
"profile": {
|
||||
"noMotto": "Nessun motto",
|
||||
"statRank": "Ruolo",
|
||||
@@ -237,7 +265,8 @@
|
||||
"postComment": "Pubblica commento",
|
||||
"toComment": "per lasciare un commento.",
|
||||
"success": {
|
||||
"posted": "Commento pubblicato."
|
||||
"posted": "Commento pubblicato.",
|
||||
"reaction": "Reazione aggiornata."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Scrivi un commento prima di pubblicare.",
|
||||
@@ -353,6 +382,35 @@
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"guildForumThread": {
|
||||
"backToForum": "← Torna al forum",
|
||||
"unnamedGuild": "Gilda senza nome",
|
||||
"noSubject": "(senza oggetto)",
|
||||
"inGuildForum": "Forum di {guild}",
|
||||
"pinned": "In evidenza",
|
||||
"locked": "Bloccata",
|
||||
"postsTitle": "Messaggi",
|
||||
"postsSubtitle": "{count, plural, one {# messaggio} other {# messaggi}}",
|
||||
"postsEmpty": "Nessun messaggio in questa discussione.",
|
||||
"userNumber": "Utente #{id}",
|
||||
"replyTitle": "Rispondi",
|
||||
"replySubtitle": "Aggiungi un messaggio a questa discussione",
|
||||
"replyPlaceholder": "Scrivi la tua risposta…",
|
||||
"replySubmit": "Pubblica risposta",
|
||||
"lockedHint": "Questa discussione è bloccata: non è possibile rispondere.",
|
||||
"loginToReply": "Accedi per rispondere.",
|
||||
"loginLink": "Accedi",
|
||||
"success": {
|
||||
"replied": "Risposta pubblicata."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Inserisci un messaggio.",
|
||||
"notFound": "Discussione non trovata.",
|
||||
"locked": "Questa discussione è bloccata.",
|
||||
"ratelimit": "Stai pubblicando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
"title": "Negozio",
|
||||
"subtitle": "Ricarica il tuo account e accaparrati pacchetti esclusivi, valute e distintivi.",
|
||||
@@ -361,7 +419,22 @@
|
||||
"allPackages": "Tutti i pacchetti",
|
||||
"packagesAvailable": "{count, plural, one {# pacchetto disponibile} other {# pacchetti disponibili}}",
|
||||
"empty": "Al momento non ci sono pacchetti disponibili.",
|
||||
"buy": "Acquista"
|
||||
"buy": "Acquista",
|
||||
"loginToBuy": "Accedi per acquistare",
|
||||
"creditsHint": "I pacchetti si pagano con crediti di gioco. Ricarica prima se ti servono altri crediti.",
|
||||
"topUpLink": "Ricarica crediti",
|
||||
"priceCredits": "{credits} crediti (≈ {usd})",
|
||||
"priceCreditsShort": "{credits} cr",
|
||||
"success": {
|
||||
"bought": "Pacchetto acquistato — le ricompense sono state aggiunte al tuo account.",
|
||||
"boughtNamed": "Hai acquistato \"{name}\" — le ricompense sono state aggiunte al tuo account."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Questo pacchetto non è più disponibile.",
|
||||
"credits": "Non hai abbastanza crediti per questo pacchetto.",
|
||||
"ratelimit": "Stai acquistando troppo velocemente. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"topup": {
|
||||
"title": "Ricarica con PayPal",
|
||||
@@ -476,6 +549,36 @@
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"helpTicketDetail": {
|
||||
"backToList": "← I tuoi ticket",
|
||||
"ticketId": "Ticket #{id}",
|
||||
"statusOpen": "Aperto",
|
||||
"statusClosed": "Chiuso",
|
||||
"messagesTitle": "Conversazione",
|
||||
"messagesSubtitle": "{count, plural, one {# messaggio} other {# messaggi}}",
|
||||
"you": "Tu",
|
||||
"userNumber": "Utente #{id}",
|
||||
"replyTitle": "Rispondi",
|
||||
"replySubtitle": "Invia un aggiornamento allo staff",
|
||||
"replyPlaceholder": "Scrivi il tuo messaggio…",
|
||||
"replySubmit": "Invia risposta",
|
||||
"closeTitle": "Chiudi ticket",
|
||||
"closeSubtitle": "Segna la richiesta come risolta se non ti serve più aiuto",
|
||||
"closeSubmit": "Chiudi ticket",
|
||||
"closedHint": "Questo ticket è chiuso. Apri un nuovo ticket se hai bisogno di altro aiuto.",
|
||||
"success": {
|
||||
"replied": "Risposta inviata.",
|
||||
"closed": "Ticket chiuso."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Inserisci un messaggio.",
|
||||
"notFound": "Ticket non trovato.",
|
||||
"closedTicket": "Questo ticket è chiuso.",
|
||||
"moderated": "Quel messaggio è stato bloccato dalla moderazione.",
|
||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"applyStaff": {
|
||||
"title": "Candidati per lo staff di {hotel}",
|
||||
"subtitle": "Apriamo le candidature per lo staff di tanto in tanto. Se trovi una posizione che senti perfetta per te, non esitare a candidarti.",
|
||||
@@ -486,7 +589,17 @@
|
||||
"alreadyApplied": "Ti sei già candidato per {rank}",
|
||||
"aboutYou": "Parlaci di te",
|
||||
"aboutPlaceholder": "Raccontaci perché saresti un ottimo {rank}…",
|
||||
"applyFor": "Candidati per {rank}"
|
||||
"applyFor": "Candidati per {rank}",
|
||||
"success": {
|
||||
"submitted": "Candidatura inviata — lo staff la esaminerà a breve."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Scrivi almeno 10 caratteri su di te.",
|
||||
"invalid": "Candidatura non valida.",
|
||||
"duplicate": "Ti sei già candidato per questa posizione.",
|
||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"applyTeam": {
|
||||
"title": "Candidati per il team di {hotel}",
|
||||
@@ -496,7 +609,17 @@
|
||||
"pending": "La tua candidatura è in attesa",
|
||||
"aboutYou": "Parlaci di te",
|
||||
"aboutPlaceholder": "Raccontaci perché saresti perfetto per {rank}…",
|
||||
"applyFor": "Candidati per {rank}"
|
||||
"applyFor": "Candidati per {rank}",
|
||||
"success": {
|
||||
"submitted": "Candidatura inviata — lo staff la esaminerà a breve."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Scrivi almeno 10 caratteri su di te.",
|
||||
"invalid": "Candidatura non valida.",
|
||||
"duplicate": "Ti sei già candidato per questo team.",
|
||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"title": "Radio",
|
||||
@@ -658,7 +781,15 @@
|
||||
"experiencePlaceholder": "Hai esperienze precedenti come DJ o conduttore?",
|
||||
"motivationLabel": "Motivazione",
|
||||
"motivationPlaceholder": "Perché vuoi entrare nel team della radio?",
|
||||
"submitButton": "Invia candidatura"
|
||||
"submitButton": "Invia candidatura",
|
||||
"success": {
|
||||
"submitted": "Candidatura inviata — ti ricontatteremo presto."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Compila tutti i campi obbligatori.",
|
||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"accountSettings": "Impostazioni account",
|
||||
@@ -1129,6 +1260,13 @@
|
||||
"delete": "Elimina",
|
||||
"noAds": "Ancora nessun annuncio",
|
||||
"confirmDelete": "Eliminare questo annuncio?",
|
||||
"deleteSuccess": "Annuncio eliminato!",
|
||||
"deleteError": "Eliminazione annuncio non riuscita.",
|
||||
"adsCount": "Annunci totali",
|
||||
"latest": "Ultimo",
|
||||
"mostRecentAd": "Annuncio più recente",
|
||||
"allAds": "Tutti gli annunci",
|
||||
"colPreview": "Anteprima",
|
||||
"colId": "ID",
|
||||
"colName": "Nome",
|
||||
"colImage": "Immagine",
|
||||
@@ -2743,25 +2881,34 @@
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
"formTitle": "Submit a request",
|
||||
"formSubtitle": "Ask the DJ to play your favourite track",
|
||||
"songLabel": "Song",
|
||||
"songPlaceholder": "Title of the song",
|
||||
"artistLabel": "Artist",
|
||||
"artistPlaceholder": "Name of the artist (optional)",
|
||||
"submitButton": "Submit request",
|
||||
"loginToRequest": "Log in to submit a request.",
|
||||
"latestTitle": "Recent requests",
|
||||
"latestSubtitle": "The most recently requested songs",
|
||||
"empty": "No requests yet.",
|
||||
"colSong": "Song",
|
||||
"colArtist": "Artist",
|
||||
"colRequester": "Requester",
|
||||
"colDate": "Date",
|
||||
"colStatus": "Status",
|
||||
"statusPending": "Pending",
|
||||
"statusApproved": "Approved",
|
||||
"statusPlayed": "Played"
|
||||
"formTitle": "Invia una richiesta",
|
||||
"formSubtitle": "Chiedi al DJ di passare la tua traccia preferita",
|
||||
"songLabel": "Brano",
|
||||
"songPlaceholder": "Titolo del brano",
|
||||
"artistLabel": "Artista",
|
||||
"artistPlaceholder": "Nome dell'artista (facoltativo)",
|
||||
"submitButton": "Invia richiesta",
|
||||
"loginToRequest": "Accedi per inviare una richiesta.",
|
||||
"latestTitle": "Richieste recenti",
|
||||
"latestSubtitle": "I brani richiesti più di recente",
|
||||
"empty": "Ancora nessuna richiesta.",
|
||||
"colSong": "Brano",
|
||||
"colArtist": "Artista",
|
||||
"colRequester": "Richiedente",
|
||||
"colDate": "Data",
|
||||
"colStatus": "Stato",
|
||||
"statusPending": "In attesa",
|
||||
"statusApproved": "Approvata",
|
||||
"statusPlayed": "Passata",
|
||||
"success": {
|
||||
"posted": "Richiesta inviata."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Inserisci il titolo del brano o l'artista.",
|
||||
"invalid": "Richiesta non valida.",
|
||||
"ratelimit": "Stai inviando troppo in fretta. Attendi un momento e riprova.",
|
||||
"error": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
-9
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"title": "Berichten",
|
||||
"subtitle": "Vriendschapsverzoeken en offline berichten die op je wachten",
|
||||
"subtitle": "Vriendschapsverzoeken, offline inbox en berichten naar vrienden",
|
||||
"requestsTitle": "Vriendschapsverzoeken",
|
||||
"requestsEmptySubtitle": "Niemand heeft nog gevraagd om je vriend te zijn",
|
||||
"requestsSubtitle": "{count, plural, one {# openstaand verzoek} other {# openstaande verzoeken}}",
|
||||
@@ -124,18 +124,46 @@
|
||||
"offlineEmptySubtitle": "Je inbox is leeg",
|
||||
"offlineSubtitle": "{count, plural, one {# bericht ontvangen} other {# berichten ontvangen}}",
|
||||
"offlineEmptyState": "Je hebt geen offline berichten.",
|
||||
"composeTitle": "Offline bericht sturen",
|
||||
"composeSubtitle": "Afgeleverd in de hotel messenger wanneer je vriend offline is",
|
||||
"composeEmptyFriends": "Voeg vrienden toe in het hotel om offline berichten te sturen.",
|
||||
"composeFriendLabel": "Vriend",
|
||||
"composeFriendPlaceholder": "Kies een vriend",
|
||||
"composeMessageLabel": "Bericht",
|
||||
"composeMessagePlaceholder": "Schrijf je bericht (max. 500 tekens)…",
|
||||
"composeSend": "Bericht versturen",
|
||||
"unknownUser": "Gebruiker #{id}",
|
||||
"success": {
|
||||
"accepted": "Vriendschapsverzoek geaccepteerd.",
|
||||
"declined": "Vriendschapsverzoek geweigerd."
|
||||
"declined": "Vriendschapsverzoek geweigerd.",
|
||||
"sent": "Offline bericht verstuurd."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Dat vriendschapsverzoek is niet gevonden.",
|
||||
"unauthorized": "Je kunt dit vriendschapsverzoek niet behandelen.",
|
||||
"invalid": "Ongeldig vriendschapsverzoek.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
"error": "Er ging iets mis. Probeer het opnieuw.",
|
||||
"notFriend": "Je kunt alleen vrienden berichten.",
|
||||
"emptyMessage": "Schrijf een bericht.",
|
||||
"rateLimited": "Te veel berichten. Wacht even en probeer opnieuw."
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Loginactiviteit",
|
||||
"subtitle": "Recente aanmeldingen en mislukte pogingen op je account",
|
||||
"recentTitle": "Recente aanmeldingen",
|
||||
"recentEmpty": "Nog geen aanmeldingen geregistreerd.",
|
||||
"colWhen": "Wanneer",
|
||||
"colIp": "IP",
|
||||
"colDevice": "Apparaat",
|
||||
"failedTitle": "Mislukte pogingen",
|
||||
"failedSubtitle": "Verkeerd wachtwoord voor je gebruikersnaam",
|
||||
"unknownDevice": "Onbekend apparaat",
|
||||
"signOutEverywhereTitle": "Overal uitloggen",
|
||||
"signOutEverywhereDescription": "Beëindigt alle websitesessies op elk apparaat. Je moet hier ook opnieuw inloggen.",
|
||||
"signOutEverywhereButton": "Alle sessies beëindigen",
|
||||
"successSignedOutAll": "Alle sessies zijn beëindigd."
|
||||
},
|
||||
"profile": {
|
||||
"noMotto": "Geen motto",
|
||||
"statRank": "Rang",
|
||||
@@ -237,7 +265,8 @@
|
||||
"postComment": "Reactie plaatsen",
|
||||
"toComment": "om een reactie achter te laten.",
|
||||
"success": {
|
||||
"posted": "Reactie geplaatst."
|
||||
"posted": "Reactie geplaatst.",
|
||||
"reaction": "Reactie bijgewerkt."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Schrijf een reactie voordat je plaatst.",
|
||||
@@ -353,6 +382,35 @@
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"guildForumThread": {
|
||||
"backToForum": "← Terug naar forum",
|
||||
"unnamedGuild": "Naamloos gilde",
|
||||
"noSubject": "(geen onderwerp)",
|
||||
"inGuildForum": "{guild} forum",
|
||||
"pinned": "Vastgezet",
|
||||
"locked": "Vergrendeld",
|
||||
"postsTitle": "Berichten",
|
||||
"postsSubtitle": "{count, plural, one {# bericht} other {# berichten}}",
|
||||
"postsEmpty": "Geen berichten in deze discussie.",
|
||||
"userNumber": "Gebruiker #{id}",
|
||||
"replyTitle": "Reageren",
|
||||
"replySubtitle": "Voeg je bericht toe aan deze discussie",
|
||||
"replyPlaceholder": "Schrijf je reactie…",
|
||||
"replySubmit": "Reactie plaatsen",
|
||||
"lockedHint": "Deze discussie is vergrendeld — nieuwe reacties zijn uitgeschakeld.",
|
||||
"loginToReply": "Log in om te reageren.",
|
||||
"loginLink": "Inloggen",
|
||||
"success": {
|
||||
"replied": "Reactie geplaatst."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Voer een bericht in.",
|
||||
"notFound": "Die discussie is niet gevonden.",
|
||||
"locked": "Deze discussie is vergrendeld.",
|
||||
"ratelimit": "Je plaatst te snel. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
"title": "Winkel",
|
||||
"subtitle": "Vul je account op en pak exclusieve pakketten, valuta en badges.",
|
||||
@@ -361,7 +419,22 @@
|
||||
"allPackages": "Alle pakketten",
|
||||
"packagesAvailable": "{count, plural, one {# pakket beschikbaar} other {# pakketten beschikbaar}}",
|
||||
"empty": "Momenteel geen pakketten beschikbaar.",
|
||||
"buy": "Kopen"
|
||||
"buy": "Kopen",
|
||||
"loginToBuy": "Log in om te kopen",
|
||||
"creditsHint": "Pakketten betaal je met in-game credits. Waardeer eerst op als je meer credits nodig hebt.",
|
||||
"topUpLink": "Credits opwaarderen",
|
||||
"priceCredits": "{credits} credits (≈ {usd})",
|
||||
"priceCreditsShort": "{credits} cr",
|
||||
"success": {
|
||||
"bought": "Pakket gekocht — beloningen zijn aan je account toegevoegd.",
|
||||
"boughtNamed": "Je hebt \"{name}\" gekocht — beloningen zijn aan je account toegevoegd."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Dit pakket is niet meer beschikbaar.",
|
||||
"credits": "Je hebt niet genoeg credits voor dit pakket.",
|
||||
"ratelimit": "Je koopt te snel achter elkaar. Wacht even en probeer het opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"topup": {
|
||||
"title": "Opwaarderen met PayPal",
|
||||
@@ -476,6 +549,36 @@
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"helpTicketDetail": {
|
||||
"backToList": "← Jouw tickets",
|
||||
"ticketId": "Ticket #{id}",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Gesloten",
|
||||
"messagesTitle": "Gesprek",
|
||||
"messagesSubtitle": "{count, plural, one {# bericht} other {# berichten}}",
|
||||
"you": "Jij",
|
||||
"userNumber": "Gebruiker #{id}",
|
||||
"replyTitle": "Reageren",
|
||||
"replySubtitle": "Stuur een vervolg naar het team",
|
||||
"replyPlaceholder": "Schrijf je bericht…",
|
||||
"replySubmit": "Antwoord versturen",
|
||||
"closeTitle": "Ticket sluiten",
|
||||
"closeSubtitle": "Markeer dit verzoek als opgelost als je geen hulp meer nodig hebt",
|
||||
"closeSubmit": "Ticket sluiten",
|
||||
"closedHint": "Dit ticket is gesloten. Open een nieuw ticket als je meer hulp nodig hebt.",
|
||||
"success": {
|
||||
"replied": "Antwoord verstuurd.",
|
||||
"closed": "Ticket gesloten."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Voer een bericht in.",
|
||||
"notFound": "Dat ticket is niet gevonden.",
|
||||
"closedTicket": "Dit ticket is gesloten.",
|
||||
"moderated": "Dat bericht is geblokkeerd door moderatie.",
|
||||
"ratelimit": "Je dient te snel in. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
@@ -486,7 +589,17 @@
|
||||
"alreadyApplied": "Je hebt al gesolliciteerd voor {rank}",
|
||||
"aboutYou": "Over jou",
|
||||
"aboutPlaceholder": "Vertel ons waarom jij een geweldige {rank} zou zijn…",
|
||||
"applyFor": "Solliciteer voor {rank}"
|
||||
"applyFor": "Solliciteer voor {rank}",
|
||||
"success": {
|
||||
"submitted": "Sollicitatie ingediend — staff bekijkt deze binnenkort."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Schrijf minstens 10 tekens over jezelf.",
|
||||
"invalid": "Ongeldige sollicitatie.",
|
||||
"duplicate": "Je hebt al gesolliciteerd voor deze positie.",
|
||||
"ratelimit": "Je dient te snel in. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"applyTeam": {
|
||||
"title": "Solliciteer voor het {hotel} team",
|
||||
@@ -496,7 +609,17 @@
|
||||
"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}"
|
||||
"applyFor": "Solliciteer voor {rank}",
|
||||
"success": {
|
||||
"submitted": "Sollicitatie ingediend — staff bekijkt deze binnenkort."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Schrijf minstens 10 tekens over jezelf.",
|
||||
"invalid": "Ongeldige sollicitatie.",
|
||||
"duplicate": "Je hebt al gesolliciteerd voor dit team.",
|
||||
"ratelimit": "Je dient te snel in. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"title": "Radio",
|
||||
@@ -656,7 +779,16 @@
|
||||
"colStatus": "Status",
|
||||
"statusPending": "In afwachting",
|
||||
"statusApproved": "Goedgekeurd",
|
||||
"statusPlayed": "Gedraaid"
|
||||
"statusPlayed": "Gedraaid",
|
||||
"success": {
|
||||
"posted": "Verzoek ingediend."
|
||||
},
|
||||
"errors": {
|
||||
"empty": "Vul een nummer of artiest in.",
|
||||
"invalid": "Ongeldig verzoek.",
|
||||
"ratelimit": "Je dient te snel in. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"title": "Nitro client",
|
||||
@@ -694,7 +826,15 @@
|
||||
"experiencePlaceholder": "Eerdere DJ- of presentatie-ervaring?",
|
||||
"motivationLabel": "Motivatie",
|
||||
"motivationPlaceholder": "Waarom wil je lid worden van het radioteam?",
|
||||
"submitButton": "Sollicitatie indienen"
|
||||
"submitButton": "Sollicitatie indienen",
|
||||
"success": {
|
||||
"submitted": "Sollicitatie ingediend — we nemen contact met je op."
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Vul alle verplichte velden in.",
|
||||
"ratelimit": "Je dient te snel in. Wacht even en probeer opnieuw.",
|
||||
"error": "Er ging iets mis. Probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"accountSettings": "Accountinstellingen",
|
||||
@@ -1165,6 +1305,13 @@
|
||||
"delete": "Verwijderen",
|
||||
"noAds": "Nog geen advertenties",
|
||||
"confirmDelete": "Deze advertentie verwijderen?",
|
||||
"deleteSuccess": "Advertentie verwijderd!",
|
||||
"deleteError": "Advertentie verwijderen mislukt.",
|
||||
"adsCount": "Totaal advertenties",
|
||||
"latest": "Laatste",
|
||||
"mostRecentAd": "Meest recente advertentie",
|
||||
"allAds": "Alle advertenties",
|
||||
"colPreview": "Voorbeeld",
|
||||
"colId": "ID",
|
||||
"colName": "Naam",
|
||||
"colImage": "Afbeelding",
|
||||
|
||||
Vendored
+2
@@ -9,5 +9,7 @@ declare module "next-auth" {
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
rank?: number;
|
||||
jwtVersion?: number;
|
||||
invalid?: boolean;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user