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
+2687
-252
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
-- CMS JWT invalidation counter on users (stateless NextAuth sessions).
|
||||||
|
-- Idempotent (MariaDB).
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS website_jwt_version INT NOT NULL DEFAULT 0;
|
||||||
@@ -64,6 +64,8 @@ model User {
|
|||||||
twoFactorSecret String? @map("two_factor_secret") @db.Text
|
twoFactorSecret String? @map("two_factor_secret") @db.Text
|
||||||
twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text
|
twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text
|
||||||
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0)
|
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0)
|
||||||
|
// Bumped to invalidate all CMS JWT sessions ("sign out everywhere").
|
||||||
|
websiteJwtVersion Int @default(0) @map("website_jwt_version")
|
||||||
|
|
||||||
createdTickets WebsiteTicket[] @relation("ticketCreator")
|
createdTickets WebsiteTicket[] @relation("ticketCreator")
|
||||||
assignedTickets WebsiteTicket[] @relation("ticketAssignee")
|
assignedTickets WebsiteTicket[] @relation("ticketAssignee")
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
import { formPositiveBigInt } from "@/lib/form-data";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
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";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
// 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");
|
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 staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = formPositiveBigInt(formData, "id");
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|||||||
+86
-38
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -55,10 +56,14 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
.trim();
|
.trim();
|
||||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||||
|
|
||||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail";
|
||||||
let boughtCode = "";
|
let boughtCode = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await clientIp();
|
||||||
|
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
|
||||||
|
outcome = "ratelimit";
|
||||||
|
} else {
|
||||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||||
where: { id: BigInt(rawId) },
|
where: { id: BigInt(rawId) },
|
||||||
select: { id: true, badgePath: true, published: true },
|
select: { id: true, badgePath: true, published: true },
|
||||||
@@ -115,6 +120,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
outcome = "fail";
|
outcome = "fail";
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+43
-14
@@ -1,8 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
// Column bounds from prisma/schema.prisma (radio_applications):
|
// Column bounds from prisma/schema.prisma (radio_applications):
|
||||||
// real_name VARCHAR(255); the rest are TEXT. age is an INT.
|
// 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 TEXT_MAX = 5000;
|
||||||
const STYLE_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 {
|
function str(form: FormData, key: string, max: number): string {
|
||||||
return String(form.get(key) ?? "")
|
return String(form.get(key) ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
@@ -19,17 +42,21 @@ function str(form: FormData, key: string, max: number): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a radio DJ application.
|
* 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> {
|
export async function applyDj(formData: FormData): Promise<void> {
|
||||||
|
let outcome: ApplyOutcome = "error";
|
||||||
|
|
||||||
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = Number(session?.user?.id);
|
const userId = Number(session?.user?.id);
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
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 realName = str(formData, "realName", NAME_MAX);
|
||||||
const availability = str(formData, "availability", TEXT_MAX);
|
const availability = str(formData, "availability", TEXT_MAX);
|
||||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||||
@@ -39,12 +66,10 @@ export async function applyDj(formData: FormData): Promise<void> {
|
|||||||
const ageRaw = Number(formData.get("age"));
|
const ageRaw = Number(formData.get("age"));
|
||||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||||
|
|
||||||
// Required fields per the schema (NOT NULL): real_name, age, availability,
|
if (!realName || !availability || !motivation || age <= 0) {
|
||||||
// motivation. experience + music_style are nullable.
|
outcome = "invalid";
|
||||||
if (!realName || !availability || !motivation || age <= 0) return;
|
} else {
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
|
||||||
await prisma.radioApplications.create({
|
await prisma.radioApplications.create({
|
||||||
data: {
|
data: {
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
@@ -59,10 +84,14 @@ export async function applyDj(formData: FormData): Promise<void> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
outcome = "submitted";
|
||||||
// DB unavailable or duplicate — fail soft; nothing to persist.
|
}
|
||||||
return;
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/radio/apply");
|
revalidatePath("/radio/apply");
|
||||||
|
applyRedirect(outcome);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,50 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const SONG_MAX = 255;
|
const SONG_MAX = 255;
|
||||||
const ARTIST_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> {
|
export async function submitRequest(formData: FormData): Promise<void> {
|
||||||
|
let outcome: RequestOutcome = "error";
|
||||||
|
|
||||||
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = Number(session?.user?.id);
|
const userId = Number(session?.user?.id);
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
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") ?? "")
|
const songTitle = String(formData.get("songTitle") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -20,10 +53,10 @@ export async function submitRequest(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, ARTIST_MAX);
|
.slice(0, ARTIST_MAX);
|
||||||
if (!songTitle && !artist) return;
|
if (!songTitle && !artist) {
|
||||||
|
outcome = "empty";
|
||||||
|
} else {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
|
||||||
await prisma.radioSongRequests.create({
|
await prisma.radioSongRequests.create({
|
||||||
data: {
|
data: {
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
@@ -34,9 +67,14 @@ export async function submitRequest(formData: FormData): Promise<void> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
outcome = "posted";
|
||||||
return;
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isNextRedirect(e)) throw e;
|
||||||
|
outcome = "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/radio/requests");
|
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" });
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+18
-3
@@ -17,7 +17,12 @@ import {
|
|||||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||||
|
|
||||||
export const createTicket = authAction(
|
export const createTicket = authAction(
|
||||||
{ schema: createTicketSchema },
|
{
|
||||||
|
schema: createTicketSchema,
|
||||||
|
rateLimitKey: "ticket-create",
|
||||||
|
rateLimitMax: 5,
|
||||||
|
rateLimitWindowMs: 60_000,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.create({
|
const ticket = await prisma.websiteTicket.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -49,7 +54,12 @@ export const createTicket = authAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const userReplyTicket = authAction(
|
export const userReplyTicket = authAction(
|
||||||
{ schema: replyTicketSchema },
|
{
|
||||||
|
schema: replyTicketSchema,
|
||||||
|
rateLimitKey: "ticket-reply",
|
||||||
|
rateLimitMax: 10,
|
||||||
|
rateLimitWindowMs: 60_000,
|
||||||
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
@@ -82,7 +92,12 @@ export const userReplyTicket = authAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const closeTicketByUser = 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) => {
|
async (ctx) => {
|
||||||
const ticket = await prisma.websiteTicket.findUnique({
|
const ticket = await prisma.websiteTicket.findUnique({
|
||||||
where: { id: ctx.data.ticketId },
|
where: { id: ctx.data.ticketId },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { sendCurrency } from "@/lib/services/send-currency";
|
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") ?? "")
|
const code = String(formData.get("code") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import { applyStaff } from "@/actions/applications";
|
import { applyStaff } from "@/actions/applications";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
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).
|
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const t = await getTranslations("pages.applyStaff");
|
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 userId = Number(session.user.id);
|
||||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||||
@@ -53,6 +88,17 @@ export default async function ApplyStaffPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<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
|
<ContentCard
|
||||||
icon="🛡️"
|
icon="🛡️"
|
||||||
title={t("title", { hotel: hotelName })}
|
title={t("title", { hotel: hotelName })}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import { applyTeam } from "@/actions/applications";
|
import { applyTeam } from "@/actions/applications";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
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).
|
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const t = await getTranslations("pages.applyTeam");
|
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 userId = Number(session.user.id);
|
||||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||||
@@ -40,6 +75,17 @@ export default async function ApplyTeamPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<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
|
<ContentCard
|
||||||
icon="🤝"
|
icon="🤝"
|
||||||
title={t("title", { hotel: hotelName })}
|
title={t("title", { hotel: hotelName })}
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ const BOUGHT_NOTE = (code: string) =>
|
|||||||
const ERROR_NOTE: Record<string, string> = {
|
const ERROR_NOTE: Record<string, string> = {
|
||||||
invalid: "That badge is no longer available.",
|
invalid: "That badge is no longer available.",
|
||||||
credits: "You don't have enough credits to buy this badge.",
|
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.",
|
fail: "Something went wrong. Please try again.",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getErrorNote(error: string): string {
|
function getErrorNote(error: string): string {
|
||||||
if (error === "invalid") return ERROR_NOTE.invalid;
|
if (error === "invalid") return ERROR_NOTE.invalid;
|
||||||
if (error === "credits") return ERROR_NOTE.credits;
|
if (error === "credits") return ERROR_NOTE.credits;
|
||||||
|
if (error === "ratelimit") return ERROR_NOTE.ratelimit;
|
||||||
return ERROR_NOTE.fail;
|
return ERROR_NOTE.fail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../friends/loading";
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
return (
|
||||||
<tr key={t2.id}>
|
<tr key={t2.id}>
|
||||||
<td style={{ padding: "0.6rem 0.75rem" }}>
|
<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")}
|
{t2.subject?.trim() || t("noSubject")}
|
||||||
</span>
|
</Link>
|
||||||
{t2.pinned ? (
|
{t2.pinned ? (
|
||||||
<span
|
<span
|
||||||
className="online-badge online"
|
className="online-badge online"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../friends/loading";
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../../friends/loading";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -136,8 +137,12 @@ export default async function HelpTicketsPage({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{tickets.map((t2) => (
|
{tickets.map((t2) => (
|
||||||
<tr key={String(t2.id)}>
|
<tr key={String(t2.id)}>
|
||||||
<td>{String(t2.id)}</td>
|
<td>
|
||||||
<td>{t2.title}</td>
|
<Link href={`/help/tickets/${t2.id}`}>{String(t2.id)}</Link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/help/tickets/${t2.id}`}>{t2.title}</Link>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`online-badge${t2.open ? " online" : ""}`}>
|
<span className={`online-badge${t2.open ? " online" : ""}`}>
|
||||||
<span className="online-dot" aria-hidden />
|
<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 { SiteFooter } from "@/components/site-footer";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { TopHeader } from "@/components/top-header";
|
import { TopHeader } from "@/components/top-header";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
* 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopHeader />
|
<TopHeader session={session} />
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<Navigation />
|
<Navigation session={session} />
|
||||||
<main>
|
<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="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">
|
<div className="col-span-12 min-w-0">
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,7 @@ import { sanitize } from "@/lib/sanitize";
|
|||||||
|
|
||||||
export const revalidate = 60;
|
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 {
|
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||||
const accent =
|
const accent =
|
||||||
@@ -87,7 +87,7 @@ export default async function ArticlePage({
|
|||||||
searchParams: SearchParams;
|
searchParams: SearchParams;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const { comment, error } = await searchParams;
|
const { comment, reaction, error } = await searchParams;
|
||||||
const t = await getTranslations("pages.article");
|
const t = await getTranslations("pages.article");
|
||||||
|
|
||||||
const article = await cacheQuery(
|
const article = await cacheQuery(
|
||||||
@@ -189,6 +189,11 @@ export default async function ArticlePage({
|
|||||||
{t("success.posted")}
|
{t("success.posted")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{reaction === "1" ? (
|
||||||
|
<div role="status" style={feedbackStyle("success")}>
|
||||||
|
{t("success.reaction")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<div role="alert" style={feedbackStyle("error")}>
|
<div role="alert" style={feedbackStyle("error")}>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
@@ -240,6 +245,7 @@ export default async function ArticlePage({
|
|||||||
name="articleId"
|
name="articleId"
|
||||||
value={String(articleId)}
|
value={String(articleId)}
|
||||||
/>
|
/>
|
||||||
|
<input type="hidden" name="slug" value={article.slug} />
|
||||||
<input type="hidden" name="reaction" value={r.key} />
|
<input type="hidden" name="reaction" value={r.key} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../friends/loading";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import { applyDj } from "@/actions/radio-apply";
|
import { applyDj } from "@/actions/radio-apply";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
@@ -7,11 +8,41 @@ import { prisma } from "@/lib/prisma";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
|
|
||||||
const t = await getTranslations("pages.radioApply");
|
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);
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
@@ -31,6 +62,17 @@ export default async function RadioApplyPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
<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")} />
|
<ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
{existing ? (
|
{existing ? (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../friends/loading";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { submitRequest } from "@/actions/radio-requests";
|
import { submitRequest } from "@/actions/radio-requests";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
@@ -7,10 +8,43 @@ import { prisma } from "@/lib/prisma";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 t = await getTranslations("pages.radioRequests");
|
||||||
const genericT = await getTranslations("pages.radio");
|
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([
|
const [requests, session] = await Promise.all([
|
||||||
prisma.radioSongRequests
|
prisma.radioSongRequests
|
||||||
@@ -37,6 +71,17 @@ export default async function RadioRequestsPage() {
|
|||||||
|
|
||||||
return (
|
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
|
<ContentCard
|
||||||
icon="🎵"
|
icon="🎵"
|
||||||
title={genericT("linkRequests")}
|
title={genericT("linkRequests")}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,11 @@
|
|||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
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 { prisma } from "@/lib/prisma";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { EditAdDeleteButton } from "../ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ export default async function EditAd({
|
|||||||
}
|
}
|
||||||
if (!ad) notFound();
|
if (!ad) notFound();
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.ads");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
@@ -34,10 +38,10 @@ export default async function EditAd({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||||
Edit advertisement
|
{t("edit")} #{String(ad.id)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,20 +50,17 @@ export default async function EditAd({
|
|||||||
<input
|
<input
|
||||||
name="image"
|
name="image"
|
||||||
defaultValue={ad.image}
|
defaultValue={ad.image}
|
||||||
placeholder="Image URL"
|
placeholder={t("form.imagePlaceholder")}
|
||||||
required
|
required
|
||||||
maxLength={255}
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Save
|
{t("form.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
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<EditAdDeleteButton id={String(ad.id)} />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import { StatusCard } from "@/components/admin/dashboard";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { AdsTable } from "./ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -51,41 +52,15 @@ export default async function AdminAds() {
|
|||||||
{ads.length === 0 ? (
|
{ads.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noAds")}</div>
|
<div className="admin-empty">{t("noAds")}</div>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<AdsTable
|
||||||
<thead>
|
ads={ads.map((ad) => ({
|
||||||
<tr>
|
id: String(ad.id),
|
||||||
<th>{t("colPreview")}</th>
|
image: ad.image,
|
||||||
<th>{t("colImage")}</th>
|
createdAt: ad.createdAt
|
||||||
<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)
|
? ad.createdAt.toISOString().slice(0, 10)
|
||||||
: "—"}
|
: null,
|
||||||
</td>
|
}))}
|
||||||
<td>
|
/>
|
||||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { MobileNav } from "@/components/mobile-nav";
|
import { MobileNav } from "@/components/mobile-nav";
|
||||||
import { NavDropdown } from "@/components/nav-dropdown";
|
import { NavDropdown } from "@/components/nav-dropdown";
|
||||||
import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
import { NavbarColorPicker } from "@/components/navbar-color-picker";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
|
|
||||||
export async function Navigation() {
|
export async function Navigation({ session }: { session: Session | null }) {
|
||||||
const session = await auth();
|
|
||||||
const t = await getTranslations("nav");
|
const t = await getTranslations("nav");
|
||||||
const isStaff = (session?.user?.rank ?? 0) >= 7;
|
const isStaff = (session?.user?.rank ?? 0) >= 7;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export async function SiteHeader() {
|
export async function SiteHeader() {
|
||||||
const t = await getTranslations("header");
|
const t = await getTranslations("header");
|
||||||
const [_session, hotelName, header, logo] = await Promise.all([
|
const [hotelName, header, logo] = await Promise.all([
|
||||||
auth(),
|
|
||||||
siteSettings.get("hotel_name", "Atom"),
|
siteSettings.get("hotel_name", "Atom"),
|
||||||
siteSettings.get("cms_header", "/assets/images/background.png"),
|
siteSettings.get("cms_header", "/assets/images/background.png"),
|
||||||
siteSettings.get("cms_logo", ""),
|
siteSettings.get("cms_logo", ""),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { avatarImageUrl } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
@@ -37,8 +38,7 @@ function Currency({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function TopHeader() {
|
export async function TopHeader({ session }: { session: Session | null }) {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return null;
|
if (!session?.user?.id) return null;
|
||||||
const id = Number(session.user.id);
|
const id = Number(session.user.id);
|
||||||
|
|
||||||
|
|||||||
+56
-4
File diff suppressed because it is too large.
Load diff
+156
-10
File diff suppressed because it is too large.
Load diff
+174
-27
File diff suppressed because it is too large.
Load diff
+156
-9
File diff suppressed because it is too large.
Load diff
Vendored
+2
@@ -9,5 +9,7 @@ declare module "next-auth" {
|
|||||||
declare module "next-auth/jwt" {
|
declare module "next-auth/jwt" {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
rank?: number;
|
rank?: number;
|
||||||
|
jwtVersion?: number;
|
||||||
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user