Harden CMS security and theme contrast

This commit is contained in:
Simo committed 2026-07-11 20:27:20 +02:00
1 parent 2465ff2170
commit 4a1e1115b3
57 files changed
+1023 -231

No files matched your search

+6 -5
View File
@@ -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");
}
}
+10 -8
View File
@@ -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");
}
}
+65 -22
View File
@@ -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(
+12
View File
@@ -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,
+5 -7
View File
@@ -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 {
+22 -24
View File
@@ -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(
{
+3 -3
View File
@@ -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({
+3 -3
View File
@@ -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 {
+3 -2
View File
@@ -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);
}
+5 -7
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)",
+2 -2
View File
@@ -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,
}}
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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))",
},
];