Harden CMS security and theme contrast
This commit is contained in:
1 parent
2465ff2170
commit
4a1e1115b3
57 files changed
+1023
-231
No files matched your search
@@ -2,18 +2,19 @@
|
||||
//
|
||||
// Reads the NextAuth session, then re-queries prisma.user by the session id to
|
||||
// return a safe field set (never password / auth_ticket / 2FA secrets / pincode
|
||||
// / mail). Returns { user: null } when unauthenticated or on DB failure.
|
||||
// / mail). Returns { user: null } only when unauthenticated or missing.
|
||||
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { apiJson, apiUnavailable } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
const session = await auth();
|
||||
const id = session?.user?.id ? Number(session.user.id) : null;
|
||||
if (!id || Number.isNaN(id)) {
|
||||
const id = sessionUserId(session?.user?.id);
|
||||
if (!id) {
|
||||
return apiJson({ user: null });
|
||||
}
|
||||
|
||||
@@ -55,6 +56,6 @@ export async function GET(_req: Request) {
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return apiJson({ user: null });
|
||||
return apiUnavailable("Account data is temporarily unavailable");
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,17 @@
|
||||
// to the user via personal_access_tokens.tokenable_id (a BigInt). NOTE: the live
|
||||
// table has no expires_at column, so it is never read or written here.
|
||||
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function currentUserId(): Promise<number | null> {
|
||||
const session = await auth();
|
||||
const id = session?.user?.id ? Number(session.user.id) : null;
|
||||
return id && !Number.isNaN(id) ? id : null;
|
||||
return sessionUserId(session?.user?.id);
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
@@ -26,14 +27,14 @@ export async function GET(_req: Request) {
|
||||
|
||||
try {
|
||||
const tokens = await prisma.personalAccessTokens.findMany({
|
||||
where: { tokenableId: BigInt(id) },
|
||||
where: personalTokenScope(id),
|
||||
select: { id: true, name: true, lastUsedAt: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
// Never expose the token hash.
|
||||
return apiJson({ data: tokens });
|
||||
} catch {
|
||||
return apiJson({ data: [] });
|
||||
return apiUnavailable("Token data is temporarily unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,20 +43,21 @@ export async function DELETE(req: Request) {
|
||||
if (!id) return apiError("Unauthorized", 401);
|
||||
|
||||
const tokenId = new URL(req.url).searchParams.get("id");
|
||||
if (!tokenId || !/^\d+$/.test(tokenId)) {
|
||||
const parsedTokenId = positiveBigInt(tokenId);
|
||||
if (!parsedTokenId) {
|
||||
return apiError("A valid token id is required", 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Scope the delete to the owner so users cannot revoke others' tokens.
|
||||
const result = await prisma.personalAccessTokens.deleteMany({
|
||||
where: { id: BigInt(tokenId), tokenableId: BigInt(id) },
|
||||
where: { id: parsedTokenId, ...personalTokenScope(id) },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
return apiError("Token not found", 404);
|
||||
}
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
return apiError("Could not revoke token", 400);
|
||||
return apiUnavailable("Could not revoke token");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { captureOrder, creditsPerUnit, isPayPalConfigured } from "@/lib/services/paypal";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { sendCurrency } from "@/lib/services/send-currency";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
authorizeTopupCapture,
|
||||
claimTopupDelivery,
|
||||
TopupCaptureError,
|
||||
} from "@/lib/services/paypal-topup";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,8 +36,8 @@ export async function POST(req: Request): Promise<Response> {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
|
||||
}
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) {
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -53,22 +60,49 @@ export async function POST(req: Request): Promise<Response> {
|
||||
return NextResponse.json({ error: "Missing orderId." }, { status: 422 });
|
||||
}
|
||||
|
||||
// Idempotency: if this order was already recorded, don't capture/credit again.
|
||||
// The create endpoint records ownership before returning the approval URL.
|
||||
// Do not let a signed-in user submit somebody else's approved order id.
|
||||
let authorized;
|
||||
try {
|
||||
const existing = await prisma.websitePaypalTransactions.findFirst({
|
||||
where: { transactionId: orderId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (existing) {
|
||||
authorized = await authorizeTopupCapture(userId, orderId, async (transactionId) => prisma.websitePaypalTransactions.findFirst({
|
||||
where: { transactionId },
|
||||
select: { userId: true, status: true, amount: true },
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof TopupCaptureError) {
|
||||
const status = error.code === "ORDER_NOT_FOUND" ? 404 : 409;
|
||||
return NextResponse.json({ error: "Order not found or already processed." }, { status });
|
||||
}
|
||||
return NextResponse.json({ error: "Could not verify the payment order." }, { status: 500 });
|
||||
}
|
||||
|
||||
if (authorized.action === "DELIVER_CREDITS") {
|
||||
const amount = authorized.amount ?? 0;
|
||||
const credits = Math.floor(amount * creditsPerUnit());
|
||||
try {
|
||||
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
|
||||
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
|
||||
}));
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
|
||||
data: { status: "COMPLETED", updatedAt: new Date() },
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: existing.status === "COMPLETED",
|
||||
alreadyProcessed: true,
|
||||
status: existing.status,
|
||||
ok: true,
|
||||
recovered: true,
|
||||
status: "COMPLETED",
|
||||
amount,
|
||||
credits,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("paypal.credit_recovery_failed", error, { userId, orderId });
|
||||
return NextResponse.json(
|
||||
{ error: "Payment is recorded but credits could not be delivered. Contact staff." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If the lookup fails we fall through; the capture call itself is the source
|
||||
// of truth and PayPal rejects a second capture of the same order.
|
||||
}
|
||||
|
||||
let result;
|
||||
@@ -85,10 +119,9 @@ export async function POST(req: Request): Promise<Response> {
|
||||
if (result.status !== "COMPLETED") {
|
||||
// Record the non-completed attempt so support can trace it.
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.create({
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREATED" },
|
||||
data: {
|
||||
userId,
|
||||
transactionId: result.id || orderId,
|
||||
status: result.status,
|
||||
description: `${env.HOTEL_NAME} top-up (not completed)`,
|
||||
amount: result.amount,
|
||||
@@ -111,11 +144,10 @@ export async function POST(req: Request): Promise<Response> {
|
||||
// Record the transaction BEFORE crediting so a crash mid-grant can't be
|
||||
// reprocessed into a double credit (the idempotency check above keys on this).
|
||||
try {
|
||||
await prisma.websitePaypalTransactions.create({
|
||||
const claimed = await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREATED" },
|
||||
data: {
|
||||
userId,
|
||||
transactionId: result.captureId ?? result.id,
|
||||
status: "COMPLETED",
|
||||
status: "CAPTURED_PENDING_CREDIT",
|
||||
description: `${env.HOTEL_NAME} top-up: ${credits} credits`,
|
||||
amount: result.amount,
|
||||
currency: result.currency,
|
||||
@@ -123,6 +155,7 @@ export async function POST(req: Request): Promise<Response> {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) throw new Error("Top-up order was already claimed");
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture record failed", { module: "paypal/capture", error: (e as Error).message });
|
||||
return NextResponse.json(
|
||||
@@ -131,9 +164,19 @@ export async function POST(req: Request): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
// Credit the buyer's website credits wallet (RCON-first, DB fallback).
|
||||
// Atomically claim delivery so concurrent retries cannot grant twice. If the
|
||||
// external delivery outcome is ambiguous, CREDIT_DELIVERING remains visible
|
||||
// for staff reconciliation instead of automatically risking a second grant.
|
||||
try {
|
||||
await claimTopupDelivery(() => prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CAPTURED_PENDING_CREDIT" },
|
||||
data: { status: "CREDIT_DELIVERING", updatedAt: new Date() },
|
||||
}));
|
||||
await sendCurrency({ rcon, db: prisma }, userId, "credits", credits);
|
||||
await prisma.websitePaypalTransactions.updateMany({
|
||||
where: { userId, transactionId: orderId, status: "CREDIT_DELIVERING" },
|
||||
data: { status: "COMPLETED", updatedAt: new Date() },
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("PayPal capture credit failed", { module: "paypal/capture", error: (e as Error).message });
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createOrder, creditsPerUnit, isPayPalConfigured, PAYPAL_CURRENCY } from "@/lib/services/paypal";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { recordCreatedTopup } from "@/lib/services/paypal-topup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,6 +24,10 @@ export async function POST(req: Request): Promise<Response> {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "You must be signed in to top up." }, { status: 401 });
|
||||
}
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Invalid session." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fail fast (and clearly) when the sandbox/live keys aren't set.
|
||||
if (!isPayPalConfigured()) {
|
||||
@@ -63,6 +70,11 @@ export async function POST(req: Request): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
await recordCreatedTopup(
|
||||
{ userId, orderId: order.id, amount, currency: PAYPAL_CURRENCY, credits },
|
||||
(data) => prisma.websitePaypalTransactions.create({ data }),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
approveUrl: order.approveUrl,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Public REST: website store packages (website_shop_articles).
|
||||
// AtomCMS JSON API parity — read-only list of buyable packages, paginated and
|
||||
// ordered by `position` (then name), matching the admin /admin/shop query.
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,12 +13,10 @@ export async function GET(req: Request) {
|
||||
// Optional ?category=<id> filter (website_shop_category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
|
||||
try {
|
||||
where = { categoryId: BigInt(categoryRaw) };
|
||||
} catch {
|
||||
where = {};
|
||||
}
|
||||
if (categoryRaw !== null) {
|
||||
const categoryId = positiveBigInt(categoryRaw);
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
where = { categoryId };
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// into website_help_center_ticket_replies. The target ticket must exist and
|
||||
// belong to the authed user. Fail-soft: never a 500.
|
||||
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -16,8 +17,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const { id } = await params;
|
||||
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
|
||||
const ticketId = BigInt(id);
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) return apiError("Invalid ticket id", 422);
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { content?: unknown };
|
||||
const content = String(body.content ?? "")
|
||||
@@ -27,28 +28,25 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
||||
|
||||
try {
|
||||
// Ownership check — only the ticket owner may reply.
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
|
||||
|
||||
const now = new Date();
|
||||
const reply = await prisma.websiteHelpCenterTicketReplies.create({
|
||||
data: {
|
||||
ticketId,
|
||||
userId: uid,
|
||||
content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
const reply = await prisma.$transaction((tx) => createOwnedTicketReply(
|
||||
{
|
||||
findTicket: (ticketId) => tx.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
createReply: (data) => tx.websiteHelpCenterTicketReplies.create({
|
||||
data,
|
||||
select: { id: true, userId: true, content: true, createdAt: true },
|
||||
}),
|
||||
touchTicket: (ticketId, updatedAt) => tx.websiteHelpCenterTickets.update({
|
||||
where: { id: ticketId },
|
||||
data: { updatedAt },
|
||||
select: { id: true },
|
||||
}),
|
||||
},
|
||||
select: { id: true, userId: true, content: true, createdAt: true },
|
||||
});
|
||||
|
||||
// Touch the parent ticket so its updatedAt reflects the latest activity.
|
||||
prisma.websiteHelpCenterTickets
|
||||
.update({ where: { id: ticketId }, data: { updatedAt: now }, select: { id: true } })
|
||||
.catch(() => {});
|
||||
{ ticketId, userId: uid, content },
|
||||
));
|
||||
if (!reply) return apiError("Ticket not found", 404);
|
||||
|
||||
return apiJson(
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// together with its replies; reply author usernames are resolved in a single
|
||||
// users lookup. Fail-soft: never a 500.
|
||||
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -16,8 +16,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const { id } = await params;
|
||||
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
|
||||
const ticketId = BigInt(id);
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) return apiError("Invalid ticket id", 422);
|
||||
|
||||
try {
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Backed by website_help_center_tickets (WebsiteHelpCenterTickets). Fail-soft:
|
||||
// DB errors return an apiError envelope, never a 500.
|
||||
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -59,8 +59,8 @@ export async function POST(req: Request) {
|
||||
// value, otherwise leave it null.
|
||||
let categoryId: bigint | null = null;
|
||||
if (body.categoryId !== undefined && body.categoryId !== null && body.categoryId !== "") {
|
||||
const raw = String(body.categoryId);
|
||||
if (/^\d+$/.test(raw)) categoryId = BigInt(raw);
|
||||
categoryId = positiveBigInt(String(body.categoryId));
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { issueToken } from "@/lib/api-auth";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionUserId } from "@/lib/auth/session-user";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
const id = session?.user?.id ? Number(session.user.id) : null;
|
||||
if (!id || Number.isNaN(id)) {
|
||||
const id = sessionUserId(session?.user?.id);
|
||||
if (!id) {
|
||||
return apiError("Unauthorized", 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Public REST: rare furni trade values (website_rare_values).
|
||||
// AtomCMS JSON API parity — read-only catalog of rares with their credit /
|
||||
// currency values. Supports ?category=<id> filter; paginated, ordered by name.
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,12 +13,10 @@ export async function GET(req: Request) {
|
||||
// Optional ?category=<id> filter (category_id is a BigInt).
|
||||
const categoryRaw = sp.get("category");
|
||||
let where: { categoryId?: bigint } = {};
|
||||
if (categoryRaw && /^\d+$/.test(categoryRaw)) {
|
||||
try {
|
||||
where = { categoryId: BigInt(categoryRaw) };
|
||||
} catch {
|
||||
where = {};
|
||||
}
|
||||
if (categoryRaw !== null) {
|
||||
const categoryId = positiveBigInt(categoryRaw);
|
||||
if (!categoryId) return apiError("A valid category id is required", 422);
|
||||
where = { categoryId };
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ function ToolbarBtn({
|
||||
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
|
||||
color: "var(--button-text-color, #1a1a2e)",
|
||||
color: "var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
||||
} as const;
|
||||
if (href) {
|
||||
return (
|
||||
@@ -139,7 +139,7 @@ export function ClientView({
|
||||
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
|
||||
color: "var(--button-text-color, #1a1a2e)",
|
||||
color: "var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
||||
}}
|
||||
title="Online users"
|
||||
>
|
||||
|
||||
+43
-24
@@ -28,6 +28,13 @@
|
||||
--color-navbar-text: #1e293b;
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-text-readable: #0f172a;
|
||||
--color-text-muted-readable: #6b7280;
|
||||
--color-primary-readable: #000000;
|
||||
--color-accent-readable: #006b49;
|
||||
--color-primary-foreground-readable: #1a1a2e;
|
||||
--color-accent-foreground-readable: #000000;
|
||||
--color-navbar-text-readable: #1e293b;
|
||||
--color-accent: #10b981;
|
||||
--color-success: #16a34a;
|
||||
--color-warning: #eab308;
|
||||
@@ -38,11 +45,14 @@
|
||||
--gradient-to: #10b981;
|
||||
--button-color: #eeb425;
|
||||
--button-text-color: #1a1a2e;
|
||||
--button-text-color-readable: #1a1a2e;
|
||||
--button-secondary-color: #22c55e;
|
||||
--button-secondary-text-color: #ffffff;
|
||||
--button-secondary-text-color-readable: #000000;
|
||||
--button-secondary-hover-color: var(--color-success);
|
||||
--button-danger-color: #ef4444;
|
||||
--button-danger-text-color: #ffffff;
|
||||
--button-danger-text-color-readable: #000000;
|
||||
--button-danger-hover-color: #dc2626;
|
||||
--button-outline-color: #eeb425;
|
||||
--button-outline-text-color: #1a1a2e;
|
||||
@@ -61,6 +71,8 @@
|
||||
--card-border-radius: 14px;
|
||||
--link-color: #eeb425;
|
||||
--link-hover-color: #d4a01f;
|
||||
--link-color-readable: #000000;
|
||||
--link-hover-color-readable: #000000;
|
||||
--input-border-color: #d1d5db;
|
||||
--input-focus-color: #eeb425;
|
||||
--navbar-height: 56px;
|
||||
@@ -77,12 +89,19 @@ html.dark {
|
||||
--color-navbar-text: #e5e7eb;
|
||||
--color-text: #e5e7eb;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-readable: var(--color-text);
|
||||
--color-text-muted-readable: var(--color-text-muted);
|
||||
--color-primary-readable: var(--color-text);
|
||||
--color-accent-readable: var(--color-text);
|
||||
--color-navbar-text-readable: var(--color-navbar-text);
|
||||
--link-color-readable: var(--color-text);
|
||||
--link-hover-color-readable: var(--color-text);
|
||||
}
|
||||
html.dark input,
|
||||
html.dark select,
|
||||
html.dark textarea {
|
||||
background: #11151c;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-readable, var(--color-text));
|
||||
}
|
||||
html.dark .admin-page table,
|
||||
html.dark .admin-card {
|
||||
@@ -98,7 +117,7 @@ html {
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text, #0f172a);
|
||||
color: var(--color-text-readable, var(--color-text, #0f172a));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -120,7 +139,7 @@ body {
|
||||
@layer components {
|
||||
.nav-item {
|
||||
@apply flex h-auto md:h-[54px] items-center text-[13px] font-bold transition-all duration-150 ease-in-out px-3 md:px-4 relative;
|
||||
color: var(--color-navbar-text, #64748b);
|
||||
color: var(--color-navbar-text-readable, var(--color-navbar-text, #64748b));
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
@@ -141,15 +160,15 @@ body {
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
}
|
||||
.nav-item:hover {
|
||||
color: var(--color-primary);
|
||||
color: var(--color-primary-readable, var(--color-primary));
|
||||
}
|
||||
.dropdown-item {
|
||||
@apply block py-2.5 px-4 text-sm font-semibold transition-all duration-100 rounded-lg mx-1;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-readable, var(--color-text));
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
color: var(--color-primary-readable, var(--color-primary));
|
||||
}
|
||||
.dropdown-menu {
|
||||
background-color: var(--color-dropdown);
|
||||
@@ -186,10 +205,10 @@ body {
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-muted-readable, var(--color-text-muted));
|
||||
}
|
||||
.text-nav {
|
||||
color: var(--color-navbar-text);
|
||||
color: var(--color-navbar-text-readable, var(--color-navbar-text));
|
||||
}
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
@@ -221,14 +240,14 @@ h3 {
|
||||
font-weight: 700;
|
||||
}
|
||||
a {
|
||||
color: var(--link-color);
|
||||
color: var(--link-color-readable, var(--link-color));
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-muted-readable, var(--color-text-muted));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.hero {
|
||||
@@ -245,7 +264,7 @@ a:hover {
|
||||
}
|
||||
.card {
|
||||
background: var(--color-navbar);
|
||||
color: var(--color-navbar-text);
|
||||
color: var(--color-navbar-text-readable, var(--color-navbar-text));
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
border-radius: var(--card-border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
@@ -282,7 +301,7 @@ a:hover {
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
background: #eef2f7;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-readable, var(--color-text));
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover {
|
||||
@@ -292,15 +311,15 @@ a:hover {
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--button-color);
|
||||
color: var(--button-text-color);
|
||||
color: var(--button-text-color-readable, var(--button-text-color));
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--button-secondary-color);
|
||||
color: var(--button-secondary-text-color);
|
||||
color: var(--button-secondary-text-color-readable, var(--button-secondary-text-color));
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--button-danger-color);
|
||||
color: var(--button-danger-text-color);
|
||||
color: var(--button-danger-text-color-readable, var(--button-danger-text-color));
|
||||
}
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
@@ -316,7 +335,7 @@ textarea {
|
||||
border: 2px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-readable, var(--color-text));
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
@@ -334,7 +353,7 @@ table {
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-muted-readable, var(--color-text-muted));
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -406,7 +425,7 @@ tbody tr:hover {
|
||||
/* Cards used inside admin pages */
|
||||
.admin-card {
|
||||
background: var(--color-navbar);
|
||||
color: var(--color-navbar-text);
|
||||
color: var(--color-navbar-text-readable, var(--color-navbar-text));
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-card);
|
||||
@@ -519,7 +538,7 @@ tbody tr:hover {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background: var(--color-navbar);
|
||||
color: var(--color-navbar-text);
|
||||
color: var(--color-navbar-text-readable, var(--color-navbar-text));
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
@@ -565,12 +584,12 @@ tbody tr:hover {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-navbar-text);
|
||||
color: var(--color-navbar-text-readable, var(--color-navbar-text));
|
||||
}
|
||||
.content-card-subtitle {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-muted-readable, var(--color-text-muted));
|
||||
}
|
||||
.content-card-action {
|
||||
flex: none;
|
||||
@@ -624,12 +643,12 @@ tbody tr:hover {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: var(--color-primary);
|
||||
color: var(--color-primary-readable, var(--color-primary));
|
||||
}
|
||||
.stat-block-label {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-muted-readable, var(--color-text-muted));
|
||||
}
|
||||
|
||||
/* Empty states */
|
||||
@@ -652,7 +671,7 @@ tbody tr:hover {
|
||||
padding: 0.1rem 0.55rem 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 14%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-muted-readable, var(--color-text-muted));
|
||||
}
|
||||
.online-badge.online {
|
||||
background: color-mix(in srgb, var(--color-success) 16%, transparent);
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ function feedbackStyle(tone: "success" | "error" | "warning"): CSSProperties {
|
||||
padding: "0.85rem 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: "var(--color-text)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--color-surface)",
|
||||
|
||||
@@ -67,7 +67,7 @@ export default async function NewsPage() {
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.05rem",
|
||||
color: "var(--color-navbar-text)",
|
||||
color: "var(--color-navbar-text-readable, var(--color-navbar-text))",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
@@ -79,7 +79,7 @@ export default async function NewsPage() {
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--color-navbar-text)",
|
||||
color: "var(--color-navbar-text-readable, var(--color-navbar-text))",
|
||||
fontSize: "0.88rem",
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function RadioLayout({ children }: { children: ReactNode })
|
||||
padding: "0.55rem 0.75rem",
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
color: "var(--color-text)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
textDecoration: "none",
|
||||
borderBottom: "1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
|
||||
marginBottom: "0.25rem",
|
||||
@@ -59,7 +59,7 @@ export default async function RadioLayout({ children }: { children: ReactNode })
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderRadius: "8px",
|
||||
color: "var(--color-text)",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
textDecoration: "none",
|
||||
fontSize: "0.9rem",
|
||||
transition: "background 0.15s",
|
||||
|
||||
@@ -87,21 +87,21 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna
|
||||
icon: "💰",
|
||||
label: t("statCredits"),
|
||||
value: user.credits,
|
||||
color: "var(--color-primary)",
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
},
|
||||
{
|
||||
key: "duckets",
|
||||
icon: "🪙",
|
||||
label: t("statDuckets"),
|
||||
value: ducketsAmount,
|
||||
color: "var(--color-accent)",
|
||||
color: "var(--color-accent-readable, var(--color-accent))",
|
||||
},
|
||||
{
|
||||
key: "diamonds",
|
||||
icon: "💎",
|
||||
label: t("statDiamonds"),
|
||||
value: diamondsAmount,
|
||||
color: "#38bdf8",
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user