refactor: full codebase overhaul — dead code removal, env validation, logger migration, date consolidation, Prisma schema cleanup, button consistency, useEffect deps, test coverage
- env.ts: added 10 missing Zod-validated env vars (imager, paypal currency, argon2/bcrypt params) - Migrated 6 modules from process.env to validated env.* (auth, proxy-auth, paypal, password, redis, imager, moderation, alert, logger) - Replaced console.warn/error with pino logger in 9 server-side modules - Removed 50+ dead exports (SWF wrappers, coalesceHotelName, signIn, isStaff re-export, formatTimestamp, Skeleton/SkeletonCard, 4 unused housekeeping sections) - Consolidated date formatting: 28 files migrated to shared formatDate() from @/lib/format-date - Wired 4 radio/settings API routes through cached siteSettings service instead of raw Prisma queries - Added getMany()/getAll() helpers to SiteSettings service - Removed 88 dead Prisma model definitions (schema 2763→1846 lines) - Created admin action-helper.ts with wrapAction() for standardized error handling - Fixed useEffect dependency arrays in 4 data-heavy components - Replaced raw btn CSS classes with shadcn Button component across admin pages - Stripped dead i18n namespaces (common, pages.client) from all 22 translation files - Removed 2 dead scripts (create-release.sh, check-local-imports.ts) - Fixed knip.json configuration - Added 7 new test suites: format-date, paypal, moderation, alert, webhook, action-helper, and fixed password.test.ts for env mocking - All 358 tests passing across 72 test files - TypeScript: 0 errors
This commit is contained in:
1 parent
12e4a07e48
commit
1acace49d0
150 files changed
+10210
-12272
No files matched your search
@@ -9,6 +9,7 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
@@ -34,8 +35,11 @@ export async function createAd(formData: FormData): Promise<void> {
|
||||
targetType: "website_ad",
|
||||
targetId: Number(ad.id),
|
||||
});
|
||||
} catch {
|
||||
// DB error — page re-renders unchanged.
|
||||
} catch (err) {
|
||||
logger.error("Action failed: createAd", {
|
||||
action: "createAd",
|
||||
error: err instanceof Error ? err.message : "DB error",
|
||||
});
|
||||
revalidatePath("/admin/ads");
|
||||
return;
|
||||
}
|
||||
@@ -65,8 +69,12 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
} catch (err) {
|
||||
logger.error("Action failed: updateAd", {
|
||||
action: "updateAd",
|
||||
id: Number(id),
|
||||
error: err instanceof Error ? err.message : "DB error",
|
||||
});
|
||||
revalidatePath(`/admin/ads/${id}`);
|
||||
return;
|
||||
}
|
||||
@@ -115,8 +123,12 @@ export async function deleteAdForm(formData: FormData): Promise<void> {
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
} catch (err) {
|
||||
logger.error("Action failed: deleteAdForm", {
|
||||
action: "deleteAdForm",
|
||||
id: Number(id),
|
||||
error: err instanceof Error ? err.message : "DB error",
|
||||
});
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
+11
-4
@@ -3,6 +3,7 @@
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
@@ -23,8 +24,11 @@ export async function signOutEverywhere(): Promise<void> {
|
||||
data: { websiteJwtVersion: { increment: 1 } },
|
||||
});
|
||||
await invalidateJwtVersionCache(userId);
|
||||
} catch {
|
||||
/* still continue */
|
||||
} catch (err) {
|
||||
logger.warn("Failed to bump JWT version during sign-out-everywhere", {
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
|
||||
@@ -32,8 +36,11 @@ export async function signOutEverywhere(): Promise<void> {
|
||||
await prisma.personalAccessTokens.deleteMany({
|
||||
where: personalTokenScope(userId),
|
||||
});
|
||||
} catch {
|
||||
/* still sign out locally */
|
||||
} catch (err) {
|
||||
logger.warn("Failed to revoke personal access tokens during sign-out-everywhere", {
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
await signOut({ redirectTo: "/login?signedOutAll=1" });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { applyStaff } from "@/actions/applications";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -55,11 +56,6 @@ export default async function ApplyStaffPage({
|
||||
const userId = Number(session.user.id);
|
||||
const hotelName = await resolveHotelName();
|
||||
|
||||
function formatDeadline(d: Date | null | undefined): string {
|
||||
if (!d) return t("noDeadline");
|
||||
return d.toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
// ── Open positions ──────────────────────────────────────────
|
||||
// Each query is isolated so a DB hiccup degrades that section to empty
|
||||
// rather than 500-ing the whole page.
|
||||
@@ -149,7 +145,7 @@ export default async function ApplyStaffPage({
|
||||
<div className="content-card-body">
|
||||
<p style={{ margin: "0 0 0.5rem" }}>{position.description}</p>
|
||||
<p className="muted" style={{ margin: "0 0 0.75rem" }}>
|
||||
{t("deadline", { date: formatDeadline(position.applyTo) })}
|
||||
{t("deadline", { date: formatDate(position.applyTo, "datetime", t("noDeadline")) })}
|
||||
</p>
|
||||
|
||||
{alreadyApplied ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { replyToThread } from "@/actions/social";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -27,11 +28,6 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function GuildForumThreadPage({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -220,7 +216,7 @@ export default async function GuildForumThreadPage({
|
||||
)}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
{formatTimestamp(post.createdAt)}
|
||||
{formatDate(post.createdAt ? new Date(post.createdAt * 1000) : null)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -36,11 +37,6 @@ type ThreadRow = {
|
||||
locked: number | null;
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function GuildForumPage({
|
||||
params,
|
||||
searchParams,
|
||||
@@ -194,7 +190,7 @@ export default async function GuildForumPage({
|
||||
{t2.postsCount ?? 0}
|
||||
</td>
|
||||
<td className="muted" style={{ padding: "0.6rem 0.75rem" }}>
|
||||
{formatTimestamp(t2.createdAt)}
|
||||
{formatDate(t2.createdAt ? new Date(t2.createdAt * 1000) : null, "date")}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const revalidate = 300;
|
||||
@@ -26,11 +27,6 @@ type MemberView = {
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function GuildPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -123,7 +119,7 @@ export default async function GuildPage({
|
||||
return a.memberSince - b.memberSince;
|
||||
});
|
||||
|
||||
const created = formatTimestamp(guild.dateCreated);
|
||||
const created = formatDate(new Date(guild.dateCreated * 1000), "date");
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -26,10 +27,6 @@ function listedAgo(ts: number, t: RelativeTimeTranslator): string {
|
||||
return t("daysAgo", { count: days });
|
||||
}
|
||||
|
||||
function listedAt(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function MarketplacePage() {
|
||||
const t = await getTranslations("pages.marketplace");
|
||||
let offers: {
|
||||
@@ -127,7 +124,7 @@ export default async function MarketplacePage() {
|
||||
<td style={{ fontWeight: 700 }}>
|
||||
{o.price.toLocaleString()} cr
|
||||
</td>
|
||||
<td className="muted" title={listedAt(o.timestamp)}>
|
||||
<td className="muted" title={formatDate(new Date(o.timestamp * 1000))}>
|
||||
{listedAgo(o.timestamp, t)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -39,12 +40,6 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
if (!seconds) return "";
|
||||
// sended_on is a unix timestamp in seconds (emulator convention).
|
||||
return new Date(seconds * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function MessagesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -353,7 +348,7 @@ export default async function MessagesPage({
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
});
|
||||
const when = formatTimestamp(msg.sendedOn);
|
||||
const when = formatDate(new Date(msg.sendedOn * 1000));
|
||||
return (
|
||||
<article
|
||||
key={msg.id}
|
||||
|
||||
@@ -205,7 +205,7 @@ export default async function ArticlePage({
|
||||
title={article.title}
|
||||
subtitle={
|
||||
article.createdAt
|
||||
? article.createdAt.toISOString().slice(0, 10)
|
||||
? formatDate(article.createdAt, "date")
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type LightboxPhoto,
|
||||
PhotoLightbox,
|
||||
} from "@/components/public/photo-lightbox";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -18,13 +19,6 @@ type Photo = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function PhotosPage() {
|
||||
const t = await getTranslations("pages.photos");
|
||||
|
||||
@@ -44,7 +38,7 @@ export default async function PhotosPage() {
|
||||
id: String(p.id),
|
||||
url: p.url,
|
||||
alt: t("photoAlt", { id: p.userId }),
|
||||
date: formatDate(p.timestamp),
|
||||
date: formatDate(new Date(p.timestamp * 1000)),
|
||||
user: t("byUser", { id: p.userId }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CSSProperties } from "react";
|
||||
import { applyDj } from "@/actions/radio-apply";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -57,7 +58,7 @@ export default async function RadioApplyPage({
|
||||
.catch(() => null);
|
||||
|
||||
const submittedDate = existing?.createdAt
|
||||
? existing.createdAt.toISOString().slice(0, 10)
|
||||
? formatDate(existing.createdAt, "date")
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,16 +3,13 @@ import { getTranslations } from "next-intl/server";
|
||||
import { signOutEverywhere } from "@/actions/sessions";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = Promise<{ signedOutAll?: string }>;
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
/** Tiny user-agent → "Browser on OS" summary (no dependency). */
|
||||
function deviceLabel(ua: string | null, unknown: string): string {
|
||||
if (!ua) return unknown;
|
||||
@@ -123,7 +120,7 @@ export default async function SessionsPage({
|
||||
{logins.map((l) => (
|
||||
<tr key={String(l.id)}>
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{fromDate(l.createdAt)}
|
||||
{formatDate(l.createdAt)}
|
||||
</td>
|
||||
<td className="muted">{l.ip}</td>
|
||||
<td>{deviceLabel(l.userAgent, t("unknownDevice"))}</td>
|
||||
@@ -151,9 +148,7 @@ export default async function SessionsPage({
|
||||
{failed.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td className="muted" style={{ whiteSpace: "nowrap" }}>
|
||||
{typeof f.attempted_at === "string"
|
||||
? f.attempted_at.slice(0, 16).replace("T", " ")
|
||||
: fromDate(f.attempted_at)}
|
||||
{formatDate(typeof f.attempted_at === "string" ? new Date(f.attempted_at) : f.attempted_at)}
|
||||
</td>
|
||||
<td className="muted">{f.ip_address}</td>
|
||||
</tr>
|
||||
|
||||
@@ -67,10 +67,7 @@ const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
const CURRENCY_DUCKETS = 0;
|
||||
const CURRENCY_DIAMONDS = 5;
|
||||
|
||||
function formatTimestamp(ts: number | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
@@ -107,9 +104,7 @@ export default async function ProfilePage({
|
||||
if (!user) notFound();
|
||||
|
||||
const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" });
|
||||
const registered = new Date(user.accountCreated * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const registered = formatDate(new Date(user.accountCreated * 1000), "date");
|
||||
const meId = Number(session?.user?.id);
|
||||
const isLoggedIn = Number.isInteger(meId) && meId > 0;
|
||||
const isSelf = isLoggedIn && meId === user.id;
|
||||
@@ -601,7 +596,7 @@ export default async function ProfilePage({
|
||||
}}
|
||||
/>
|
||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
||||
{formatTimestamp(p.timestamp)}
|
||||
{formatDate(new Date(p.timestamp * 1000), "date")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { updateAd } from "@/actions/admin-ads";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { EditAdDeleteButton } from "../ads-table";
|
||||
@@ -54,9 +55,9 @@ export default async function EditAd({
|
||||
required
|
||||
maxLength={255}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<EditAdDeleteButton id={String(ad.id)} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { deleteAd } from "@/actions/admin-ads";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
@@ -64,15 +65,16 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm inline-flex items-center gap-1"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete(ad)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -106,14 +108,14 @@ export function EditAdDeleteButton({ id }: { id: string }) {
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t("form.deleteAd")}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createAd } from "@/actions/admin-ads";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function NewAd() {
|
||||
@@ -27,9 +28,9 @@ export default async function NewAd() {
|
||||
</div>
|
||||
<form action={createAd} className="admin-card">
|
||||
<input name="image" placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
Create
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -26,15 +28,17 @@ export default async function AdminAds() {
|
||||
}
|
||||
|
||||
const latest = ads[0]?.createdAt
|
||||
? ads[0].createdAt.toISOString().slice(0, 10)
|
||||
? formatDate(ads[0].createdAt, "date")
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Link href="/admin/ads/new" className="btn btn-primary">
|
||||
+ {t("newAd")}
|
||||
</Link>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/ads/new">
|
||||
+ {t("newAd")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
@@ -57,7 +61,7 @@ export default async function AdminAds() {
|
||||
id: String(ad.id),
|
||||
image: ad.image,
|
||||
createdAt: ad.createdAt
|
||||
? ad.createdAt.toISOString().slice(0, 10)
|
||||
? formatDate(ad.createdAt, "date")
|
||||
: null,
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { sendHotelAlert } from "@/actions/admin-alerts";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -8,10 +10,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
const CRITICAL = new Set(["critical", "error", "danger"]);
|
||||
const WARNING = new Set(["warning", "warn"]);
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
function severityColor(severity: string): string {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
@@ -118,9 +116,9 @@ export default async function AdminAlerts() {
|
||||
placeholder={t("form.messagePlaceholder")}
|
||||
className="flex-1 min-w-[240px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.send")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -143,7 +141,7 @@ export default async function AdminAlerts() {
|
||||
{alerts.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fromDate(a.createdAt)}
|
||||
{formatDate(a.createdAt)}
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { dismissApplication } from "@/actions/admin-applications";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -95,9 +96,9 @@ export default async function AdminApplications() {
|
||||
</p>
|
||||
<form action={dismissApplication}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -75,9 +76,9 @@ export default async function EditArticle({
|
||||
/>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(article.id)} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.deleteArticle")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,10 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArticleCard } from "@/components/admin/article-card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -48,15 +50,17 @@ export default async function AdminArticles({
|
||||
}
|
||||
|
||||
const latest = articles[0]?.createdAt
|
||||
? articles[0].createdAt.toISOString().slice(0, 10)
|
||||
? formatDate(articles[0].createdAt, "date")
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Link href="/admin/articles/new" className="btn btn-primary">
|
||||
{t("newArticle")}
|
||||
</Link>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/articles/new">
|
||||
{t("newArticle")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { uploadBadge } from "@/actions/admin-badge-upload";
|
||||
import { giveBadge } from "@/actions/admin-badges";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -61,9 +62,9 @@ export default async function AdminBadges({
|
||||
pattern="[A-Za-z0-9_-]{1,64}"
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("uploadButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -94,9 +95,9 @@ export default async function AdminBadges({
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("giveButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -142,9 +143,9 @@ export default async function AdminBadges({
|
||||
required
|
||||
className="w-[90px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("giveButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,16 +2,14 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromUnix(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function AdminBans() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
|
||||
@@ -81,9 +79,9 @@ export default async function AdminBans() {
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("ban")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -122,16 +120,16 @@ export default async function AdminBans() {
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fromUnix(b.banExpire)}
|
||||
{formatDate(new Date(b.banExpire * 1000))}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<button type="submit" className="btn btn-outline">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("lift")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -31,11 +32,6 @@ type Reward = {
|
||||
subscriptionDays: number;
|
||||
};
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminCalendarCampaign({
|
||||
params,
|
||||
}: {
|
||||
@@ -119,7 +115,7 @@ export default async function AdminCalendarCampaign({
|
||||
{t("detail.campaignMeta", {
|
||||
id: campaign.id,
|
||||
days: campaign.totalDays,
|
||||
date: fmtDate(campaign.startTimestamp),
|
||||
date: formatDate(new Date(campaign.startTimestamp * 1000), "date"),
|
||||
})}
|
||||
{disabled
|
||||
? ` · ${t("detail.disabled")}`
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -17,11 +18,6 @@ type Campaign = {
|
||||
enabled: string;
|
||||
};
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminCalendar() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) {
|
||||
@@ -122,7 +118,7 @@ export default async function AdminCalendar() {
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
|
||||
{t("startsAt", { date: fmtDate(c.startTimestamp) })}
|
||||
{t("startsAt", { date: formatDate(new Date(c.startTimestamp * 1000), "date") })}
|
||||
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
updateNavigator,
|
||||
updateWordFilter,
|
||||
} from "@/actions/commandocentrum";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
const RCON_ERROR = "RCON command failed. Is the emulator running?";
|
||||
@@ -56,9 +57,9 @@ export function CommandocentrumControls() {
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateCatalogDesc")}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateCatalog(), {
|
||||
@@ -68,7 +69,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("updateCatalog")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
@@ -78,9 +79,9 @@ export function CommandocentrumControls() {
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateWordFilterDesc")}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateWordFilter(), {
|
||||
@@ -90,7 +91,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("updateWordFilter")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
@@ -100,9 +101,9 @@ export function CommandocentrumControls() {
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{t("updateNavigatorDesc")}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(() => updateNavigator(), {
|
||||
@@ -112,7 +113,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("updateNavigator")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,9 +134,9 @@ export function CommandocentrumControls() {
|
||||
className="flex-1 min-w-[220px]"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
variant="destructive"
|
||||
disabled={isPending || !hotelMessage.trim()}
|
||||
onClick={() =>
|
||||
run(() => hotelAlert({ message: hotelMessage }), {
|
||||
@@ -146,7 +147,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("sendAlert")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -187,9 +188,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-danger w-full"
|
||||
variant="destructive" className="w-full"
|
||||
disabled={
|
||||
isPending ||
|
||||
!disconnect.userId ||
|
||||
@@ -212,7 +213,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("actions.disconnect")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -246,9 +247,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={
|
||||
isPending || !alert.userId || !alert.message.trim()
|
||||
}
|
||||
@@ -268,7 +269,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("actions.alert")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,9 +303,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !forward.userId || !forward.roomId}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -323,7 +324,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("forwardUser")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,9 +360,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !credits.userId || !credits.credits}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -380,7 +381,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("giveCredits")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -414,9 +415,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !duckets.userId || !duckets.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -435,7 +436,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("giveDuckets")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -469,9 +470,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !diamonds.userId || !diamonds.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -490,7 +491,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("giveDiamonds")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,9 +527,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !badge.userId || !badge.badge.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -546,7 +547,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("giveBadge")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -580,9 +581,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !motto.userId || !motto.motto.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -601,7 +602,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("setMotto")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -636,9 +637,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !rank.userId || rank.rank === ""}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -656,7 +657,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("setRank")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -692,9 +693,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={
|
||||
isPending || !command.userId || !command.command.trim()
|
||||
}
|
||||
@@ -715,7 +716,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("execute")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -760,9 +761,9 @@ export function CommandocentrumControls() {
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-primary w-full"
|
||||
variant="default" className="w-full"
|
||||
disabled={isPending || !gift.userId || !gift.itemId}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -786,7 +787,7 @@ export function CommandocentrumControls() {
|
||||
}
|
||||
>
|
||||
{t("sendGift")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,21 +9,13 @@ import {
|
||||
} from "@/components/admin/dashboard";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatTimestamp(ts: number | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
function decodeStacktrace(raw: unknown): string {
|
||||
if (raw == null) return "";
|
||||
try {
|
||||
@@ -229,7 +221,7 @@ export default async function CommandoCentrum() {
|
||||
{activity.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fromDate(a.createdAt)}
|
||||
{formatDate(a.createdAt, "datetime-seconds")}
|
||||
</td>
|
||||
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
||||
<td>
|
||||
@@ -276,7 +268,7 @@ export default async function CommandoCentrum() {
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatTimestamp(e.timestamp)}
|
||||
{formatDate(e.timestamp ? new Date(e.timestamp * 1000) : null, "datetime-seconds")}
|
||||
</td>
|
||||
<td>{e.type}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deleteEmailTemplate,
|
||||
updateEmailTemplate,
|
||||
} from "@/actions/admin-email-templates";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -69,9 +70,9 @@ export default async function AdminEmailTemplates() {
|
||||
rows={5}
|
||||
className="w-full resize-y mb-3"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("create")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="grid">
|
||||
@@ -116,9 +117,9 @@ export default async function AdminEmailTemplates() {
|
||||
className="w-full resize-y mb-2"
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("save")}
|
||||
</button>
|
||||
</Button>
|
||||
{template.updatedAt ? (
|
||||
<span className="text-xs theme-text-muted dark:theme-text-muted">
|
||||
{t("updated", {
|
||||
@@ -133,9 +134,9 @@ export default async function AdminEmailTemplates() {
|
||||
</form>
|
||||
<form action={deleteEmailTemplate} className="mt-4">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
updateEmulatorText,
|
||||
} from "@/actions/admin-emulator";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -76,9 +77,9 @@ export default async function AdminEmulator() {
|
||||
maxLength={512}
|
||||
className="flex-1 min-w-[200px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -104,9 +105,9 @@ export default async function AdminEmulator() {
|
||||
maxLength={512}
|
||||
className="flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("save")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -142,9 +143,9 @@ export default async function AdminEmulator() {
|
||||
maxLength={4096}
|
||||
className="flex-1 min-w-[200px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -170,9 +171,9 @@ export default async function AdminEmulator() {
|
||||
maxLength={4096}
|
||||
className="flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
<Button type="submit" variant="outline">
|
||||
{t("save")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LayoutDashboard, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Admin route-segment error boundary. Renders inside the admin layout shell.
|
||||
@@ -47,14 +48,16 @@ export default function AdminErrorPage({
|
||||
{t("body")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
<button type="button" className="btn btn-primary" onClick={() => reset()}>
|
||||
<Button type="button" variant="default" onClick={() => reset()}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
{t("tryAgain")}
|
||||
</button>
|
||||
<Link className="btn btn-outline" href="/admin">
|
||||
<LayoutDashboard className="size-4" aria-hidden />
|
||||
{tNav("dashboard")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin">
|
||||
<LayoutDashboard className="size-4" aria-hidden />
|
||||
{tNav("dashboard")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{error.digest ? (
|
||||
<p
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { EventsTable } from "./events-table";
|
||||
@@ -46,9 +47,11 @@ export default async function EventsPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Link href="/admin/events/create" className="btn btn-primary">
|
||||
{t("newEvent")}
|
||||
</Link>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/events/create">
|
||||
{t("newEvent")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EventsTable
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { deleteFavicon, saveFavicon } from "@/actions/save-favicon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { FaviconGenerator } from "./favicon-generator";
|
||||
|
||||
@@ -116,22 +117,22 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="btn btn-primary"
|
||||
variant="default"
|
||||
>
|
||||
{uploading ? t("uploading") : t("upload")}
|
||||
</button>
|
||||
</Button>
|
||||
{preview && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="btn btn-danger"
|
||||
variant="destructive"
|
||||
>
|
||||
{deleting ? t("deleting") : t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const SIZE = 64;
|
||||
const HALF = SIZE / 2;
|
||||
@@ -185,13 +186,14 @@ export function FaviconGenerator({ onSave }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="btn btn-primary w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,13 @@ import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { disbandGuild } from "@/actions/admin-guilds";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function AdminGuildDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -97,26 +94,30 @@ export default async function AdminGuildDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/admin/guilds" className="btn btn-ghost btn-sm">
|
||||
{t("back")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/guilds/${guild.id}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
target="_blank"
|
||||
>
|
||||
{t("viewPublic")}
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/admin/guilds">
|
||||
{t("back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
href={`/guilds/${guild.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
{t("viewPublic")}
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<form action={disbandGuild}>
|
||||
<input type="hidden" name="id" value={guild.id} />
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-sm"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
style={{ color: "var(--admin-error)" }}
|
||||
>
|
||||
{t("disband")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -151,7 +152,7 @@ export default async function AdminGuildDetailPage({
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="text-xs muted">{t("colCreated")}</div>
|
||||
<div>{fmt(guild.dateCreated)}</div>
|
||||
<div>{formatDate(new Date(guild.dateCreated * 1000))}</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="text-xs muted">{t("colRoom")}</div>
|
||||
@@ -192,7 +193,7 @@ export default async function AdminGuildDetailPage({
|
||||
</Link>
|
||||
</td>
|
||||
<td className="muted">{m.levelId}</td>
|
||||
<td className="muted">{fmt(m.memberSince)}</td>
|
||||
<td className="muted">{formatDate(new Date(m.memberSince * 1000))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -3,17 +3,14 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminGuildsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -122,9 +119,9 @@ export default async function AdminGuildsPage({
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="admin-input flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm">
|
||||
<Button type="submit" variant="default" size="sm">
|
||||
{t("search")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
@@ -187,7 +184,7 @@ export default async function AdminGuildsPage({
|
||||
</Link>
|
||||
</td>
|
||||
<td>{membersByGuild.get(g.id) ?? 0}</td>
|
||||
<td className="muted">{fmt(g.dateCreated)}</td>
|
||||
<td className="muted">{formatDate(new Date(g.dateCreated * 1000), "date")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HelpCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { deleteHelpQuestion, updateHelpQuestion } from "@/actions/admin-help";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -126,15 +127,15 @@ export default async function EditHelpQuestion({
|
||||
/>
|
||||
Small box
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
Save
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<form action={deleteHelpQuestion} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(entry.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
Delete entry
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { createHelpQuestion } from "@/actions/admin-help";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -85,9 +86,9 @@ export default async function NewHelpQuestion({
|
||||
<input name="smallBox" type="checkbox" value="1" />
|
||||
Small box
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
Create
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -32,9 +33,11 @@ export default async function AdminHelpQuestions() {
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Link href="/admin/help-questions/new" className="btn btn-primary">
|
||||
+ {t("newEntry")}
|
||||
</Link>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/help-questions/new">
|
||||
+ {t("newEntry")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
HelpTicketsTable,
|
||||
@@ -12,10 +13,6 @@ import {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "—";
|
||||
}
|
||||
|
||||
export default async function AdminHelpTicketsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -158,8 +155,8 @@ export default async function AdminHelpTicketsPage({
|
||||
userId: ticket.userId,
|
||||
replies: replyCountByTicket.get(String(ticket.id)) ?? 0,
|
||||
open: ticket.open,
|
||||
date: fromDate(ticket.createdAt),
|
||||
updated: fromDate(ticket.updatedAt),
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
updated: formatDate(ticket.updatedAt, "date"),
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,811 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
applyPreset,
|
||||
bulkDeletePermissions,
|
||||
bulkImportPermissions,
|
||||
clearAllPermissions,
|
||||
deletePermission,
|
||||
exportPermissions,
|
||||
upsertPermission,
|
||||
} from "@/actions/admin-housekeeping";
|
||||
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface HKPerm {
|
||||
id: bigint;
|
||||
permission: string;
|
||||
minRank: number;
|
||||
description: string | null;
|
||||
groupName: string | null;
|
||||
dependsOn: string | null;
|
||||
}
|
||||
|
||||
interface SharedProps {
|
||||
permissions: HKPerm[];
|
||||
groups: string[];
|
||||
groupCounts: Record<string, number>;
|
||||
permSet: Set<string>;
|
||||
}
|
||||
|
||||
/* ────────── Manage Section ────────── */
|
||||
|
||||
export function ManageSection(props: SharedProps) {
|
||||
const t = useTranslations("pages.admin.housekeeping");
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [search, setSearch] = useState(sp.get("search") ?? "");
|
||||
const [groupFilter, setGroupFilter] = useState(sp.get("group") ?? "");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pendingDel, setPendingDel] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [selectAllMatching, setSelectAllMatching] = useState(false);
|
||||
const [busyBulk, setBusyBulk] = useState(false);
|
||||
const [confirmDel, setConfirmDel] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [sortKey, setSortKey] = useState<string>("permission");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const editRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return props.permissions.filter((p) => {
|
||||
if (search && !p.permission.toLowerCase().includes(search.toLowerCase()))
|
||||
return false;
|
||||
if (groupFilter && (p.groupName ?? "") !== groupFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [props.permissions, search, groupFilter]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...filtered];
|
||||
arr.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortKey) {
|
||||
case "permission":
|
||||
cmp = a.permission.localeCompare(b.permission);
|
||||
break;
|
||||
case "minRank":
|
||||
cmp = a.minRank - b.minRank;
|
||||
break;
|
||||
case "groupName":
|
||||
cmp = (a.groupName ?? "").localeCompare(b.groupName ?? "");
|
||||
break;
|
||||
case "description":
|
||||
cmp = (a.description ?? "").localeCompare(b.description ?? "");
|
||||
break;
|
||||
case "dependsOn":
|
||||
cmp = (a.dependsOn ?? "").localeCompare(b.dependsOn ?? "");
|
||||
break;
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
return arr;
|
||||
}, [filtered, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const paginated = sorted.slice(
|
||||
(safePage - 1) * PAGE_SIZE,
|
||||
safePage * PAGE_SIZE,
|
||||
);
|
||||
|
||||
const syncUrl = useCallback(
|
||||
(s: string, g: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (s) params.set("search", s);
|
||||
if (g) params.set("group", g);
|
||||
const qs = params.toString();
|
||||
router.replace(`/admin/housekeeping?tab=manage${qs ? `&${qs}` : ""}`, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const debouncedSync = useCallback(
|
||||
(s: string, g: string) => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => syncUrl(s, g), 300);
|
||||
},
|
||||
[syncUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpsert = useCallback(
|
||||
async (formData: FormData) => {
|
||||
try {
|
||||
await upsertPermission(formData);
|
||||
toast.success(t("saved"));
|
||||
if (formRef.current) formRef.current.reset();
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
},
|
||||
[router, t],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
setPendingDel(id);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("id", id);
|
||||
await deletePermission(fd);
|
||||
toast.success(t("deleted"));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
setPendingDel(null);
|
||||
setConfirmDel(null);
|
||||
},
|
||||
[router, t],
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
if (!selected.size) return;
|
||||
setBusyBulk(true);
|
||||
try {
|
||||
const ids = [...selected].map((s) => BigInt(s));
|
||||
await bulkDeletePermissions(ids);
|
||||
toast.success(t("deleted"));
|
||||
setSelected(new Set());
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
setBusyBulk(false);
|
||||
setConfirmDel(null);
|
||||
}, [selected, router, t]);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
setSelectAllMatching(false);
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (selected.size === paginated.length && !selectAllMatching) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(paginated.map((p) => String(p.id))));
|
||||
setSelectAllMatching(false);
|
||||
}
|
||||
}, [paginated, selected, selectAllMatching]);
|
||||
|
||||
const handleSelectAllMatching = useCallback(() => {
|
||||
setSelected(new Set(sorted.map((p) => String(p.id))));
|
||||
setSelectAllMatching(true);
|
||||
}, [sorted]);
|
||||
|
||||
const handleSort = useCallback(
|
||||
(key: string) => {
|
||||
setSortDir((prev) =>
|
||||
sortKey === key && prev === "asc" ? "desc" : "asc",
|
||||
);
|
||||
setSortKey(key);
|
||||
setPage(1);
|
||||
},
|
||||
[sortKey],
|
||||
);
|
||||
|
||||
const startEdit = useCallback((id: string, value: number) => {
|
||||
setEditingId(id);
|
||||
setEditValue(String(value));
|
||||
setTimeout(() => editRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setEditValue("");
|
||||
}, []);
|
||||
|
||||
const commitEdit = useCallback(
|
||||
async (p: HKPerm) => {
|
||||
const fd = new FormData();
|
||||
fd.set("permission", p.permission);
|
||||
fd.set("minRank", editValue);
|
||||
fd.set("description", p.description ?? "");
|
||||
fd.set("groupName", p.groupName ?? "");
|
||||
fd.set("dependsOn", p.dependsOn ?? "");
|
||||
await handleUpsert(fd);
|
||||
setEditingId(null);
|
||||
},
|
||||
[editValue, handleUpsert],
|
||||
);
|
||||
|
||||
const thClass =
|
||||
"cursor-pointer select-none hover:opacity-80 transition-opacity";
|
||||
|
||||
const sortIcon = (key: string) => {
|
||||
if (sortKey !== key) return " \u2195";
|
||||
return sortDir === "asc" ? " \u2191" : " \u2193";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ConfirmDialog
|
||||
open={confirmDel !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDel(null);
|
||||
}}
|
||||
title={t("confirmDelete")}
|
||||
description={
|
||||
confirmDel === "__bulk__"
|
||||
? t("confirmBulkDelete", { count: selected.size })
|
||||
: t("confirmDeletePermission")
|
||||
}
|
||||
confirmLabel={t("confirm")}
|
||||
cancelLabel={t("cancel")}
|
||||
onConfirm={async () => {
|
||||
if (confirmDel === "__bulk__") {
|
||||
await handleBulkDelete();
|
||||
} else if (confirmDel !== null) {
|
||||
await handleDelete(confirmDel);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="admin-filter-bar">
|
||||
<div className="flex gap-2 flex-1 flex-wrap">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setSearch(v);
|
||||
setPage(1);
|
||||
debouncedSync(v, groupFilter);
|
||||
}}
|
||||
placeholder={t("searchPermission")}
|
||||
className="flex-1 min-w-[200px]"
|
||||
/>
|
||||
<select
|
||||
value={groupFilter}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setGroupFilter(v);
|
||||
setPage(1);
|
||||
debouncedSync(search, v);
|
||||
}}
|
||||
className="w-[180px]"
|
||||
>
|
||||
<option value="">{t("allGroups")}</option>
|
||||
{props.groups.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g} ({props.groupCounts[g] ?? 0})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("addPermission")}</h2>
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleUpsert(new FormData(e.currentTarget));
|
||||
}}
|
||||
className="admin-card"
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="permission"
|
||||
placeholder={t("permissionKey")}
|
||||
required
|
||||
className="min-w-[200px] flex-1"
|
||||
/>
|
||||
<input
|
||||
name="minRank"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("minRank")}
|
||||
required
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<input
|
||||
name="description"
|
||||
placeholder={t("description")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<select name="groupName" className="w-[150px]">
|
||||
<option value="">{t("noGroup")}</option>
|
||||
{props.groups.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
name="dependsOn"
|
||||
placeholder={t("dependsOn")}
|
||||
className="min-w-[160px] flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="admin-section-title mb-0">
|
||||
{t("permissions")}{" "}
|
||||
<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)]">
|
||||
{filtered.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{selected.size > 0 && selected.size < sorted.length && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAllMatching}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
{t("selectAllMatching", { count: sorted.length })}
|
||||
</button>
|
||||
)}
|
||||
{selected.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDel("__bulk__")}
|
||||
disabled={busyBulk}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
{t("deleteSelected", { count: selected.size })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="admin-empty">{t("noPermissions")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[36px]">
|
||||
{selectAllMatching ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
onChange={() => {
|
||||
setSelected(new Set());
|
||||
setSelectAllMatching(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
paginated.length > 0 &&
|
||||
selected.size === paginated.length
|
||||
}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
<th className="w-[40px]">#</th>
|
||||
<th
|
||||
className={thClass}
|
||||
onClick={() => handleSort("permission")}
|
||||
>
|
||||
{t("colPermission")}
|
||||
{sortIcon("permission")}
|
||||
</th>
|
||||
<th
|
||||
className={thClass}
|
||||
onClick={() => handleSort("description")}
|
||||
>
|
||||
{t("colDescription")}
|
||||
{sortIcon("description")}
|
||||
</th>
|
||||
<th
|
||||
className={`w-[120px] ${thClass}`}
|
||||
onClick={() => handleSort("groupName")}
|
||||
>
|
||||
{t("colGroup")}
|
||||
{sortIcon("groupName")}
|
||||
</th>
|
||||
<th
|
||||
className={`w-[100px] ${thClass}`}
|
||||
onClick={() => handleSort("minRank")}
|
||||
>
|
||||
{t("colMinRank")}
|
||||
{sortIcon("minRank")}
|
||||
</th>
|
||||
<th className="w-[180px]">{t("colDepends")}</th>
|
||||
<th className="w-[80px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginated.map((p, i) => {
|
||||
const sid = String(p.id);
|
||||
return (
|
||||
<tr key={sid}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectAllMatching || selected.has(sid)}
|
||||
onChange={() => toggleSelect(sid)}
|
||||
/>
|
||||
</td>
|
||||
<td className="muted w-[40px]">
|
||||
{(safePage - 1) * PAGE_SIZE + i + 1}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{p.permission}</strong>
|
||||
</td>
|
||||
<td className="muted">{p.description || "\u2014"}</td>
|
||||
<td>
|
||||
{p.groupName ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/12 text-xs font-medium">
|
||||
{p.groupName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs opacity-50">\u2014</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingId === sid ? (
|
||||
<div className="flex gap-1 items-center">
|
||||
<input
|
||||
ref={editRef}
|
||||
type="number"
|
||||
min={0}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitEdit(p);
|
||||
} else if (e.key === "Escape") {
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => commitEdit(p)}
|
||||
>
|
||||
\u2713
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
\u2715
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm min-w-[70px] text-center"
|
||||
onClick={() => startEdit(sid, p.minRank)}
|
||||
>
|
||||
{p.minRank}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{p.dependsOn ? (
|
||||
<span
|
||||
className={
|
||||
props.permSet.has(p.dependsOn)
|
||||
? "opacity-70"
|
||||
: "opacity-100 font-semibold"
|
||||
}
|
||||
style={
|
||||
!props.permSet.has(p.dependsOn)
|
||||
? { color: "var(--color-danger)" }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{p.dependsOn}
|
||||
{!props.permSet.has(p.dependsOn)
|
||||
? ` ${t("missing")}`
|
||||
: ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="opacity-50">\u2014</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => setConfirmDel(sid)}
|
||||
disabled={pendingDel === sid}
|
||||
>
|
||||
{pendingDel === sid ? "..." : "\u2715"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={safePage <= 1}
|
||||
onClick={() => setPage(safePage - 1)}
|
||||
>
|
||||
{t("prev")}
|
||||
</button>
|
||||
<span className="text-xs opacity-60">
|
||||
{safePage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={safePage >= totalPages}
|
||||
onClick={() => setPage(safePage + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────── Presets Section ────────── */
|
||||
|
||||
export function PresetsSection() {
|
||||
const t = useTranslations("pages.admin.housekeeping");
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
const handleApplyPreset = useCallback(
|
||||
async (preset: string) => {
|
||||
setBusy(preset);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("preset", preset);
|
||||
await applyPreset(fd);
|
||||
toast.success(t("presetApplied"));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
setBusy(null);
|
||||
},
|
||||
[router, t],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(async () => {
|
||||
setBusy("__clear__");
|
||||
try {
|
||||
await clearAllPermissions();
|
||||
toast.success(t("cleared"));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
setBusy(null);
|
||||
setConfirmClear(false);
|
||||
}, [router, t]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<ConfirmDialog
|
||||
open={confirmClear}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmClear(false);
|
||||
}}
|
||||
title={t("confirmClearAll")}
|
||||
description={t("confirmClearAllDesc")}
|
||||
confirmLabel={t("confirm")}
|
||||
cancelLabel={t("cancel")}
|
||||
onConfirm={handleClearAll}
|
||||
/>
|
||||
|
||||
<h2 className="admin-section-title">{t("presets")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("presetsHint")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{(["moderator", "admin", "superadmin"] as const).map((preset) => {
|
||||
const labels: Record<string, string> = {
|
||||
moderator: t("presetModerator"),
|
||||
admin: t("presetAdmin"),
|
||||
superadmin: t("presetSuperAdmin"),
|
||||
};
|
||||
const descs: Record<string, string> = {
|
||||
moderator: t("presetModeratorDesc"),
|
||||
admin: t("presetAdminDesc"),
|
||||
superadmin: t("presetSuperAdminDesc"),
|
||||
};
|
||||
const loading = busy === preset;
|
||||
return (
|
||||
<div key={preset} className="admin-card">
|
||||
<h3 className="font-bold text-lg mb-1">{labels[preset]}</h3>
|
||||
<p className="text-xs opacity-60 mb-3">{descs[preset]}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApplyPreset(preset)}
|
||||
disabled={loading || busy === "__clear__"}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{loading ? t("loading") : t("applyPreset")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={busy !== null}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
{busy === "__clear__" ? t("loading") : t("clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────── Test Section ────────── */
|
||||
|
||||
export function TestSection({ permissions }: { permissions: HKPerm[] }) {
|
||||
const t = useTranslations("pages.admin.housekeeping");
|
||||
const [testRank, setTestRank] = useState("");
|
||||
const [testResult, setTestResult] = useState<{
|
||||
rank: number;
|
||||
permissions: string[];
|
||||
} | null>(null);
|
||||
|
||||
const handleTest = useCallback(() => {
|
||||
const rank = Number(testRank);
|
||||
if (!Number.isFinite(rank) || rank < 0)
|
||||
return toast.error(t("invalidRank"));
|
||||
const matching = permissions.filter((p) => p.minRank <= rank);
|
||||
setTestResult({ rank, permissions: matching.map((p) => p.permission) });
|
||||
toast.success(t("testDone", { count: matching.length }));
|
||||
}, [testRank, permissions, t]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("testTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("testHint")}
|
||||
</p>
|
||||
<div className="admin-card flex gap-2 flex-wrap">
|
||||
<input
|
||||
value={testRank}
|
||||
onChange={(e) => setTestRank(e.target.value)}
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("rank")}
|
||||
className="w-[150px]"
|
||||
/>
|
||||
<button type="button" onClick={handleTest} className="btn btn-primary">
|
||||
{t("test")}
|
||||
</button>
|
||||
</div>
|
||||
{testResult && (
|
||||
<div className="admin-card mt-3">
|
||||
<div className="font-semibold mb-2">
|
||||
{t("rank")} {testResult.rank}: {testResult.permissions.length}{" "}
|
||||
{t("permissions")}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto text-xs space-y-0.5">
|
||||
{testResult.permissions.map((p) => (
|
||||
<div key={p} className="font-mono">
|
||||
{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────── Import Section ────────── */
|
||||
|
||||
export function ImportSection() {
|
||||
const t = useTranslations("pages.admin.housekeeping");
|
||||
const router = useRouter();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const handleBulkImport = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const raw = String(fd.get("json") ?? "").trim();
|
||||
if (!raw) return toast.error(t("emptyInput"));
|
||||
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch {
|
||||
return toast.error(t("invalidJson"));
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await bulkImportPermissions(fd);
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(
|
||||
t("importedWithErrors", {
|
||||
count: result.count,
|
||||
errors: result.errors.length,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("imported", { count: result.count }));
|
||||
}
|
||||
if (textareaRef.current) textareaRef.current.value = "";
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("errorOccurred"));
|
||||
}
|
||||
setImporting(false);
|
||||
},
|
||||
[router, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="admin-section-title">{t("importTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("importHint")}
|
||||
</p>
|
||||
<form onSubmit={handleBulkImport} className="admin-card">
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="json"
|
||||
placeholder={t("importPlaceholder")}
|
||||
required
|
||||
rows={6}
|
||||
className="w-full font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={importing}
|
||||
className="btn btn-primary self-start"
|
||||
>
|
||||
{importing ? t("loading") : t("import")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { exportPermissions } from "@/actions/admin-housekeeping";
|
||||
|
||||
/* ────────── Export Section ────────── */
|
||||
|
||||
@@ -843,26 +42,29 @@ export function ExportSection() {
|
||||
{t("exportHint")}
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="btn btn-outline mb-3"
|
||||
variant="outline"
|
||||
className="mb-3"
|
||||
>
|
||||
{exporting ? t("loading") : t("export")}
|
||||
</button>
|
||||
</Button>
|
||||
{data && (
|
||||
<div className="relative">
|
||||
<pre className="admin-card font-mono text-xs overflow-auto max-h-[300px] p-3">
|
||||
{data}
|
||||
</pre>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="btn btn-outline btn-sm absolute top-2 right-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
{copying ? t("copied") : t("copy")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -916,13 +118,14 @@ export function AuditSection({ initialLogs }: AuditSectionProps) {
|
||||
</p>
|
||||
<div className="admin-card">
|
||||
<p className="text-sm opacity-70">{t("auditLogsAt")}</p>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push("/admin/logs/audit")}
|
||||
className="btn btn-outline mt-2"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
>
|
||||
{t("viewAuditLogs")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -31,10 +31,7 @@ export default async function AdminHousekeeping({
|
||||
|
||||
const t = await getTranslations("pages.admin.housekeeping");
|
||||
const sp = await searchParams;
|
||||
let tab = sp.tab ?? "overview";
|
||||
if (tab === "manage" || tab === "presets" || tab === "test") {
|
||||
tab = "compare";
|
||||
}
|
||||
const tab = sp.tab ?? "overview";
|
||||
|
||||
type HKPerm = Awaited<
|
||||
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
|
||||
|
||||
@@ -262,7 +262,7 @@ export function ImportFurniClient() {
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
});
|
||||
}, [toggleSelectAll]);
|
||||
|
||||
// Derived: unique categories from loaded items
|
||||
const categories = useMemo(() => {
|
||||
@@ -354,7 +354,6 @@ export function ImportFurniClient() {
|
||||
|
||||
// Auto-search with debounce (500ms)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
@@ -367,7 +366,7 @@ export function ImportFurniClient() {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchTerm]);
|
||||
}, [searchTerm, fetchItems, activeSearch, statusFilter]);
|
||||
|
||||
// Re-fetch when status filter changes to/from missing-nitro
|
||||
const prevStatusRef = useRef(statusFilter);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteWhitelist,
|
||||
} from "@/actions/admin-ip";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -84,9 +85,9 @@ export default async function AdminIp() {
|
||||
placeholder={t("asnPlaceholder")}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
<Button type="submit" variant="secondary">
|
||||
{t("add")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -116,9 +117,9 @@ export default async function AdminIp() {
|
||||
<td>
|
||||
<form action={deleteWhitelist}>
|
||||
<input type="hidden" name="id" value={String(w.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("remove")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -146,9 +147,9 @@ export default async function AdminIp() {
|
||||
placeholder={t("asnPlaceholder")}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("add")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -178,9 +179,9 @@ export default async function AdminIp() {
|
||||
<td>
|
||||
<form action={deleteBlacklist}>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("remove")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface ChatLogRow {
|
||||
@@ -34,7 +34,7 @@ export function ChatTable({ data }: ChatTableProps) {
|
||||
key: "timestamp",
|
||||
label: t("colTime"),
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||
render: (value) => <span>{formatDate(new Date(Number(value) * 1000))}</span>,
|
||||
},
|
||||
{ key: "roomId", label: t("chat.colRoomId"), sortable: true },
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface CommandLogRow {
|
||||
@@ -37,7 +37,7 @@ export function CommandsTable({ data }: CommandsTableProps) {
|
||||
key: "timestamp",
|
||||
label: t("colTime"),
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||
render: (value) => <span>{formatDate(new Date(Number(value) * 1000))}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -4,15 +4,12 @@ import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { StaffActivitiesTable } from "./staff-activities-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
|
||||
when: "createdAt",
|
||||
staff: "userId",
|
||||
@@ -85,7 +82,7 @@ export default async function AdminStaffActivities({
|
||||
|
||||
const rows = activities.map((a) => ({
|
||||
id: Number(a.id),
|
||||
when: fromDate(a.createdAt),
|
||||
when: formatDate(a.createdAt),
|
||||
staff: t("staffWithId", {
|
||||
id: Number(a.userId),
|
||||
name: nameById.get(Number(a.userId)) ?? t("unknownUser"),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatTimestamp } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface TradeLogRow {
|
||||
@@ -46,7 +46,7 @@ export function TradesTable({ data }: TradesTableProps) {
|
||||
key: "timestamp",
|
||||
label: t("colTime"),
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||
render: (value) => <span>{formatDate(new Date(Number(value) * 1000))}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { saveMaintenance } from "@/actions/admin-maintenance";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -137,9 +138,9 @@ export default async function AdminMaintenance() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("saveChanges")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -4,17 +4,14 @@ import { getTranslations } from "next-intl/server";
|
||||
import { cancelMarketplaceListing } from "@/actions/admin-marketplace";
|
||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function AdminMarketplacePage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -80,18 +77,24 @@ export default async function AdminMarketplacePage({
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/admin/marketplace?filter=active"
|
||||
className={`btn btn-sm ${filter === "active" ? "btn-primary" : "btn-ghost"}`}
|
||||
<Button
|
||||
variant={filter === "active" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
{t("filterActive")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/marketplace?filter=sold"
|
||||
className={`btn btn-sm ${filter === "sold" ? "btn-primary" : "btn-ghost"}`}
|
||||
<Link href="/admin/marketplace?filter=active">
|
||||
{t("filterActive")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "sold" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
{t("filterSold")}
|
||||
</Link>
|
||||
<Link href="/admin/marketplace?filter=sold">
|
||||
{t("filterSold")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
@@ -135,20 +138,21 @@ export default async function AdminMarketplacePage({
|
||||
</Link>
|
||||
</td>
|
||||
<td>{row.price}</td>
|
||||
<td className="muted">{fmt(row.timestamp)}</td>
|
||||
<td className="muted">{formatDate(new Date(row.timestamp * 1000))}</td>
|
||||
{filter === "sold" ? (
|
||||
<td className="muted">{fmt(row.soldTimestamp)}</td>
|
||||
<td className="muted">{formatDate(row.soldTimestamp ? new Date(row.soldTimestamp * 1000) : null)}</td>
|
||||
) : null}
|
||||
{canEdit && filter === "active" ? (
|
||||
<td>
|
||||
<form action={cancelMarketplaceListing}>
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-ghost btn-sm"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CfhTable, type CfhRow } from "./cfh-table";
|
||||
|
||||
@@ -127,7 +128,7 @@ export default async function CfhListPage({
|
||||
issue: ticket.issue,
|
||||
roomId: ticket.roomId,
|
||||
when: ticket.timestamp
|
||||
? new Date(ticket.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")
|
||||
? formatDate(new Date(ticket.timestamp * 1000))
|
||||
: "—",
|
||||
sender: userMap.get(ticket.senderId) ?? "—",
|
||||
senderId: ticket.senderId,
|
||||
|
||||
@@ -10,14 +10,11 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { activeBanWhere } from "@/lib/bans";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
type RankCount = { rank: number; count: number };
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
@@ -205,7 +202,7 @@ export default async function AdminDashboard() {
|
||||
{a.action}
|
||||
</span>
|
||||
<div className="text-[0.6rem] text-[var(--admin-text-muted)] mt-0.5">
|
||||
{fromDate(a.createdAt)}
|
||||
{formatDate(a.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deletePhoto } from "@/actions/admin-photos";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -95,9 +96,9 @@ export default async function AdminPhotos() {
|
||||
</p>
|
||||
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
|
||||
<input type="hidden" name="id" value={String(p.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { fetchAdminList } from "@/lib/admin-list";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { PollsTable } from "./polls-table";
|
||||
@@ -45,9 +46,11 @@ export default async function PollsPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Link href="/admin/polls/create" className="btn btn-primary">
|
||||
{t("newPoll")}
|
||||
</Link>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/polls/create">
|
||||
{t("newPoll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PollsTable
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
toggleApiKey,
|
||||
} from "@/actions/admin-radio-api-keys";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -154,9 +155,9 @@ export default async function AdminRadioApiKeysPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("apiKeysForm.generate")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -220,17 +221,17 @@ export default async function AdminRadioApiKeysPage() {
|
||||
<div style={{ display: "inline-flex", gap: "0.4rem" }}>
|
||||
<form action={toggleApiKey}>
|
||||
<input type="hidden" name="id" value={String(k.id)} />
|
||||
<button type="submit" className="btn">
|
||||
<Button type="submit" variant="outline">
|
||||
{k.isActive
|
||||
? t("apiKeysForm.deactivate")
|
||||
: t("apiKeysForm.activate")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<form action={deleteApiKey}>
|
||||
<input type="hidden" name="id" value={String(k.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("apiKeysForm.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
toggleTrack,
|
||||
} from "@/actions/admin-radio-autodj";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -204,9 +205,9 @@ export default async function AdminRadioAutoDjPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("autoDjForm.addTrack")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -297,11 +298,11 @@ export default async function AdminRadioAutoDjPage() {
|
||||
name="isActive"
|
||||
value={track.isActive ? "0" : "1"}
|
||||
/>
|
||||
<button type="submit" className="btn">
|
||||
<Button type="submit" variant="outline">
|
||||
{track.isActive
|
||||
? t("autoDjForm.deactivate")
|
||||
: t("autoDjForm.activate")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<form action={deleteTrack}>
|
||||
<input
|
||||
@@ -309,9 +310,9 @@ export default async function AdminRadioAutoDjPage() {
|
||||
name="id"
|
||||
value={String(track.id)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("autoDjForm.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
updateRadioBanner,
|
||||
} from "@/actions/admin-radio-extra";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -157,9 +158,9 @@ export default async function AdminRadioBannersPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("banners.createBanner")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -279,9 +280,9 @@ export default async function AdminRadioBannersPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.6rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("banners.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -290,9 +291,9 @@ export default async function AdminRadioBannersPage() {
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
>
|
||||
<input type="hidden" name="id" value={String(b.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("banners.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Read-only copy box for an embed snippet. Renders the code inside a
|
||||
@@ -42,9 +43,9 @@ export function EmbedSnippet({
|
||||
<h2 className="m-0 text-base font-bold text-[var(--admin-text)]">
|
||||
{title}
|
||||
</h2>
|
||||
<button type="button" className="btn btn-primary" onClick={copy}>
|
||||
<Button type="button" variant="default" onClick={copy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mb-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { EmbedSnippet } from "./embed-snippet";
|
||||
@@ -73,9 +74,11 @@ export default async function AdminRadioEmbedPage() {
|
||||
{t("embedPage.noStreamUrlText")}
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/admin/radio/settings" className="btn btn-primary">
|
||||
{t("embedPage.goToSettings")}
|
||||
</Link>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/radio/settings">
|
||||
{t("embedPage.goToSettings")}
|
||||
</Link>
|
||||
</Button>
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { deleteShout } from "@/actions/admin-radio-moderation";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -75,9 +76,9 @@ export default async function AdminRadioModerationPage() {
|
||||
<td>
|
||||
<form action={deleteShout}>
|
||||
<input type="hidden" name="id" value={String(s.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteShout")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { deleteShout } from "@/actions/admin-radio-moderation";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -157,9 +158,9 @@ export default async function AdminRadioPage() {
|
||||
<td>
|
||||
<form action={deleteShout}>
|
||||
<input type="hidden" name="id" value={String(s.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteShout")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { savePoints } from "@/actions/admin-radio-points";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -226,9 +227,9 @@ export default async function AdminRadioPointsPage({
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("pointsForm.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
updateRadioRank,
|
||||
} from "@/actions/admin-radio-extra";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -128,9 +129,9 @@ export default async function AdminRadioRanksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("ranksForm.createRank")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -195,16 +196,16 @@ export default async function AdminRadioRanksPage() {
|
||||
/>{" "}
|
||||
{t("ranksForm.active")}
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("ranksForm.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form action={deleteRadioRank} style={{ marginTop: "0.5rem" }}>
|
||||
<input type="hidden" name="id" value={String(r.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("ranksForm.deleteRank", { id: String(r.id) })}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
saveRadioSettings,
|
||||
} from "@/actions/admin-radio-extra";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -436,9 +437,9 @@ export default async function AdminRadioSettingsPage() {
|
||||
<form action={saveRadioSettings}>
|
||||
<input type="hidden" name="__keys" value={allKeys} />
|
||||
<div className="flex justify-end mb-4">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("settingsForm.saveAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.map((group) => (
|
||||
@@ -482,9 +483,9 @@ export default async function AdminRadioSettingsPage() {
|
||||
))}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("settingsForm.saveAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -505,9 +506,9 @@ export default async function AdminRadioSettingsPage() {
|
||||
name="comment"
|
||||
placeholder={t("settingsForm.commentPlaceholder")}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("settingsForm.saveSingle")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deleteValue,
|
||||
} from "@/actions/admin-rare-values";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -82,9 +83,9 @@ export default async function AdminRareValues() {
|
||||
placeholder={t("priorityPlaceholder")}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("addCategoryButton")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -120,9 +121,9 @@ export default async function AdminRareValues() {
|
||||
</div>
|
||||
<form action={deleteCategory}>
|
||||
<input type="hidden" name="id" value={catId} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteCategory")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -158,9 +159,9 @@ export default async function AdminRareValues() {
|
||||
<td>
|
||||
<form action={deleteValue}>
|
||||
<input type="hidden" name="id" value={String(v.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
✕
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -215,9 +216,9 @@ export default async function AdminRareValues() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("itemForm.addValue")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deleteShopArticle, updateShopArticle } from "@/actions/admin-shop";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -141,14 +142,14 @@ export default async function EditShopArticle({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-2">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</Button>
|
||||
<form action={deleteShopArticle}>
|
||||
<input type="hidden" name="id" value={String(pkg.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("form.deletePackage")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createShopArticle } from "@/actions/admin-shop";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function NewShopArticle() {
|
||||
@@ -111,9 +112,9 @@ export default async function NewShopArticle() {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 text-right">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -116,9 +118,11 @@ export default async function AdminShop({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<Link href="/admin/shop/new" className="btn btn-primary">
|
||||
{t("newPackage")}
|
||||
</Link>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/shop/new">
|
||||
{t("newPackage")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
@@ -235,9 +239,7 @@ export default async function AdminShop({
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">
|
||||
{o.createdAt
|
||||
? o.createdAt.toISOString().slice(0, 10)
|
||||
: "—"}
|
||||
{formatDate(o.createdAt, "date")}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type SubRow = {
|
||||
@@ -13,10 +14,6 @@ type SubRow = {
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
function fmt(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function AdminSubscriptions({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -149,7 +146,7 @@ export default async function AdminSubscriptions({
|
||||
<td>
|
||||
{s.expiresAt <= soonCutoff ? t("expiringSoon") : "OK"}
|
||||
</td>
|
||||
<td>{fmt(s.expiresAt)}</td>
|
||||
<td>{formatDate(new Date(s.expiresAt * 1000))}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { createTag, deleteTag, updateTag } from "@/actions/admin-tags";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -110,9 +111,9 @@ export default async function AdminTagsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("createTag")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -191,15 +192,15 @@ export default async function AdminTagsPage() {
|
||||
maxLength={10}
|
||||
className="w-[90px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("updateTag")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<form action={deleteTag} style={{ marginTop: "0.4rem" }}>
|
||||
<input type="hidden" name="id" value={String(tag.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("deleteTag")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createTeam, deleteTeam } from "@/actions/admin-teams";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -102,9 +103,9 @@ export default async function AdminTeams() {
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("createTeam")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -166,9 +167,9 @@ export default async function AdminTeams() {
|
||||
name="id"
|
||||
value={String(team.id)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
saveTheme,
|
||||
} from "@/actions/admin-theme";
|
||||
import { RenameTheme } from "@/components/admin/theme/rename-theme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { listCustomThemes } from "@/lib/theme-custom-store";
|
||||
@@ -409,9 +410,9 @@ export default async function AdminTheme({
|
||||
{Object.entries(PRESETS).map(([name, palette]) => (
|
||||
<form key={name} action={applyPreset}>
|
||||
<input type="hidden" name="preset" value={name} />
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-outline inline-flex items-center gap-2"
|
||||
variant="outline"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{[
|
||||
@@ -435,7 +436,7 @@ export default async function AdminTheme({
|
||||
))}
|
||||
</span>
|
||||
{name}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
@@ -458,9 +459,9 @@ export default async function AdminTheme({
|
||||
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm w-64"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
Save current theme
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{customThemes.length === 0 ? (
|
||||
@@ -503,15 +504,15 @@ export default async function AdminTheme({
|
||||
<div className="flex gap-2">
|
||||
<form action={applyCustomTheme}>
|
||||
<input type="hidden" name="id" value={theme.id} />
|
||||
<button type="submit" className="btn btn-sm btn-outline">
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Load
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
<form action={deleteCustomTheme}>
|
||||
<input type="hidden" name="id" value={theme.id} />
|
||||
<button type="submit" className="btn btn-sm btn-danger">
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
Delete
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -652,9 +653,9 @@ export default async function AdminTheme({
|
||||
className="w-full font-mono text-sm"
|
||||
/>
|
||||
<div className="mt-4 text-right">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("saveCss")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
@@ -4,15 +4,12 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { TicketsTable, type TicketRow } from "./tickets-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "—";
|
||||
}
|
||||
|
||||
export default async function AdminTicketsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -112,7 +109,7 @@ export default async function AdminTicketsPage({
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assignee?.username ?? "—",
|
||||
date: fromDate(ticket.createdAt),
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
TransactionsTable,
|
||||
@@ -32,10 +33,6 @@ function statusKind(
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string): string {
|
||||
const n = Number(amount);
|
||||
const value = Number.isFinite(n) ? n.toFixed(2) : "0.00";
|
||||
@@ -160,7 +157,7 @@ export default async function AdminTransactions({
|
||||
status: x.status ?? "",
|
||||
statusKind: statusKind(x.status),
|
||||
transactionId: x.transactionId,
|
||||
date: fromDate(x.createdAt),
|
||||
date: formatDate(x.createdAt),
|
||||
}));
|
||||
|
||||
const pagePaid = rows.filter((r) => r.statusKind === "ok").length;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import Link from "next/link";
|
||||
import type { SanctionEvent } from "../_lib/load-user-sanctions";
|
||||
|
||||
function fmt(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
const KIND_CLASS: Record<SanctionEvent["kind"], string> = {
|
||||
ban: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
|
||||
lift: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]",
|
||||
@@ -32,7 +28,7 @@ export function UserSanctionTimeline({
|
||||
<h3 className="text-sm font-semibold m-0">Sanctions timeline</h3>
|
||||
{muted ? (
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/20 text-[var(--admin-accent)]">
|
||||
Muted until {fmt(muteUntil)}
|
||||
Muted until {formatDate(new Date(muteUntil * 1000))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -66,7 +62,7 @@ export function UserSanctionTimeline({
|
||||
<span className="text-sm font-medium">{ev.label}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmt(ev.at)}
|
||||
{formatDate(new Date(ev.at * 1000))}
|
||||
</span>
|
||||
{ev.actor ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { isBanActive, unixNow } from "@/lib/bans";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -121,7 +122,7 @@ export async function loadUserSanctions(
|
||||
at: now,
|
||||
kind: "status",
|
||||
label: "Mute active",
|
||||
detail: `Until ${new Date(muteUntil * 1000).toISOString().slice(0, 16).replace("T", " ")}`,
|
||||
detail: `Until ${formatDate(new Date(muteUntil * 1000))}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export type VoucherRow = {
|
||||
@@ -19,11 +21,6 @@ export type VoucherRow = {
|
||||
status: "active" | "expired" | "usedUp";
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return iso.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export function VouchersClient({
|
||||
vouchers,
|
||||
canEdit,
|
||||
@@ -155,13 +152,13 @@ export function VouchersClient({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? t("form.saving") : t("form.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -216,21 +213,21 @@ export function VouchersClient({
|
||||
)}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fmtDate(v.expiresAt)}
|
||||
{formatDate(v.expiresAt ? new Date(v.expiresAt) : null)}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fmtDate(v.createdAt)}
|
||||
{formatDate(v.createdAt ? new Date(v.createdAt) : null)}
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => setPendingDelete(v)}
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { saveVpn } from "@/actions/admin-vpn";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -164,9 +165,9 @@ export default async function AdminVpn({
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -178,9 +179,11 @@ export default async function AdminVpn({
|
||||
ipLink: (chunks) => <Link href="/admin/ip">{chunks}</Link>,
|
||||
})}
|
||||
</p>
|
||||
<Link href="/admin/ip" className="btn btn-secondary">
|
||||
{t("openIpManagement")}
|
||||
</Link>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="/admin/ip">
|
||||
{t("openIpManagement")}
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { addWord, deleteWord } from "@/actions/admin-wordfilter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
@@ -64,13 +65,13 @@ export function WordFilterClient({
|
||||
disabled={isPending}
|
||||
className="flex-1 min-w-[200px]"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !word.trim()}
|
||||
className="btn btn-primary"
|
||||
variant="default"
|
||||
>
|
||||
{isPending ? t("form.saving") : t("form.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -106,14 +107,14 @@ export function WordFilterClient({
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => setPendingDelete(w)}
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateBox,
|
||||
} from "@/actions/admin-writeable-boxes";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -131,9 +132,9 @@ export default async function AdminWriteableBoxes() {
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -262,9 +263,9 @@ export default async function AdminWriteableBoxes() {
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Button type="submit" variant="default">
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -284,15 +285,15 @@ export default async function AdminWriteableBoxes() {
|
||||
name="next"
|
||||
value={box.isActive ? "0" : "1"}
|
||||
/>
|
||||
<button type="submit" className="btn">
|
||||
{box.isActive ? t("hide") : t("activate")}
|
||||
</button>
|
||||
<Button type="submit" variant="outline">
|
||||
{box.isActive ? t("hide") : t("activate")}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={deleteBox}>
|
||||
<input type="hidden" name="id" value={String(box.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
<Button type="submit" variant="destructive">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,36 +3,22 @@
|
||||
// writes), falling back to the legacy radio_autoplay alias if present. Booleans
|
||||
// are stored as the strings '1' / '0'. Returns { autoplay: boolean }.
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const AUTOPLAY_KEYS = ["radio_auto_play", "radio_autoplay"];
|
||||
|
||||
function truthy(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
function truthy(value: string | null | undefined): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
const s = value.trim().toLowerCase();
|
||||
return s === "1" || s === "true";
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: AUTOPLAY_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
|
||||
// Prefer the canonical key the settings page uses; fall back to the alias.
|
||||
const autoplay = truthy(map.radio_auto_play ?? map.radio_autoplay);
|
||||
|
||||
const values = await siteSettings.getMany(["radio_auto_play", "radio_autoplay"]);
|
||||
const autoplay = truthy(values.radio_auto_play ?? values.radio_autoplay);
|
||||
return apiJson({ autoplay });
|
||||
} catch {
|
||||
// DB unavailable — default to no autoplay rather than a 500.
|
||||
return apiJson({ autoplay: false }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redisCache, apiCacheKey } from "@/lib/redis-cache";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Radio player config: the subset of radio_* website_settings the front-end
|
||||
// player needs (stream URL, name, autoplay, enabled, widget visibility) as a
|
||||
// flat { key: value } map. No secrets live among these keys.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// radio_* keys relevant to the public player widget. Mirrors what the AtomCMS
|
||||
// radio-player blade requests from /api/radio/config.
|
||||
const CONFIG_KEYS = new Set([
|
||||
const CONFIG_KEYS = [
|
||||
"radio_enabled",
|
||||
"radio_name",
|
||||
"radio_stream_url",
|
||||
@@ -24,25 +21,15 @@ const CONFIG_KEYS = new Set([
|
||||
"radio_widget_enabled",
|
||||
"radio_widget_show_globally",
|
||||
"radio_widget_position",
|
||||
]);
|
||||
];
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const config = await redisCache(
|
||||
apiCacheKey("radio:config"),
|
||||
30,
|
||||
async () => {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: Array.from(CONFIG_KEYS) } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
const result: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.key] = row.value;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const values = await siteSettings.getMany(CONFIG_KEYS);
|
||||
const config: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(values)) {
|
||||
if (val !== null) config[key] = val;
|
||||
}
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
return apiJson({}, { status: 200 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Minimal radio_* settings an external page needs to embed the player: stream
|
||||
// URL, display name, whether the radio is enabled, and autoplay. Returned as a
|
||||
@@ -15,19 +15,13 @@ const EMBED_KEYS = [
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: EMBED_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const values = await siteSettings.getMany(EMBED_KEYS);
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
for (const [key, val] of Object.entries(values)) {
|
||||
if (val !== null) config[key] = val;
|
||||
}
|
||||
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Public website settings as a flat { key: value } map. Secrets are stripped so
|
||||
// this can be served to the game client / external integrations.
|
||||
@@ -16,19 +16,13 @@ function isSensitive(key: string): boolean {
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const map = await siteSettings.getAll();
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
if (isSensitive(row.key)) continue;
|
||||
settings[row.key] = row.value;
|
||||
for (const [key, value] of map) {
|
||||
if (!isSensitive(key)) settings[key] = value;
|
||||
}
|
||||
|
||||
return apiJson(settings);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty settings map rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,11 @@ import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromUnix(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function ModBansPage() {
|
||||
await requireModPermission([PERMS.MOD_BANS_VIEW, PERMS.BANS_VIEW]);
|
||||
|
||||
@@ -85,7 +82,7 @@ export default async function ModBansPage() {
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
fromUnix(b.banExpire)
|
||||
formatDate(new Date(b.banExpire * 1000))
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,14 +8,11 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "—";
|
||||
}
|
||||
|
||||
export default async function ModTicketsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -114,7 +111,7 @@ export default async function ModTicketsPage({
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assignee?.username ?? "—",
|
||||
date: fromDate(ticket.createdAt),
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -144,7 +144,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [canEdit, isDirty, saving, page, handleSave]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (id: number) => {
|
||||
|
||||
@@ -177,7 +177,6 @@ export function SortableTree({
|
||||
const loadFullTree = loadTabTree;
|
||||
|
||||
// Load tab children when active root changes (same as Catalog Structure expand)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (activeTabId === null) return;
|
||||
setSearchQuery("");
|
||||
@@ -185,7 +184,7 @@ export function SortableTree({
|
||||
setFilter("all");
|
||||
dispatch({ type: "COLLAPSE_ALL" });
|
||||
void loadTabTree();
|
||||
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId, loadTabTree]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
@@ -314,7 +313,6 @@ export function SortableTree({
|
||||
});
|
||||
|
||||
// Auto-scroll to selected page
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (!selectedPageId) return;
|
||||
const idx = flatIds.indexOf(selectedPageId);
|
||||
@@ -324,9 +322,10 @@ export function SortableTree({
|
||||
virtualizer.scrollToIndex(idx, { align: "center" }),
|
||||
);
|
||||
}
|
||||
}, [selectedPageId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [selectedPageId, flatIds, virtualizer]);
|
||||
|
||||
// ── Keyboard navigation ─────────────────────────────────────
|
||||
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -388,7 +387,7 @@ export function SortableTree({
|
||||
dispatch({ type: "TOGGLE_MULTI_SELECT", id: focusedId });
|
||||
} else if (e.key === "Delete" && focusedId) {
|
||||
e.preventDefault();
|
||||
handleDelete(focusedId);
|
||||
handleDeleteRef.current(focusedId);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
@@ -403,7 +402,7 @@ export function SortableTree({
|
||||
dispatch,
|
||||
onSelectPage,
|
||||
handleToggleExpand,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// ── Selection with Ctrl/Shift ───────────────────────────────
|
||||
const handleSelect = useCallback(
|
||||
@@ -587,6 +586,7 @@ export function SortableTree({
|
||||
toast.error("Failed to delete page");
|
||||
}
|
||||
};
|
||||
handleDeleteRef.current = handleDelete;
|
||||
|
||||
const handleDuplicate = async (id: number) => {
|
||||
if (!canEdit) return;
|
||||
|
||||
@@ -229,7 +229,7 @@ export function CatalogTree({
|
||||
return () => {
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
}, [searchQuery, catalogType]);
|
||||
|
||||
// Quick actions from context menu
|
||||
const catParam = catalogType === "bc" ? "&catalog=bc" : "";
|
||||
@@ -540,33 +540,6 @@ function TreeItem({
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const hasChildren = node.childCount > 0;
|
||||
|
||||
// Auto-expand root level on mount
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Expand all signal
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (expandSignal > 0 && hasChildren) {
|
||||
if (!loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
} else {
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
}, [expandSignal]);
|
||||
|
||||
// Collapse all signal
|
||||
useEffect(() => {
|
||||
if (collapseSignal > 0) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [collapseSignal]);
|
||||
|
||||
const loadChildren = useCallback(async () => {
|
||||
if (loaded || !hasChildren) return;
|
||||
setLoading(true);
|
||||
@@ -587,6 +560,31 @@ function TreeItem({
|
||||
}
|
||||
}, [node.id, loaded, hasChildren, catalogType]);
|
||||
|
||||
// Auto-expand root level on mount
|
||||
useEffect(() => {
|
||||
if (defaultExpanded && level === 0 && hasChildren && !loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
}
|
||||
}, [defaultExpanded, level, hasChildren, loaded, loadChildren]);
|
||||
|
||||
// Expand all signal
|
||||
useEffect(() => {
|
||||
if (expandSignal > 0 && hasChildren) {
|
||||
if (!loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
} else {
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
}, [expandSignal, hasChildren, loaded, loadChildren]);
|
||||
|
||||
// Collapse all signal
|
||||
useEffect(() => {
|
||||
if (collapseSignal > 0) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [collapseSignal]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!expanded && !loaded) {
|
||||
loadChildren().then(() => setExpanded(true));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { uploadMedia, deleteMedia } from "@/actions/admin-media";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type MediaFile = {
|
||||
name: string;
|
||||
@@ -24,18 +25,6 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
try {
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extOf(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
return i >= 0 ? name.slice(i + 1).toUpperCase() : "";
|
||||
@@ -257,7 +246,7 @@ export function AdminMediaGrid() {
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[11px] m-0 text-[var(--admin-text-muted)]">
|
||||
{formatSize(f.size)} · {formatDate(f.uploaded)}
|
||||
{formatSize(f.size)} · {formatDate(new Date(f.uploaded), "date")}
|
||||
</p>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<button
|
||||
|
||||
@@ -120,51 +120,4 @@ export function RankBadge({ position }: { position: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Skeleton loaders ────────────────────────────────────────── */
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
borderRadius: "6px",
|
||||
animation: "shimmer 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCard({ lines = 3 }: { lines?: number }) {
|
||||
return (
|
||||
<div className="content-card" style={{ padding: "1rem" }}>
|
||||
<div className="content-card-head" style={{ margin: "-1rem -1rem 1rem" }}>
|
||||
<Skeleton
|
||||
style={{ width: "44px", height: "44px", borderRadius: "999px" }}
|
||||
/>
|
||||
<div style={{ flex: 1, display: "grid", gap: "0.35rem" }}>
|
||||
<Skeleton style={{ height: "16px", width: "60%" }} />
|
||||
<Skeleton style={{ height: "12px", width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: lines }, (_, i) => i).map((n) => (
|
||||
<Skeleton
|
||||
key={`skeleton-line-${n}`}
|
||||
style={{
|
||||
height: "12px",
|
||||
width: `${85 - n * 10}%`,
|
||||
marginBottom: "0.6rem",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
@@ -18,6 +18,11 @@ const schema = z.object({
|
||||
.default(10_000),
|
||||
HOTEL_NAME: z.string().default(FALLBACK_HOTEL_NAME),
|
||||
APP_URL: z.string().url().default("http://localhost:3000"),
|
||||
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
|
||||
// Public imager URL — overrides the default /imaging relative path.
|
||||
NEXT_PUBLIC_IMAGER_URL: z.string().optional(),
|
||||
// Upstream avatar imager proxy (defaults to Habbo's public imager).
|
||||
IMAGING_UPSTREAM_URL: z.string().optional(),
|
||||
// Resend API key (preferred — simpler HTTP API, always works).
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
// SMTP (password reset / notifications). Email features no-op if unset.
|
||||
@@ -47,6 +52,12 @@ const schema = z.object({
|
||||
.transform((v) => v === "true" || v === "1"),
|
||||
// Hashing driver for NEW passwords: bcrypt (default, fits varchar(64)) | argon2id.
|
||||
PASSWORD_HASH: z.enum(["bcrypt", "argon2id"]).optional(),
|
||||
// Argon2id parameters (mirrors config/hashing.php).
|
||||
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
|
||||
ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4),
|
||||
ARGON2_MEMORY_SIZE: z.coerce.number().int().positive().default(65536),
|
||||
// Bcrypt cost factor (rounds).
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().positive().default(12),
|
||||
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
||||
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
||||
// when unset.
|
||||
@@ -66,6 +77,8 @@ const schema = z.object({
|
||||
PAYPAL_CLIENT_ID: z.string().optional(),
|
||||
PAYPAL_SECRET: z.string().optional(),
|
||||
PAYPAL_API: z.string().url().optional(),
|
||||
PAYPAL_CURRENCY: z.string().default("USD"),
|
||||
PAYPAL_CREDITS_PER_USD: z.coerce.number().positive().default(100),
|
||||
// Redis — strongly recommended in production (required for multi-instance).
|
||||
// Without it, rate limits / shared caches are in-process only.
|
||||
REDIS_URL: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { error: mockError },
|
||||
}));
|
||||
|
||||
import { wrapAction, actionError } from "./action-helper";
|
||||
|
||||
beforeEach(() => {
|
||||
mockError.mockReset();
|
||||
});
|
||||
|
||||
describe("wrapAction", () => {
|
||||
it("returns success with data when fn resolves", async () => {
|
||||
const result = await wrapAction(async () => "hello", "testAction");
|
||||
expect(result).toEqual({ success: true, data: "hello" });
|
||||
});
|
||||
|
||||
it("returns failure and logs when fn throws", async () => {
|
||||
const result = await wrapAction(async () => {
|
||||
throw new Error("boom");
|
||||
}, "testAction");
|
||||
expect(result).toEqual({ success: false, error: "boom" });
|
||||
expect(mockError).toHaveBeenCalledWith("Action failed: testAction", {
|
||||
action: "testAction",
|
||||
error: "boom",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("actionError", () => {
|
||||
it("returns failure result and logs", () => {
|
||||
const result = actionError("myAction", "something went wrong");
|
||||
expect(result).toEqual({ success: false, error: "something went wrong" });
|
||||
expect(mockError).toHaveBeenCalledWith("Action failed: myAction", {
|
||||
action: "myAction",
|
||||
error: "something went wrong",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export interface ActionResult<T = void> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function wrapAction<T>(
|
||||
fn: () => Promise<T>,
|
||||
actionName: string,
|
||||
): Promise<ActionResult<T>> {
|
||||
try {
|
||||
const data = await fn();
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
logger.error(`Action failed: ${actionName}`, {
|
||||
action: actionName,
|
||||
error: message,
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export function actionError(actionName: string, message: string): ActionResult<never> {
|
||||
logger.error(`Action failed: ${actionName}`, { action: actionName, error: message });
|
||||
return { success: false, error: message };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ListParams } from "@/types";
|
||||
|
||||
export const PER_PAGE_OPTIONS = [10, 20, 50] as const;
|
||||
export const DEFAULT_PER_PAGE = 20;
|
||||
const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
export function parseListParams(searchParams: URLSearchParams): ListParams {
|
||||
const search = searchParams.get("search") || "";
|
||||
@@ -32,7 +32,3 @@ export function calcPagination(total: number, page: number, perPage: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts: number): string {
|
||||
if (!ts) return "N/A";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { calcPagination, parseListParams } from "./admin-helpers";
|
||||
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic Prisma model access requires it
|
||||
type PrismaModel = any;
|
||||
|
||||
interface AdminListConfig<TRow> {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export { isStaff };
|
||||
|
||||
export interface StaffUser {
|
||||
id: number;
|
||||
rank: number;
|
||||
|
||||
+2
-2
@@ -49,9 +49,9 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
export const { handlers, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
secret: env.AUTH_SECRET,
|
||||
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
|
||||
pages: { signIn: "/login" },
|
||||
providers: [
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { hash as bcryptHash } from "bcrypt";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
PASSWORD_HASH: undefined,
|
||||
ARGON2_PARALLELISM: 1,
|
||||
ARGON2_ITERATIONS: 4,
|
||||
ARGON2_MEMORY_SIZE: 65536,
|
||||
BCRYPT_ROUNDS: 12,
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
env: mockEnv,
|
||||
}));
|
||||
|
||||
import {
|
||||
checkLogin,
|
||||
hashPassword,
|
||||
@@ -17,51 +30,32 @@ describe("md5Hex", () => {
|
||||
|
||||
describe("hashPassword (default driver: bcrypt)", () => {
|
||||
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
delete process.env.PASSWORD_HASH; // exercise the default
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$2y\$/);
|
||||
expect(h.length).toBeLessThanOrEqual(60); // fits varchar(64)
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
mockEnv.PASSWORD_HASH = undefined;
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$2y\$/);
|
||||
expect(h.length).toBeLessThanOrEqual(60);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword (PASSWORD_HASH=argon2id)", () => {
|
||||
it("hashes with the AtomCMS params and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
const prevMem = process.env.ARGON2_MEMORY_SIZE;
|
||||
const prevIters = process.env.ARGON2_ITERATIONS;
|
||||
process.env.PASSWORD_HASH = "argon2id";
|
||||
process.env.ARGON2_MEMORY_SIZE = "1024";
|
||||
process.env.ARGON2_ITERATIONS = "1";
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
if (prevMem === undefined) delete process.env.ARGON2_MEMORY_SIZE;
|
||||
else process.env.ARGON2_MEMORY_SIZE = prevMem;
|
||||
if (prevIters === undefined) delete process.env.ARGON2_ITERATIONS;
|
||||
else process.env.ARGON2_ITERATIONS = prevIters;
|
||||
}
|
||||
mockEnv.PASSWORD_HASH = "argon2id";
|
||||
mockEnv.ARGON2_MEMORY_SIZE = 1024;
|
||||
mockEnv.ARGON2_ITERATIONS = 1;
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bcrypt", () => {
|
||||
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
||||
const rounds = Number(process.env.BCRYPT_ROUNDS) || 4;
|
||||
const h = await bcryptHash("hunter2", rounds); // native bcrypt emits $2a$/$2b$
|
||||
mockEnv.BCRYPT_ROUNDS = 4;
|
||||
const h = await bcryptHash("hunter2", 4);
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
// PHP stores $2y$ — bcrypt must accept it as equivalent.
|
||||
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
@@ -78,16 +72,13 @@ describe("isMd5Of", () => {
|
||||
|
||||
describe("checkLogin", () => {
|
||||
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
|
||||
mockEnv.PASSWORD_HASH = undefined;
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
// Default driver is bcrypt — the upgraded hash must fit varchar(64).
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
||||
// The upgraded hash verifies the same password.
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||
@@ -100,6 +91,7 @@ describe("checkLogin", () => {
|
||||
});
|
||||
|
||||
it("validates an existing modern hash with no upgrade", async () => {
|
||||
mockEnv.PASSWORD_HASH = undefined;
|
||||
const stored = await hashPassword("modern");
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
|
||||
+14
-11
@@ -2,17 +2,22 @@ import { randomBytes } from "node:crypto";
|
||||
import { compare as bcryptCompare, hash as bcryptHash } from "bcrypt";
|
||||
import { argon2id, argon2Verify, md5 } from "hash-wasm";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4,
|
||||
// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator
|
||||
// validates the SAME users.password hash, so these must match.
|
||||
const ARGON2_PARAMS = {
|
||||
parallelism: Number(process.env.ARGON2_PARALLELISM) || 1,
|
||||
iterations: Number(process.env.ARGON2_ITERATIONS) || 4,
|
||||
memorySize: Number(process.env.ARGON2_MEMORY_SIZE) || 65536, // KiB
|
||||
hashLength: 32,
|
||||
} as const;
|
||||
function argon2Params() {
|
||||
return {
|
||||
parallelism: env.ARGON2_PARALLELISM,
|
||||
iterations: env.ARGON2_ITERATIONS,
|
||||
memorySize: env.ARGON2_MEMORY_SIZE,
|
||||
hashLength: 32,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function bcryptRounds(): number {
|
||||
return Number(process.env.BCRYPT_ROUNDS) || 12;
|
||||
return env.BCRYPT_ROUNDS;
|
||||
}
|
||||
|
||||
// Which algorithm hashPassword() emits for NEW/upgraded passwords.
|
||||
@@ -20,9 +25,7 @@ function bcryptRounds(): number {
|
||||
// - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id.
|
||||
// verifyPassword() always accepts BOTH, so logins keep working either way.
|
||||
function hashDriver(): "bcrypt" | "argon2id" {
|
||||
return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id"
|
||||
? "argon2id"
|
||||
: "bcrypt";
|
||||
return env.PASSWORD_HASH === "argon2id" ? "argon2id" : "bcrypt";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +51,7 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
outputType: "encoded",
|
||||
...ARGON2_PARAMS,
|
||||
...argon2Params(),
|
||||
});
|
||||
}
|
||||
// native bcrypt emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDate } from "./format-date";
|
||||
|
||||
describe("formatDate", () => {
|
||||
const d = new Date("2026-07-18T18:30:45.123Z");
|
||||
|
||||
it("formats as datetime by default", () => {
|
||||
expect(formatDate(d)).toBe("2026-07-18 18:30");
|
||||
});
|
||||
|
||||
it("formats as date only", () => {
|
||||
expect(formatDate(d, "date")).toBe("2026-07-18");
|
||||
});
|
||||
|
||||
it("formats with seconds", () => {
|
||||
expect(formatDate(d, "datetime-seconds")).toBe("2026-07-18 18:30:45");
|
||||
});
|
||||
|
||||
it("returns fallback for null/undefined", () => {
|
||||
expect(formatDate(null)).toBe("—");
|
||||
expect(formatDate(undefined)).toBe("—");
|
||||
expect(formatDate(null, "date")).toBe("");
|
||||
expect(formatDate(undefined, "datetime", "N/A")).toBe("N/A");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user