feat: comprehensive CMS improvements
- Fix DOMPurify SSR crash (use isomorphic-dompurify) - Fix SanitizedHtml to sanitize by default - Add auth guards to studio/catalog maintenance pages - Add update/edit to vouchers CRUD - Add update/edit to rare-values CRUD - Add approve workflow to applications page - Add edit form to guilds detail page - Add SEO metadata to all public pages (21 pages) - Fix mobile nav accessibility (focus trap, aria attributes) - Fix missing labels and table accessibility - Add dynamic imports for heavy client components (6 components) - Fix silent error swallowing (40+ locations) - Add content scheduling for articles (publishAt, status) - Wire up 12 missing webhook notification triggers - Add global search to admin panel - Add bulk actions to admin users table - Fix JSON formatting and a11y issues
This commit is contained in:
1 parent
b810b16672
commit
30c95b1a5c
78 files changed
+2252
-134
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -14,6 +15,19 @@ import {
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.applyStaff");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -8,6 +9,19 @@ import { auth } from "@/lib/auth";
|
||||
import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.applyTeam");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { asc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Badges" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.badges");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
|
||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export const metadata = { title: "Community" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.community");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
// Public API documentation. Static, hand-maintained from the routes that
|
||||
// actually exist under src/app/api — keep this in sync when endpoints change.
|
||||
|
||||
export const metadata = { title: "API" };
|
||||
export const metadata: Metadata = {
|
||||
title: "Developers",
|
||||
description: "Public API documentation for the hotel.",
|
||||
openGraph: {
|
||||
title: "Developers",
|
||||
description: "Public API documentation for the hotel.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
type Method = "GET" | "POST" | "DELETE";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { buyBadge } from "@/actions/draw-badge";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
@@ -10,7 +11,15 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
// Reads the live users + website_drawbadges tables and writes credits/badges on
|
||||
// purchase — must never be statically rendered.
|
||||
|
||||
export const metadata = { title: "Draw a Badge" };
|
||||
export const metadata: Metadata = {
|
||||
title: "Draw a Badge",
|
||||
description: "Draw a random badge and add it to your collection.",
|
||||
openGraph: {
|
||||
title: "Draw a Badge",
|
||||
description: "Draw a random badge and add it to your collection.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PRICE = 50;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { count, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
@@ -12,7 +13,18 @@ import {
|
||||
import { excerpt, slugify } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Events" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.events");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventsPage() {
|
||||
const t = await getTranslations("pages.events");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asc, eq, inArray, or } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -9,7 +10,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, MessengerFriendships, User } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Friends" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.friends");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("emptySubtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("emptySubtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type SearchParams = Promise<{ removed?: string; error?: string }>;
|
||||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, Guilds } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
|
||||
export const metadata = { title: "Guilds" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.guilds");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type GuildCard = {
|
||||
id: number;
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { asc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.help");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import {
|
||||
db,
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
import MotionPageWrapper from "@/components/motion-page-wrapper";
|
||||
import { Navigation } from "@/components/navigation";
|
||||
import RadioPlayerGate from "@/components/public/radio-player-gate";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { TopHeader } from "@/components/top-header";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
const RadioPlayerGate = dynamic(
|
||||
() => import("@/components/public/radio-player-gate"),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-12" />
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||
@@ -10,7 +11,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Leaderboard" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.leaderboard");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle", { currency: "credits" }),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle", { currency: "credits" }),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Currency type ids (see UsersCurrency in src/db/schema.ts):
|
||||
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { db, MarketplaceItems, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Marketplace" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.marketplace");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// state == 1 → an active, unsold offer in the Arcturus marketplace.
|
||||
const STATE_ACTIVE = 1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -56,7 +56,16 @@ export async function generateMetadata({
|
||||
shortStory: WebsiteArticles.shortStory,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
if (!article) return { title: "Article" };
|
||||
@@ -108,7 +117,16 @@ export default async function ArticlePage({
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.catch(() => []);
|
||||
return row ?? null;
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
openGraph: {
|
||||
title: "Home",
|
||||
description:
|
||||
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
import { AmbientOrbs } from "@/components/ambient-orbs";
|
||||
import { AnimatedCounter } from "@/components/animated-counter";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
type LightboxPhoto,
|
||||
@@ -9,7 +10,18 @@ import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Photos" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.photos");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Photo = {
|
||||
id: number;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, count, desc, inArray, isNull, lte, or } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
@@ -6,7 +7,18 @@ import { db, WebsitePoll, WebsitePollQuestion } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export const metadata = { title: "Polls" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.polls");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PollsPage() {
|
||||
const t = await getTranslations("pages.polls");
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.radio");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||
import { db, RadioSchedules, User } from "@/lib/db";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import {
|
||||
@@ -12,7 +13,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Rankings" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.rankings");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type TopUser = {
|
||||
username: string;
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { asc, count } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const metadata = { title: "Rare values" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.rares");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CategoryRow = {
|
||||
id: bigint;
|
||||
|
||||
@@ -5,7 +5,15 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { db, Rooms, User } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Search" };
|
||||
export const metadata: Metadata = {
|
||||
title: "Search",
|
||||
description: "Search for users and rooms in the hotel.",
|
||||
openGraph: {
|
||||
title: "Search",
|
||||
description: "Search for users and rooms in the hotel.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
type SearchParams = Promise<{ q?: string }>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { buyShopArticle } from "@/actions/shop";
|
||||
@@ -10,7 +11,18 @@ import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||
|
||||
export const metadata = { title: "Shop" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.shop");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
const accent =
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { asc, desc, eq, gte } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, User, WebsiteTeams } from "@/lib/db";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const metadata = { title: "Staff" };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.staff");
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("subtitle"),
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type StaffMember = {
|
||||
username: string;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { RevenueChart } from "@/components/admin/revenue-chart";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -16,6 +16,19 @@ import {
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
const RevenueChart = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/revenue-chart").then((m) => ({
|
||||
default: m.RevenueChart,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
type EconomyData = {
|
||||
totalCredits: number;
|
||||
totalPixels: number;
|
||||
|
||||
@@ -7,16 +7,41 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { DashboardChart } from "@/components/admin/dashboard-chart";
|
||||
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
const DashboardChart = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/dashboard-chart").then((m) => ({
|
||||
default: m.DashboardChart,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const PeakHoursHeatmap = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/peak-hours-heatmap").then((m) => ({
|
||||
default: m.PeakHoursHeatmap,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
type AnalyticsData = {
|
||||
totalUsers: number;
|
||||
onlineUsers: number;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { desc, inArray } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { dismissApplication } from "@/actions/admin-applications";
|
||||
import { ApplicationActions } from "@/components/admin/application-actions";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { db, User, WebsiteStaffApplications } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
type ApplicationRow = {
|
||||
id: bigint;
|
||||
@@ -31,7 +32,8 @@ export default async function AdminApplications() {
|
||||
.from(WebsiteStaffApplications)
|
||||
.orderBy(desc(WebsiteStaffApplications.createdAt))
|
||||
.limit(100);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logServerError("applications.query_failed", error);
|
||||
applications = [];
|
||||
}
|
||||
|
||||
@@ -45,8 +47,8 @@ export default async function AdminApplications() {
|
||||
.from(User)
|
||||
.where(inArray(User.id, ids));
|
||||
for (const u of users) userMap.set(u.id, u.username);
|
||||
} catch {
|
||||
// no DB — fall back to raw ids
|
||||
} catch (error) {
|
||||
logServerError("applications.user_lookup_failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +95,7 @@ export default async function AdminApplications() {
|
||||
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
|
||||
{a.content}
|
||||
</p>
|
||||
<form action={dismissApplication}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
</form>
|
||||
<ApplicationActions id={String(a.id)} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function CatalogMaintenancePage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
export default function CatalogMaintenancePage() {
|
||||
return <CatalogMaintenancePanel />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { asc, count, eq, inArray } from "drizzle-orm";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { disbandGuild } from "@/actions/admin-guilds";
|
||||
import { disbandGuild, updateGuild } from "@/actions/admin-guilds";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
|
||||
@@ -35,6 +35,10 @@ export default async function AdminGuildDetailPage({
|
||||
dateCreated: Guilds.dateCreated,
|
||||
state: Guilds.state,
|
||||
forum: Guilds.forum,
|
||||
readForum: Guilds.readForum,
|
||||
postMessages: Guilds.postMessages,
|
||||
postThreads: Guilds.postThreads,
|
||||
modForum: Guilds.modForum,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(eq(Guilds.id, id))
|
||||
@@ -139,6 +143,142 @@ export default async function AdminGuildDetailPage({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canEdit ? (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("edit")}</h2>
|
||||
<form action={updateGuild} className="space-y-3">
|
||||
<input type="hidden" name="id" value={guild.id} />
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3">
|
||||
<div>
|
||||
<label htmlFor="guild-name" className="text-xs muted">
|
||||
{t("formName")}
|
||||
</label>
|
||||
<input
|
||||
id="guild-name"
|
||||
type="text"
|
||||
name="name"
|
||||
maxLength={50}
|
||||
defaultValue={guild.name}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-description" className="text-xs muted">
|
||||
{t("formDescription")}
|
||||
</label>
|
||||
<input
|
||||
id="guild-description"
|
||||
type="text"
|
||||
name="description"
|
||||
maxLength={250}
|
||||
defaultValue={guild.description}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-state" className="text-xs muted">
|
||||
{t("formState")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-state"
|
||||
name="state"
|
||||
defaultValue={guild.state}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="0">0 — Normal</option>
|
||||
<option value="1">1 — Locked</option>
|
||||
<option value="2">2 — Exclusive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-forum" className="text-xs muted">
|
||||
{t("formForum")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-forum"
|
||||
name="forum"
|
||||
defaultValue={guild.forum}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="0">0 — Off</option>
|
||||
<option value="1">1 — On</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-readForum" className="text-xs muted">
|
||||
{t("formReadForum")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-readForum"
|
||||
name="readForum"
|
||||
defaultValue={guild.readForum}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="EVERYONE">EVERYONE</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-postMessages" className="text-xs muted">
|
||||
{t("formPostMessages")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-postMessages"
|
||||
name="postMessages"
|
||||
defaultValue={guild.postMessages}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="EVERYONE">EVERYONE</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-postThreads" className="text-xs muted">
|
||||
{t("formPostThreads")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-postThreads"
|
||||
name="postThreads"
|
||||
defaultValue={guild.postThreads}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="EVERYONE">EVERYONE</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="guild-modForum" className="text-xs muted">
|
||||
{t("formModForum")}
|
||||
</label>
|
||||
<select
|
||||
id="guild-modForum"
|
||||
name="modForum"
|
||||
defaultValue={guild.modForum}
|
||||
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="ADMINS">ADMINS</option>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="NONE">NONE</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
|
||||
<div className="admin-card">
|
||||
<div className="text-xs muted">{t("colOwner")}</div>
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminMediaGrid } from "@/components/admin/media-grid";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
const AdminMediaGrid = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/media-grid").then((m) => ({
|
||||
default: m.AdminMediaGrid,
|
||||
})),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||
),
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export default async function AdminMediaPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -104,6 +104,7 @@ export function ModActionsClient() {
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="Enter user ID"
|
||||
aria-label="User ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +145,7 @@ export function ModActionsClient() {
|
||||
onChange={(e) => setMuteDuration(e.target.value)}
|
||||
className="w-24"
|
||||
placeholder="Min"
|
||||
aria-label="Mute duration in minutes"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -215,6 +217,7 @@ export function ModActionsClient() {
|
||||
value={roomId}
|
||||
onChange={(e) => setRoomId(e.target.value)}
|
||||
placeholder="Enter room ID"
|
||||
aria-label="Room ID"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
createValue,
|
||||
deleteCategory,
|
||||
deleteValue,
|
||||
updateCategory,
|
||||
updateValue,
|
||||
} from "@/actions/admin-rare-values";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -120,12 +122,49 @@ export default async function AdminRareValues() {
|
||||
{t("itemCount", { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
<form action={deleteCategory}>
|
||||
<input type="hidden" name="id" value={catId} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteCategory")}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="flex gap-2 items-center">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none text-xs font-bold text-[var(--admin-accent)] hover:underline">
|
||||
{t("editCategory")}
|
||||
</summary>
|
||||
<form
|
||||
action={updateCategory}
|
||||
className="mt-2 flex gap-2 flex-wrap"
|
||||
>
|
||||
<input type="hidden" name="id" value={catId} />
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={cat.name}
|
||||
placeholder={t("categoryForm.namePlaceholder")}
|
||||
required
|
||||
className="flex-1 min-w-[140px]"
|
||||
/>
|
||||
<input
|
||||
name="badge"
|
||||
defaultValue={cat.badge}
|
||||
placeholder={t("badgePlaceholder")}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="priority"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={cat.priority}
|
||||
placeholder={t("priorityPlaceholder")}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<Button type="submit" variant="default">
|
||||
{t("categoryForm.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
<form action={deleteCategory}>
|
||||
<input type="hidden" name="id" value={catId} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteCategory")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table style={{ marginTop: "0.75rem" }}>
|
||||
@@ -141,32 +180,108 @@ export default async function AdminRareValues() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((v) => (
|
||||
<tr key={String(v.id)}>
|
||||
<td>{v.name}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.itemId ?? "—"}
|
||||
</td>
|
||||
<td>{v.creditValue ?? "—"}</td>
|
||||
<td>{v.currencyValue ?? "—"}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{v.currencyType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.furnitureIcon}
|
||||
</td>
|
||||
<td>
|
||||
<form action={deleteValue}>
|
||||
<input type="hidden" name="id" value={String(v.id)} />
|
||||
<Button type="submit" variant="destructive">
|
||||
✕
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.map((v) => {
|
||||
const vId = String(v.id);
|
||||
return (
|
||||
<tr key={vId}>
|
||||
<td>{v.name}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.itemId ?? "—"}
|
||||
</td>
|
||||
<td>{v.creditValue ?? "—"}</td>
|
||||
<td>{v.currencyValue ?? "—"}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{v.currencyType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{v.furnitureIcon}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1 items-center">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none text-[0.72rem] font-bold text-[var(--admin-accent)] hover:underline">
|
||||
{t("editValue")}
|
||||
</summary>
|
||||
<form
|
||||
action={updateValue}
|
||||
className="mt-2 flex gap-1 flex-wrap items-end"
|
||||
>
|
||||
<input type="hidden" name="id" value={vId} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="categoryId"
|
||||
value={catId}
|
||||
/>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={v.name}
|
||||
placeholder={t(
|
||||
"itemForm.itemNamePlaceholder",
|
||||
)}
|
||||
required
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
<input
|
||||
name="itemId"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={v.itemId ?? ""}
|
||||
placeholder={t("itemForm.itemIdPlaceholder")}
|
||||
className="w-[90px]"
|
||||
/>
|
||||
<input
|
||||
name="furnitureIcon"
|
||||
defaultValue={v.furnitureIcon}
|
||||
placeholder={t("itemForm.iconPlaceholder")}
|
||||
required
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
<input
|
||||
name="creditValue"
|
||||
defaultValue={v.creditValue ?? ""}
|
||||
placeholder={t("itemForm.creditsPlaceholder")}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<input
|
||||
name="currencyValue"
|
||||
defaultValue={v.currencyValue ?? ""}
|
||||
placeholder={t(
|
||||
"itemForm.currencyValuePlaceholder",
|
||||
)}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<select
|
||||
name="currencyType"
|
||||
defaultValue={v.currencyType}
|
||||
>
|
||||
{CURRENCY_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("itemForm.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
<form action={deleteValue}>
|
||||
<input type="hidden" name="id" value={vId} />
|
||||
<Button type="submit" variant="destructive">
|
||||
✕
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 ? (
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function StudioCatalogMaintenancePage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
export default function StudioCatalogMaintenancePage() {
|
||||
return (
|
||||
<div className="min-h-0">
|
||||
<CatalogMaintenancePanel />
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
|
||||
import {
|
||||
createVoucher,
|
||||
deleteVoucher,
|
||||
updateVoucher,
|
||||
} from "@/actions/admin-vouchers";
|
||||
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
@@ -33,10 +37,15 @@ export function VouchersClient({
|
||||
const t = useTranslations("pages.admin.vouchers");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
|
||||
const [editingVoucher, setEditingVoucher] = useState<VoucherRow | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [maxUses, setMaxUses] = useState("1");
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [editCode, setEditCode] = useState("");
|
||||
const [editAmount, setEditAmount] = useState("");
|
||||
const [editMaxUses, setEditMaxUses] = useState("1");
|
||||
const [editExpiresAt, setEditExpiresAt] = useState("");
|
||||
|
||||
function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -72,6 +81,36 @@ export function VouchersClient({
|
||||
});
|
||||
}
|
||||
|
||||
function handleStartEdit(v: VoucherRow) {
|
||||
setEditingVoucher(v);
|
||||
setEditCode(v.code);
|
||||
setEditAmount(String(v.amount));
|
||||
setEditMaxUses(String(v.maxUses));
|
||||
setEditExpiresAt(
|
||||
v.expiresAt ? new Date(v.expiresAt).toISOString().slice(0, 16) : "",
|
||||
);
|
||||
}
|
||||
|
||||
function handleUpdate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editingVoucher || !canEdit) return;
|
||||
run(
|
||||
() =>
|
||||
updateVoucher({
|
||||
id: editingVoucher.id,
|
||||
code: editCode,
|
||||
amount: Number(editAmount),
|
||||
maxUses: Number(editMaxUses),
|
||||
expiresAt: editExpiresAt || undefined,
|
||||
}),
|
||||
{
|
||||
successMessage: t("updated"),
|
||||
errorMessage: t("updateError"),
|
||||
onSuccess: () => setEditingVoucher(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
@@ -158,6 +197,79 @@ export function VouchersClient({
|
||||
</form>
|
||||
)}
|
||||
|
||||
{canEdit && editingVoucher && (
|
||||
<form onSubmit={handleUpdate} className="admin-card mb-6">
|
||||
<h3 style={{ marginTop: 0 }}>
|
||||
{t("edit")} — {editingVoucher.code}
|
||||
</h3>
|
||||
<div className="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.code")}
|
||||
</span>
|
||||
<input
|
||||
value={editCode}
|
||||
onChange={(e) => setEditCode(e.target.value)}
|
||||
placeholder={t("form.codePlaceholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.amount")}
|
||||
</span>
|
||||
<input
|
||||
value={editAmount}
|
||||
onChange={(e) => setEditAmount(e.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.maxUses")}
|
||||
</span>
|
||||
<input
|
||||
value={editMaxUses}
|
||||
onChange={(e) => setEditMaxUses(e.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{t("form.expiresAt")}
|
||||
</span>
|
||||
<input
|
||||
value={editExpiresAt}
|
||||
onChange={(e) => setEditExpiresAt(e.target.value)}
|
||||
type="datetime-local"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isPending ? t("form.saving") : t("form.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setEditingVoucher(null)}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("title")} ({vouchers.length})
|
||||
@@ -216,14 +328,24 @@ export function VouchersClient({
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => setPendingDelete(v)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() => handleStartEdit(v)}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => setPendingDelete(v)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { eq, like, or } from "drizzle-orm";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiOk } from "@/lib/api-response";
|
||||
import {
|
||||
db,
|
||||
Guilds,
|
||||
Rooms,
|
||||
User,
|
||||
WebsiteArticles,
|
||||
WebsiteRareValues,
|
||||
WebsiteShopArticles,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
type SearchResult = {
|
||||
type: string;
|
||||
id: number | string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const PER_TYPE = 5;
|
||||
const MAX_TOTAL = 20;
|
||||
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ADMIN_DASHBOARD },
|
||||
async (request) => {
|
||||
const q = (request.nextUrl.searchParams.get("q") || "").trim();
|
||||
if (q.length < 2) {
|
||||
return apiOk({ results: [] });
|
||||
}
|
||||
|
||||
const pattern = `%${q}%`;
|
||||
const idExact = Number.parseInt(q, 10);
|
||||
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
||||
|
||||
const [users, articles, rooms, guilds, shopArticles, rareValues] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
subtitle: User.mail,
|
||||
})
|
||||
.from(User)
|
||||
.where(
|
||||
or(
|
||||
like(User.username, pattern),
|
||||
like(User.mail, pattern),
|
||||
...(hasId ? [eq(User.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
subtitle: WebsiteArticles.slug,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteArticles.title, pattern),
|
||||
like(WebsiteArticles.slug, pattern),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
title: Rooms.name,
|
||||
subtitle: Rooms.ownerName,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(
|
||||
or(
|
||||
like(Rooms.name, pattern),
|
||||
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
title: Guilds.name,
|
||||
subtitle: Guilds.description,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(
|
||||
or(
|
||||
like(Guilds.name, pattern),
|
||||
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: WebsiteShopArticles.id,
|
||||
title: WebsiteShopArticles.name,
|
||||
subtitle: WebsiteShopArticles.info,
|
||||
})
|
||||
.from(WebsiteShopArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteShopArticles.name, pattern),
|
||||
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: WebsiteRareValues.id,
|
||||
title: WebsiteRareValues.name,
|
||||
subtitle: WebsiteRareValues.itemId,
|
||||
})
|
||||
.from(WebsiteRareValues)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteRareValues.name, pattern),
|
||||
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE),
|
||||
]);
|
||||
|
||||
const groupMap: Record<
|
||||
string,
|
||||
{ type: string; items: Array<SearchResult> }
|
||||
> = {
|
||||
users: { type: "users", items: [] },
|
||||
articles: { type: "articles", items: [] },
|
||||
rooms: { type: "rooms", items: [] },
|
||||
guilds: { type: "guilds", items: [] },
|
||||
shop: { type: "shop", items: [] },
|
||||
rareValues: { type: "rareValues", items: [] },
|
||||
};
|
||||
|
||||
for (const r of users) {
|
||||
groupMap.users.items.push({
|
||||
type: "users",
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/users/show/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of articles) {
|
||||
groupMap.articles.items.push({
|
||||
type: "articles",
|
||||
id: Number(r.id),
|
||||
title: r.title,
|
||||
subtitle: r.subtitle,
|
||||
url: `/admin/articles/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of rooms) {
|
||||
groupMap.rooms.items.push({
|
||||
type: "rooms",
|
||||
id: r.id,
|
||||
title: r.title || `Room #${r.id}`,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/rooms/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of guilds) {
|
||||
groupMap.guilds.items.push({
|
||||
type: "guilds",
|
||||
id: r.id,
|
||||
title: r.title || `Guild #${r.id}`,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/guilds/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of shopArticles) {
|
||||
groupMap.shop.items.push({
|
||||
type: "shop",
|
||||
id: Number(r.id),
|
||||
title: r.title,
|
||||
subtitle: r.subtitle || "",
|
||||
url: `/admin/shop/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of rareValues) {
|
||||
groupMap.rareValues.items.push({
|
||||
type: "rareValues",
|
||||
id: Number(r.id),
|
||||
title: r.title,
|
||||
subtitle: r.subtitle != null ? `Item #${r.subtitle}` : "",
|
||||
url: `/admin/rare-values/${r.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
const order = [
|
||||
"users",
|
||||
"articles",
|
||||
"rooms",
|
||||
"guilds",
|
||||
"shop",
|
||||
"rareValues",
|
||||
];
|
||||
for (const key of order) {
|
||||
for (const item of groupMap[key].items) {
|
||||
results.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return apiOk({ results: results.slice(0, MAX_TOTAL) });
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||
@@ -30,7 +30,16 @@ export async function GET(
|
||||
updatedAt: WebsiteArticles.updatedAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.slug, slug),
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!article) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { count, desc } from "drizzle-orm";
|
||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { apiJson, pagination } from "@/lib/api";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||
@@ -17,8 +17,18 @@ export async function GET(req: Request) {
|
||||
apiCacheKey(`articles:${page}:${perPage}`),
|
||||
60,
|
||||
async () => {
|
||||
const publishedFilter = and(
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
);
|
||||
const [totalRows, articles] = await Promise.all([
|
||||
db.select({ total: count() }).from(WebsiteArticles),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteArticles)
|
||||
.where(publishedFilter),
|
||||
db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
@@ -29,6 +39,7 @@ export async function GET(req: Request) {
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(publishedFilter)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(take)
|
||||
.offset(skip),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { env } from "@/env";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
@@ -24,6 +24,15 @@ export async function GET(_req: Request) {
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
and(
|
||||
eq(WebsiteArticles.status, "published"),
|
||||
or(
|
||||
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(4),
|
||||
db.select({ total: count() }).from(User).where(eq(User.online, "1")),
|
||||
|
||||
@@ -74,10 +74,10 @@ export default async function ModBansPage() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th scope="col">{t("colUserId")}</th>
|
||||
<th scope="col">{t("colType")}</th>
|
||||
<th scope="col">{t("colReason")}</th>
|
||||
<th scope="col">{t("colExpires")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, SupportTickets, User } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
const SORT_COLS = {
|
||||
id: SupportTickets.id,
|
||||
@@ -55,7 +56,8 @@ export default async function ModCfhListPage({
|
||||
.where(like(User.username, `%${q}%`))
|
||||
.limit(50);
|
||||
userIds = users.map((u) => u.id);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logServerError("cfh.user_search_failed", error, { query: q });
|
||||
userIds = [];
|
||||
}
|
||||
|
||||
@@ -91,7 +93,10 @@ export default async function ModCfhListPage({
|
||||
.from(SupportTickets)
|
||||
.where(where)
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0);
|
||||
.catch((error) => {
|
||||
logServerError("cfh.count_failed", error);
|
||||
return 0;
|
||||
});
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await db
|
||||
@@ -101,7 +106,10 @@ export default async function ModCfhListPage({
|
||||
.orderBy(finalOrder)
|
||||
.offset(pagination.offset)
|
||||
.limit(pagination.perPage)
|
||||
.catch(() => []);
|
||||
.catch((error) => {
|
||||
logServerError("cfh.query_failed", error);
|
||||
return [];
|
||||
});
|
||||
|
||||
const userIds = [
|
||||
...new Set([
|
||||
|
||||
@@ -8,6 +8,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import type { DataTableColumn } from "@/types/common";
|
||||
|
||||
type ModUserRow = {
|
||||
@@ -139,7 +140,10 @@ export default async function ModUsersPage({
|
||||
.from(User)
|
||||
.where(where)
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
.catch((error) => {
|
||||
logServerError("users.count_failed", error);
|
||||
return 0;
|
||||
}),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
@@ -155,7 +159,10 @@ export default async function ModUsersPage({
|
||||
.orderBy(finalOrder)
|
||||
.offset((parsed.page - 1) * parsed.perPage)
|
||||
.limit(parsed.perPage)
|
||||
.catch(() => [] as ModUserRow[]),
|
||||
.catch((error) => {
|
||||
logServerError("users.query_failed", error);
|
||||
return [] as ModUserRow[];
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = totals;
|
||||
|
||||
Reference in new issue
Block a user