Improvements: remove dead config, fix ESM, add URL validation, unify types, add missing logging
- Remove .prettierrc (dead config, Biome replaces Prettier) - Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat - Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit - Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts - Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars - Replace barrel export src/types/index.ts with direct @/types/common imports - Make trustHost conditional (development only) in auth.ts - Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations - Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
This commit is contained in:
1 parent
b922f6d49f
commit
17847545dd
292 files changed
+69195
-67915
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type Achievement = {
|
||||
name: string;
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 { prisma } from "@/lib/prisma";
|
||||
import { EditAdDeleteButton } from "../ads-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export type AdRow = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AdsTable } from "./ads-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -27,17 +27,13 @@ export default async function AdminAds() {
|
||||
ads = [];
|
||||
}
|
||||
|
||||
const latest = ads[0]?.createdAt
|
||||
? formatDate(ads[0].createdAt, "date")
|
||||
: "—";
|
||||
const latest = ads[0]?.createdAt ? formatDate(ads[0].createdAt, "date") : "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/ads/new">
|
||||
+ {t("newAd")}
|
||||
</Link>
|
||||
<Link href="/admin/ads/new">+ {t("newAd")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -60,9 +56,7 @@ export default async function AdminAds() {
|
||||
ads={ads.map((ad) => ({
|
||||
id: String(ad.id),
|
||||
image: ad.image,
|
||||
createdAt: ad.createdAt
|
||||
? formatDate(ad.createdAt, "date")
|
||||
: null,
|
||||
createdAt: ad.createdAt ? formatDate(ad.createdAt, "date") : null,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const CRITICAL = new Set(["critical", "error", "danger"]);
|
||||
const WARNING = new Set(["warning", "warn"]);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
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 { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -18,7 +18,6 @@ type ApplicationRow = {
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
|
||||
export default async function AdminApplications() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArticleCard } from "@/components/admin/article-card";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -38,7 +38,9 @@ export default async function AdminArticles({
|
||||
.catch(() => []);
|
||||
|
||||
const authorMap = new Map<number, string>();
|
||||
const authorIds = articles.map((a) => a.userId).filter((id): id is number => Boolean(id));
|
||||
const authorIds = articles
|
||||
.map((a) => a.userId)
|
||||
.filter((id): id is number => Boolean(id));
|
||||
if (authorIds.length > 0) {
|
||||
const authors = await prisma.user
|
||||
.findMany({
|
||||
@@ -57,9 +59,7 @@ export default async function AdminArticles({
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/articles/new">
|
||||
{t("newArticle")}
|
||||
</Link>
|
||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -2,8 +2,8 @@ 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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -115,7 +115,10 @@ export default async function AdminCalendarCampaign({
|
||||
{t("detail.campaignMeta", {
|
||||
id: campaign.id,
|
||||
days: campaign.totalDays,
|
||||
date: formatDate(new Date(campaign.startTimestamp * 1000), "date"),
|
||||
date: formatDate(
|
||||
new Date(campaign.startTimestamp * 1000),
|
||||
"date",
|
||||
),
|
||||
})}
|
||||
{disabled
|
||||
? ` · ${t("detail.disabled")}`
|
||||
|
||||
@@ -2,8 +2,8 @@ import Link from "next/link";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -118,7 +118,12 @@ export default async function AdminCalendar() {
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
|
||||
{t("startsAt", { date: formatDate(new Date(c.startTimestamp * 1000), "date") })}
|
||||
{t("startsAt", {
|
||||
date: formatDate(
|
||||
new Date(c.startTimestamp * 1000),
|
||||
"date",
|
||||
),
|
||||
})}
|
||||
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ import { deleteCatalogPage, updateCatalogPage } from "@/actions/catalog";
|
||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -167,343 +167,356 @@ export function CatalogPageForm({
|
||||
<>
|
||||
{confirmDialog}
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* ── Left column (2/3): tabs ─────────────────────────── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="texts" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Texts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appearance" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── General ──────────────────────────────────── */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="caption">Caption</Label>
|
||||
<Input
|
||||
id="caption"
|
||||
value={form.caption}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Parent Page</Label>
|
||||
<ParentCombobox
|
||||
value={form.parentId}
|
||||
onChange={(id) => update("parentId", id)}
|
||||
pages={allPages}
|
||||
currentPageId={catalogPage.id}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Layout</Label>
|
||||
<LayoutSelect
|
||||
value={form.pageLayout}
|
||||
onChange={(v) => update("pageLayout", v)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[form.pageLayout as CatalogLayout] ??
|
||||
"Custom layout"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orderNum">Order</Label>
|
||||
<Input
|
||||
id="orderNum"
|
||||
type="number"
|
||||
value={form.orderNum}
|
||||
onChange={(e) =>
|
||||
update("orderNum", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minRank">Min Rank</Label>
|
||||
<Input
|
||||
id="minRank"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.minRank}
|
||||
onChange={(e) =>
|
||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Texts ────────────────────────────────────── */}
|
||||
<TabsContent value="texts" className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageHeadline}
|
||||
onChange={(v) => update("pageHeadline", v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ImagePreview value={form.pageHeadline} height={50} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Teaser</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageTeaser}
|
||||
onChange={(v) => update("pageTeaser", v)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ImagePreview value={form.pageTeaser} height={50} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextareaField
|
||||
id="pageText1"
|
||||
label="Text 1"
|
||||
value={form.pageText1}
|
||||
onChange={(v) => update("pageText1", v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
/>
|
||||
<TextareaField
|
||||
id="pageText2"
|
||||
label="Text 2"
|
||||
value={form.pageText2}
|
||||
onChange={(v) => update("pageText2", v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextareaField
|
||||
id="pageTextDetails"
|
||||
label="Details"
|
||||
value={form.pageTextDetails}
|
||||
onChange={(v) => update("pageTextDetails", v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
/>
|
||||
<TextareaField
|
||||
id="pageTextTeaser"
|
||||
label="Text Teaser"
|
||||
value={form.pageTextTeaser}
|
||||
onChange={(v) => update("pageTextTeaser", v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
<TextareaField
|
||||
id="pageSpecial"
|
||||
label="Special"
|
||||
value={form.pageSpecial}
|
||||
onChange={(v) => update("pageSpecial", v)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
max={2048}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Appearance ───────────────────────────────── */}
|
||||
<TabsContent value="appearance" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<IconPicker
|
||||
value={form.iconImage}
|
||||
onChange={(id) => update("iconImage", id)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Icon shown in the catalog tree navigation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iconColor">Icon Color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="iconColor"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32"
|
||||
value={form.iconColor}
|
||||
onChange={(e) =>
|
||||
update("iconColor", parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Legacy tint value. Leave at default unless you need to
|
||||
match a specific theme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Child pages list */}
|
||||
{childPages.length > 0 && (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* ── Left column (2/3): tabs ─────────────────────────── */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
Child Pages ({childPages.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{childPages.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/admin/catalog/${child.id}`}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
|
||||
>
|
||||
<span>
|
||||
{child.caption}
|
||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
||||
#{child.id}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={child.enabled === "1" ? "default" : "outline"}
|
||||
>
|
||||
{child.enabled === "1" ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<CardContent className="pt-4">
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="texts" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Texts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appearance" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── General ──────────────────────────────────── */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="caption">Caption</Label>
|
||||
<Input
|
||||
id="caption"
|
||||
value={form.caption}
|
||||
onChange={(e) => update("caption", e.target.value)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Parent Page</Label>
|
||||
<ParentCombobox
|
||||
value={form.parentId}
|
||||
onChange={(id) => update("parentId", id)}
|
||||
pages={allPages}
|
||||
currentPageId={catalogPage.id}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Layout</Label>
|
||||
<LayoutSelect
|
||||
value={form.pageLayout}
|
||||
onChange={(v) => update("pageLayout", v)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{LAYOUT_DESCRIPTIONS[
|
||||
form.pageLayout as CatalogLayout
|
||||
] ?? "Custom layout"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orderNum">Order</Label>
|
||||
<Input
|
||||
id="orderNum"
|
||||
type="number"
|
||||
value={form.orderNum}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"orderNum",
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minRank">Min Rank</Label>
|
||||
<Input
|
||||
id="minRank"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.minRank}
|
||||
onChange={(e) =>
|
||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Texts ────────────────────────────────────── */}
|
||||
<TabsContent value="texts" className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Headline</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageHeadline}
|
||||
onChange={(v) => update("pageHeadline", v)}
|
||||
type="header"
|
||||
label="Headline image"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ImagePreview value={form.pageHeadline} height={50} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Teaser</Label>
|
||||
<CatalogImagePicker
|
||||
value={form.pageTeaser}
|
||||
onChange={(v) => update("pageTeaser", v)}
|
||||
type="teaser"
|
||||
label="Teaser image"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ImagePreview value={form.pageTeaser} height={50} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextareaField
|
||||
id="pageText1"
|
||||
label="Text 1"
|
||||
value={form.pageText1}
|
||||
onChange={(v) => update("pageText1", v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
/>
|
||||
<TextareaField
|
||||
id="pageText2"
|
||||
label="Text 2"
|
||||
value={form.pageText2}
|
||||
onChange={(v) => update("pageText2", v)}
|
||||
disabled={!canEdit}
|
||||
rows={4}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextareaField
|
||||
id="pageTextDetails"
|
||||
label="Details"
|
||||
value={form.pageTextDetails}
|
||||
onChange={(v) => update("pageTextDetails", v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
/>
|
||||
<TextareaField
|
||||
id="pageTextTeaser"
|
||||
label="Text Teaser"
|
||||
value={form.pageTextTeaser}
|
||||
onChange={(v) => update("pageTextTeaser", v)}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
max={5000}
|
||||
/>
|
||||
</div>
|
||||
<TextareaField
|
||||
id="pageSpecial"
|
||||
label="Special"
|
||||
value={form.pageSpecial}
|
||||
onChange={(v) => update("pageSpecial", v)}
|
||||
disabled={!canEdit}
|
||||
rows={2}
|
||||
max={2048}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Appearance ───────────────────────────────── */}
|
||||
<TabsContent value="appearance" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<IconPicker
|
||||
value={form.iconImage}
|
||||
onChange={(id) => update("iconImage", id)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Icon shown in the catalog tree navigation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iconColor">Icon Color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="iconColor"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32"
|
||||
value={form.iconColor}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"iconColor",
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Legacy tint value. Leave at default unless you need to
|
||||
match a specific theme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Child pages list */}
|
||||
{childPages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
Child Pages ({childPages.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{childPages.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/admin/catalog/${child.id}`}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-accent text-sm"
|
||||
>
|
||||
<span>
|
||||
{child.caption}
|
||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
||||
#{child.id}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
child.enabled === "1" ? "default" : "outline"
|
||||
}
|
||||
>
|
||||
{child.enabled === "1" ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right column (1/3): status sticky ────────────────── */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="lg:sticky lg:top-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Status & Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="flex items-center gap-3 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded bg-background shrink-0">
|
||||
<CatalogIcon iconImage={form.iconImage} size={36} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{form.caption || "Untitled"}
|
||||
</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
|
||||
layoutColorClass,
|
||||
)}
|
||||
>
|
||||
{form.pageLayout}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
id="tg-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the catalog"
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-visible"
|
||||
label="Visible"
|
||||
description="Shown in navigation"
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderTree className="h-3.5 w-3.5" />
|
||||
{childPages.length} sub
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="h-3.5 w-3.5" />#{catalogPage.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Sync
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Page
|
||||
</Button>
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
|
||||
save
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right column (1/3): status sticky ────────────────── */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="lg:sticky lg:top-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Status & Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="flex items-center gap-3 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded bg-background shrink-0">
|
||||
<CatalogIcon iconImage={form.iconImage} size={36} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{form.caption || "Untitled"}
|
||||
</p>
|
||||
<Badge
|
||||
className={cn(
|
||||
"mt-1 h-5 px-1.5 text-[10px] font-mono border-0",
|
||||
layoutColorClass,
|
||||
)}
|
||||
>
|
||||
{form.pageLayout}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
id="tg-enabled"
|
||||
label="Enabled"
|
||||
description="Active in the catalog"
|
||||
checked={form.enabled === "1"}
|
||||
onChange={(v) => update("enabled", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-visible"
|
||||
label="Visible"
|
||||
description="Shown in navigation"
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderTree className="h-3.5 w-3.5" />
|
||||
{childPages.length} sub
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="h-3.5 w-3.5" />#{catalogPage.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Sync
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Page
|
||||
</Button>
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Press <kbd className="rounded border px-1">Ctrl+S</kbd> to
|
||||
save
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -543,44 +556,6 @@ function ToggleRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Text field with counter ────────────────────────────────────
|
||||
|
||||
function _TextField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
max,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
max?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{max && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{value.length}/{max}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
maxLength={max}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaField({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -35,12 +35,12 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import {
|
||||
habboGamedataHotelLabel,
|
||||
habboGamedataHotelShort,
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { FurniIcon } from "./catalog-items-table";
|
||||
|
||||
interface BaseItemForTranslation {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
updateBcItem,
|
||||
updateBcPage,
|
||||
} from "@/actions/catalog-bc";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface CatalogPageRow {
|
||||
id: number;
|
||||
|
||||
@@ -190,11 +190,10 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive" className="w-full"
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
disabled={
|
||||
isPending ||
|
||||
!disconnect.userId ||
|
||||
!disconnect.username.trim()
|
||||
isPending || !disconnect.userId || !disconnect.username.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -249,10 +248,9 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
disabled={
|
||||
isPending || !alert.userId || !alert.message.trim()
|
||||
}
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !alert.userId || !alert.message.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
@@ -305,7 +303,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !forward.userId || !forward.roomId}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -317,8 +316,7 @@ export function CommandocentrumControls() {
|
||||
{
|
||||
successMessage: "User forwarded.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setForward({ userId: "", roomId: "" }),
|
||||
onSuccess: () => setForward({ userId: "", roomId: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -362,7 +360,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !credits.userId || !credits.credits}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -374,8 +373,7 @@ export function CommandocentrumControls() {
|
||||
{
|
||||
successMessage: "Credits sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setCredits({ userId: "", credits: "" }),
|
||||
onSuccess: () => setCredits({ userId: "", credits: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -417,7 +415,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !duckets.userId || !duckets.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -429,8 +428,7 @@ export function CommandocentrumControls() {
|
||||
{
|
||||
successMessage: "Duckets sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDuckets({ userId: "", amount: "" }),
|
||||
onSuccess: () => setDuckets({ userId: "", amount: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -472,7 +470,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !diamonds.userId || !diamonds.amount}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -484,8 +483,7 @@ export function CommandocentrumControls() {
|
||||
{
|
||||
successMessage: "Diamonds sent.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setDiamonds({ userId: "", amount: "" }),
|
||||
onSuccess: () => setDiamonds({ userId: "", amount: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -529,7 +527,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !badge.userId || !badge.badge.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -583,7 +582,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !motto.userId || !motto.motto.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -595,8 +595,7 @@ export function CommandocentrumControls() {
|
||||
{
|
||||
successMessage: "Motto updated.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setMottoForm({ userId: "", motto: "" }),
|
||||
onSuccess: () => setMottoForm({ userId: "", motto: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -639,7 +638,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !rank.userId || rank.rank === ""}
|
||||
onClick={() =>
|
||||
run(
|
||||
@@ -695,7 +695,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={
|
||||
isPending || !command.userId || !command.command.trim()
|
||||
}
|
||||
@@ -709,8 +710,7 @@ export function CommandocentrumControls() {
|
||||
{
|
||||
successMessage: "Command executed.",
|
||||
errorMessage: RCON_ERROR,
|
||||
onSuccess: () =>
|
||||
setCommand({ userId: "", command: "" }),
|
||||
onSuccess: () => setCommand({ userId: "", command: "" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -763,7 +763,8 @@ export function CommandocentrumControls() {
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default" className="w-full"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isPending || !gift.userId || !gift.itemId}
|
||||
onClick={() =>
|
||||
run(
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
OnlineUsersWidget,
|
||||
StatusCard,
|
||||
} from "@/components/admin/dashboard";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
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";
|
||||
@@ -268,7 +268,10 @@ export default async function CommandoCentrum() {
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{formatDate(e.timestamp ? new Date(e.timestamp * 1000) : null, "datetime-seconds")}
|
||||
{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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
createEmailTemplate,
|
||||
@@ -5,9 +6,8 @@ import {
|
||||
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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function AdminEmailTemplates() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
|
||||
@@ -41,10 +41,16 @@ export default function AdminErrorPage({
|
||||
{t("code")}
|
||||
</p>
|
||||
<h1 className="text-xl font-extrabold mb-2">{t("title")}</h1>
|
||||
<p className="text-sm mb-1" style={{ color: "var(--admin-muted, #6b7280)" }}>
|
||||
<p
|
||||
className="text-sm mb-1"
|
||||
style={{ color: "var(--admin-muted, #6b7280)" }}
|
||||
>
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<p className="text-sm mb-6" style={{ color: "var(--admin-muted, #6b7280)" }}>
|
||||
<p
|
||||
className="text-sm mb-6"
|
||||
style={{ color: "var(--admin-muted, #6b7280)" }}
|
||||
>
|
||||
{t("body")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CalendarDays, Gift, Trophy, Users } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -118,7 +118,9 @@ export default async function EventDetailPage({
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("statPrizes")}</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("statPrizes")}
|
||||
</CardTitle>
|
||||
<Gift className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -138,7 +140,9 @@ export default async function EventDetailPage({
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("statTime")}</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("statTime")}
|
||||
</CardTitle>
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -189,7 +193,8 @@ export default async function EventDetailPage({
|
||||
eventId={event.id}
|
||||
winners={event.winners.map((w) => ({
|
||||
...w,
|
||||
username: userMap.get(w.userId) ?? t("userFallback", { id: w.userId }),
|
||||
username:
|
||||
userMap.get(w.userId) ?? t("userFallback", { id: w.userId }),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -197,7 +202,8 @@ export default async function EventDetailPage({
|
||||
<EventRegistrations
|
||||
registrations={event.registrations.map((r) => ({
|
||||
...r,
|
||||
username: userMap.get(r.userId) ?? t("userFallback", { id: r.userId }),
|
||||
username:
|
||||
userMap.get(r.userId) ?? t("userFallback", { id: r.userId }),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { EventForm } from "../event-form";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface EventRow {
|
||||
id: number;
|
||||
|
||||
@@ -48,9 +48,7 @@ export default async function EventsPage({
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/events/create">
|
||||
{t("newEvent")}
|
||||
</Link>
|
||||
<Link href="/admin/events/create">{t("newEvent")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 { Button } from "@/components/ui/button";
|
||||
import { FaviconGenerator } from "./favicon-generator";
|
||||
|
||||
export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
|
||||
@@ -82,7 +82,10 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
|
||||
</h3>
|
||||
{preview && (
|
||||
<div className="mb-6">
|
||||
<label htmlFor="favicon-current" className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2">
|
||||
<label
|
||||
htmlFor="favicon-current"
|
||||
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
|
||||
>
|
||||
{t("current")}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -117,11 +120,7 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
variant="default"
|
||||
>
|
||||
<Button type="submit" disabled={uploading} variant="default">
|
||||
{uploading ? t("uploading") : t("upload")}
|
||||
</Button>
|
||||
{preview && (
|
||||
|
||||
@@ -105,7 +105,10 @@ export function FaviconGenerator({ onSave }: Props) {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="favicon-text" className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2">
|
||||
<label
|
||||
htmlFor="favicon-text"
|
||||
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
|
||||
>
|
||||
{t("text")}
|
||||
</label>
|
||||
<input
|
||||
@@ -120,7 +123,10 @@ export function FaviconGenerator({ onSave }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="favicon-bgColor" className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2">
|
||||
<label
|
||||
htmlFor="favicon-bgColor"
|
||||
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
|
||||
>
|
||||
{t("bgColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -153,7 +159,10 @@ export function FaviconGenerator({ onSave }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="favicon-textColor" className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2">
|
||||
<label
|
||||
htmlFor="favicon-textColor"
|
||||
className="block text-xs font-bold text-[var(--admin-text-muted)] uppercase tracking-wider mb-2"
|
||||
>
|
||||
{t("textColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { FaviconForm } from "./favicon-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { FaviconForm } from "./favicon-form";
|
||||
|
||||
export default async function AdminFaviconPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
|
||||
@@ -3,8 +3,8 @@ 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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -95,15 +95,10 @@ export default async function AdminGuildDetailPage({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/admin/guilds">
|
||||
{t("back")}
|
||||
</Link>
|
||||
<Link href="/admin/guilds">{t("back")}</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
href={`/guilds/${guild.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Link href={`/guilds/${guild.id}`} target="_blank">
|
||||
{t("viewPublic")}
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -193,7 +188,9 @@ export default async function AdminGuildDetailPage({
|
||||
</Link>
|
||||
</td>
|
||||
<td className="muted">{m.levelId}</td>
|
||||
<td className="muted">{formatDate(new Date(m.memberSince * 1000))}</td>
|
||||
<td className="muted">
|
||||
{formatDate(new Date(m.memberSince * 1000))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -184,7 +184,9 @@ export default async function AdminGuildsPage({
|
||||
</Link>
|
||||
</td>
|
||||
<td>{membersByGuild.get(g.id) ?? 0}</td>
|
||||
<td className="muted">{formatDate(new Date(g.dateCreated * 1000), "date")}</td>
|
||||
<td className="muted">
|
||||
{formatDate(new Date(g.dateCreated * 1000), "date")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -3,8 +3,8 @@ 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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createHelpQuestion } from "@/actions/admin-help";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function NewHelpQuestion({
|
||||
|
||||
@@ -1,10 +1,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 { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -34,9 +34,7 @@ export default async function AdminHelpQuestions() {
|
||||
<main>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/help-questions/new">
|
||||
+ {t("newEntry")}
|
||||
</Link>
|
||||
<Link href="/admin/help-questions/new">+ {t("newEntry")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,17 +78,15 @@ export function AdminHelpTicketDetail({
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
run(
|
||||
() => closeHelpCenterTicket({ ticketId: ticket.id }),
|
||||
{ successMessage: t("success.closed") },
|
||||
);
|
||||
run(() => closeHelpCenterTicket({ ticketId: ticket.id }), {
|
||||
successMessage: t("success.closed"),
|
||||
});
|
||||
}
|
||||
|
||||
function handleReopen() {
|
||||
run(
|
||||
() => reopenHelpCenterTicket({ ticketId: ticket.id }),
|
||||
{ successMessage: t("success.reopened") },
|
||||
);
|
||||
run(() => reopenHelpCenterTicket({ ticketId: ticket.id }), {
|
||||
successMessage: t("success.reopened"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -98,8 +98,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
}),
|
||||
];
|
||||
|
||||
const creator =
|
||||
openerId != null ? userById.get(openerId) : undefined;
|
||||
const creator = openerId != null ? userById.get(openerId) : undefined;
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export interface HelpTicketRow {
|
||||
id: string;
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
HelpTicketsTable,
|
||||
type HelpTicketRow,
|
||||
} from "./help-tickets-table";
|
||||
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -116,7 +113,9 @@ export default async function AdminHelpTicketsPage({
|
||||
const ticketIds = tickets.map((ticket) => ticket.id);
|
||||
const userIds = [
|
||||
...new Set(
|
||||
tickets.map((ticket) => ticket.userId).filter((id): id is number => id != null),
|
||||
tickets
|
||||
.map((ticket) => ticket.userId)
|
||||
.filter((id): id is number => id != null),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { exportPermissions } from "@/actions/admin-housekeeping";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/* ────────── Export Section ────────── */
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import { runSseImport } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EffectItem {
|
||||
id: string;
|
||||
|
||||
@@ -388,6 +388,7 @@ export function ImportFurniClient() {
|
||||
setPage(1);
|
||||
fetchItems(searchTerm, 1, statusFilter);
|
||||
}
|
||||
void _doSearch;
|
||||
|
||||
function goPage(p: number) {
|
||||
setPage(p);
|
||||
|
||||
@@ -245,9 +245,11 @@ export function NitroEditorDialog({
|
||||
};
|
||||
|
||||
const toggleDirection = (dir: number) => {
|
||||
const current = getNested(metadata, "logic.model.directions", [
|
||||
0,
|
||||
]) as number[];
|
||||
const current = getNested(
|
||||
metadata,
|
||||
"logic.model.directions",
|
||||
[0],
|
||||
) as number[];
|
||||
const next = current.includes(dir)
|
||||
? current.filter((d: number) => d !== dir)
|
||||
: [...current, dir].sort((a: number, b: number) => a - b);
|
||||
@@ -568,9 +570,7 @@ export function NitroEditorDialog({
|
||||
<Input
|
||||
id="logic-type"
|
||||
value={metadata.logicType || ""}
|
||||
onChange={(e) =>
|
||||
patchMeta("logicType", e.target.value)
|
||||
}
|
||||
onChange={(e) => patchMeta("logicType", e.target.value)}
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,9 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PetItem {
|
||||
lib: string;
|
||||
|
||||
@@ -11,14 +11,12 @@ export default async function SyncAllPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Sync All Sources
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Scan all configured clone sources and automatically import
|
||||
any furniture that is not yet present in your hotel. Missing
|
||||
.nitro files, icons, database entries, catalog items, and
|
||||
FurnitureData.json will be added automatically.
|
||||
Scan all configured clone sources and automatically import any
|
||||
furniture that is not yet present in your hotel. Missing .nitro files,
|
||||
icons, database entries, catalog items, and FurnitureData.json will be
|
||||
added automatically.
|
||||
</p>
|
||||
</div>
|
||||
<SyncAllClient />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import {
|
||||
addBlacklist,
|
||||
addWhitelist,
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} 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 { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,7 +21,6 @@ type IpRow = {
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
|
||||
export default async function AdminIp() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
@@ -20,7 +20,8 @@ interface AuditRow {
|
||||
function DiffCell({ diff }: { diff: string | null }) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!diff) return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
if (!diff)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface ChatLogRow {
|
||||
id: number;
|
||||
@@ -34,7 +34,9 @@ export function ChatTable({ data }: ChatTableProps) {
|
||||
key: "timestamp",
|
||||
label: t("colTime"),
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatDate(new Date(Number(value) * 1000))}</span>,
|
||||
render: (value) => (
|
||||
<span>{formatDate(new Date(Number(value) * 1000))}</span>
|
||||
),
|
||||
},
|
||||
{ key: "roomId", label: t("chat.colRoomId"), sortable: true },
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface CommandLogRow {
|
||||
id: number;
|
||||
@@ -37,7 +37,9 @@ export function CommandsTable({ data }: CommandsTableProps) {
|
||||
key: "timestamp",
|
||||
label: t("colTime"),
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatDate(new Date(Number(value) * 1000))}</span>,
|
||||
render: (value) => (
|
||||
<span>{formatDate(new Date(Number(value) * 1000))}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { StaffActivitiesTable } from "./staff-activities-table";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export interface StaffActivityRow {
|
||||
id: number;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface TradeLogRow {
|
||||
id: number;
|
||||
@@ -46,7 +46,9 @@ export function TradesTable({ data }: TradesTableProps) {
|
||||
key: "timestamp",
|
||||
label: t("colTime"),
|
||||
sortable: true,
|
||||
render: (value) => <span>{formatDate(new Date(Number(value) * 1000))}</span>,
|
||||
render: (value) => (
|
||||
<span>{formatDate(new Date(Number(value) * 1000))}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ 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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -91,9 +91,7 @@ export default async function AdminMarketplacePage({
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/marketplace?filter=sold">
|
||||
{t("filterSold")}
|
||||
</Link>
|
||||
<Link href="/admin/marketplace?filter=sold">{t("filterSold")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -138,19 +136,23 @@ export default async function AdminMarketplacePage({
|
||||
</Link>
|
||||
</td>
|
||||
<td>{row.price}</td>
|
||||
<td className="muted">{formatDate(new Date(row.timestamp * 1000))}</td>
|
||||
<td className="muted">
|
||||
{formatDate(new Date(row.timestamp * 1000))}
|
||||
</td>
|
||||
{filter === "sold" ? (
|
||||
<td className="muted">{formatDate(row.soldTimestamp ? new Date(row.soldTimestamp * 1000) : null)}</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
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AdminMediaGrid } from "@/components/admin/media-grid";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminMediaGrid } from "@/components/admin/media-grid";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function AdminMediaPage() {
|
||||
|
||||
@@ -72,7 +72,6 @@ export function CfhDetail({
|
||||
moderator,
|
||||
banHistory,
|
||||
canEdit,
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: part of the public component API
|
||||
currentUserId,
|
||||
listHref = "/admin/moderation/cfh",
|
||||
userLinkMode = "admin",
|
||||
@@ -96,6 +95,7 @@ export function CfhDetail({
|
||||
listHref?: string;
|
||||
userLinkMode?: "admin" | "public";
|
||||
}) {
|
||||
void currentUserId;
|
||||
const { run, isPending } = useServerAction();
|
||||
const [alertMsg, setAlertMsg] = useState("");
|
||||
const [muteDuration, setMuteDuration] = useState(10);
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export interface CfhRow {
|
||||
id: number;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { redirect } from "next/navigation";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CfhTable, type CfhRow } from "./cfh-table";
|
||||
import { type CfhRow, CfhTable } from "./cfh-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -64,19 +64,21 @@ export default async function CfhListPage({
|
||||
const where: Prisma.SupportTicketsWhereInput =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
|
||||
const SORT_MAP: Record<string, Prisma.SupportTicketsOrderByWithRelationInput> =
|
||||
{
|
||||
id: { id: "desc" },
|
||||
issue: { issue: "asc" },
|
||||
sender: { senderId: "asc" },
|
||||
reported: { reportedId: "asc" },
|
||||
mod: { modId: "asc" },
|
||||
state: { state: "asc" },
|
||||
};
|
||||
const SORT_MAP: Record<
|
||||
string,
|
||||
Prisma.SupportTicketsOrderByWithRelationInput
|
||||
> = {
|
||||
id: { id: "desc" },
|
||||
issue: { issue: "asc" },
|
||||
sender: { senderId: "asc" },
|
||||
reported: { reportedId: "asc" },
|
||||
mod: { modId: "asc" },
|
||||
state: { state: "asc" },
|
||||
};
|
||||
|
||||
const baseOrder = SORT_MAP[parsed.sort ?? "id"] ?? { id: "desc" };
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the
|
||||
// navigator. The Prisma delegate exists because the table has a primary key.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface OnlineUserRow {
|
||||
id: number;
|
||||
|
||||
@@ -12,9 +12,7 @@ export default async function RankEditPage({
|
||||
}) {
|
||||
const { id: rawId } = await params;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (
|
||||
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
|
||||
) {
|
||||
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,7 @@ export function RankEditClient({
|
||||
if (permSearch && filtered.length === 0) return null;
|
||||
const on = filtered.filter((k) => (perms[k] ?? "0") !== "0").length;
|
||||
const _allOn = on === filtered.length;
|
||||
void _allOn;
|
||||
const open = !collapsed.has(sectionKey);
|
||||
|
||||
return (
|
||||
|
||||
@@ -107,8 +107,8 @@ export function PermissionsList({
|
||||
className="rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm"
|
||||
role="status"
|
||||
>
|
||||
You were sent here from the permission archive. Edit rank access
|
||||
below — this is the live control panel.
|
||||
You were sent here from the permission archive. Edit rank access below
|
||||
— this is the live control panel.
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { PollForm } from "../poll-form";
|
||||
|
||||
|
||||
@@ -47,9 +47,7 @@ export default async function PollsPage({
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/polls/create">
|
||||
{t("newPoll")}
|
||||
</Link>
|
||||
<Link href="/admin/polls/create">{t("newPoll")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface PollRow {
|
||||
id: number;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import {
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
} from "@/actions/admin-radio-api-keys";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -25,7 +25,6 @@ type ApiKey = {
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
|
||||
function maskKey(key: string): string {
|
||||
return key.length > 8 ? `${key.slice(0, 8)}\u2026` : key;
|
||||
}
|
||||
@@ -69,7 +68,6 @@ export default async function AdminRadioApiKeysPage() {
|
||||
return (
|
||||
<main>
|
||||
<section className="mt-6">
|
||||
|
||||
{dbError ? (
|
||||
<div className="admin-card" style={{ marginTop: "1rem" }}>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
createTrack,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
} from "@/actions/admin-radio-autodj";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -34,7 +34,6 @@ function formatDuration(seconds: number | null): string {
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
|
||||
export default async function AdminRadioAutoDjPage() {
|
||||
const t = await getTranslations("pages.admin.radio");
|
||||
|
||||
@@ -72,9 +71,7 @@ export default async function AdminRadioAutoDjPage() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
<section className="mt-6">
|
||||
|
||||
{dbError ? (
|
||||
<div className="admin-card mb-6">
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import NextImage from "next/image";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
createRadioBanner,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
} from "@/actions/admin-radio-extra";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -23,7 +23,6 @@ type Banner = {
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
|
||||
export default async function AdminRadioBannersPage() {
|
||||
const t = await getTranslations("pages.admin.radio");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,7 +15,6 @@ type History = {
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
|
||||
function formatDuration(start: Date, end: Date | null): string {
|
||||
if (!end) return "\u2014";
|
||||
const ms = end.getTime() - start.getTime();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,7 +15,6 @@ type Shout = {
|
||||
updatedAt: Date | null;
|
||||
};
|
||||
|
||||
|
||||
export default async function AdminRadioModerationPage() {
|
||||
const t = await getTranslations("pages.admin.radio");
|
||||
|
||||
@@ -38,13 +37,8 @@ export default async function AdminRadioModerationPage() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("shouts")}
|
||||
value={shouts.length}
|
||||
icon="📣"
|
||||
/>
|
||||
<StatusCard label={t("shouts")} value={shouts.length} icon="📣" />
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
|
||||
@@ -211,7 +211,6 @@ export default async function AdminRadioMonitoringPage() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
<section className="mt-6">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -62,7 +62,6 @@ export default async function AdminRadioPointsPage({
|
||||
|
||||
return (
|
||||
<main>
|
||||
|
||||
<section className="mt-6">
|
||||
{saved === "1" ? (
|
||||
<p>
|
||||
|
||||
@@ -52,11 +52,7 @@ export default async function AdminRadioRanksPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("ranks")}
|
||||
value={ranks.length}
|
||||
icon="🎖️"
|
||||
/>
|
||||
<StatusCard label={t("ranks")} value={ranks.length} icon="🎖️" />
|
||||
<StatusCard
|
||||
label={t("ranksForm.active")}
|
||||
value={activeCount}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
createCategory,
|
||||
@@ -7,9 +8,8 @@ import {
|
||||
} 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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface RoomRow {
|
||||
id: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AdvancedSettingsPanel } from "./advanced-settings-panel";
|
||||
|
||||
@@ -3,11 +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 { 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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -119,9 +119,7 @@ export default async function AdminShop({
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/shop/new">
|
||||
{t("newPackage")}
|
||||
</Link>
|
||||
<Link href="/admin/shop/new">{t("newPackage")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache";
|
||||
import { parseTraxChannels } from "@/lib/trax-format";
|
||||
import { TraxPlayer } from "./trax-player";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
interface SoundtrackRow {
|
||||
id: number;
|
||||
@@ -345,9 +345,7 @@ export function SoundsClient({
|
||||
<Dialog open onOpenChange={(open) => !open && setDeleting(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Delete “{deleting.name}”?
|
||||
</DialogTitle>
|
||||
<DialogTitle>Delete “{deleting.name}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleting.usedBy > 0 ? (
|
||||
<>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { getTranslations } from "next-intl/server";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type SubRow = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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 { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type Tag = {
|
||||
id: bigint;
|
||||
@@ -14,7 +14,6 @@ type Tag = {
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
|
||||
export default async function AdminTagsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -410,10 +410,7 @@ export default async function AdminTheme({
|
||||
{Object.entries(PRESETS).map(([name, palette]) => (
|
||||
<form key={name} action={applyPreset}>
|
||||
<input type="hidden" name="preset" value={name} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
>
|
||||
<Button type="submit" variant="outline">
|
||||
<span className="flex items-center gap-1">
|
||||
{[
|
||||
"color_primary",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { TicketsTable, type TicketRow } from "./tickets-table";
|
||||
import { type TicketRow, TicketsTable } from "./tickets-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -50,9 +50,9 @@ export default async function AdminTicketsPage({
|
||||
const where: Prisma.WebsiteTicketWhereInput =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
|
||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||
{
|
||||
@@ -80,7 +80,9 @@ export default async function AdminTicketsPage({
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: "waiting" } })
|
||||
.catch(() => 0),
|
||||
prisma.websiteTicket.count({ where: { status: "closed" } }).catch(() => 0),
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: "closed" } })
|
||||
.catch(() => 0),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export interface TicketRow {
|
||||
id: number;
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
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 { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
TransactionsTable,
|
||||
type TransactionRow,
|
||||
} from "./transactions-table";
|
||||
import { type TransactionRow, TransactionsTable } from "./transactions-table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export interface TransactionRow {
|
||||
id: number;
|
||||
|
||||
@@ -110,8 +110,7 @@ export function ClientTranslations({
|
||||
onClick={handleSave}
|
||||
disabled={isPending || activeFile.readOnly || !!parseError}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />{" "}
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
<Save className="w-4 h-4 mr-1" /> {isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,8 +185,7 @@ export function ClientTranslations({
|
||||
<Lock className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
This file is read-only.{" "}
|
||||
{activeFile.note ??
|
||||
"Manual editing is not supported by the CMS."}
|
||||
{activeFile.note ?? "Manual editing is not supported by the CMS."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import Link from "next/link";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { SanctionEvent } from "../_lib/load-user-sanctions";
|
||||
|
||||
const KIND_CLASS: Record<SanctionEvent["kind"], string> = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { isBanActive, unixNow } from "@/lib/bans";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type SanctionEvent = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
detectMultiAccounts,
|
||||
type MultiAccountCluster,
|
||||
@@ -45,7 +45,9 @@ export default async function MultiAccountsPage({
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-muted-foreground">{t("resultLimit")}</span>
|
||||
<span className="block text-muted-foreground">
|
||||
{t("resultLimit")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
name="limit"
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { VouchersClient, type VoucherRow } from "./vouchers-client";
|
||||
import { type VoucherRow, VouchersClient } from "./vouchers-client";
|
||||
|
||||
export default async function AdminVouchers() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
@@ -57,7 +57,5 @@ export default async function AdminVouchers() {
|
||||
expired: rows.filter((v) => v.status === "expired").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<VouchersClient vouchers={rows} canEdit={canEdit} stats={stats} />
|
||||
);
|
||||
return <VouchersClient vouchers={rows} canEdit={canEdit} stats={stats} />;
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export type VoucherRow = {
|
||||
id: string;
|
||||
@@ -152,11 +152,7 @@ export function VouchersClient({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
>
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isPending ? t("form.saving") : t("form.create")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -245,9 +241,7 @@ export function VouchersClient({
|
||||
}}
|
||||
title={t("confirmDeleteTitle")}
|
||||
description={
|
||||
pendingDelete
|
||||
? t("confirmDelete", { code: pendingDelete.code })
|
||||
: ""
|
||||
pendingDelete ? t("confirmDelete", { code: pendingDelete.code }) : ""
|
||||
}
|
||||
confirmLabel={t("delete")}
|
||||
cancelLabel={t("form.cancel")}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "VPN" };
|
||||
@@ -180,9 +180,7 @@ export default async function AdminVpn({
|
||||
})}
|
||||
</p>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="/admin/ip">
|
||||
{t("openIpManagement")}
|
||||
</Link>
|
||||
<Link href="/admin/ip">{t("openIpManagement")}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
export type WordFilterRow = { id: string; word: string };
|
||||
@@ -132,9 +132,7 @@ export function WordFilterClient({
|
||||
}}
|
||||
title={t("confirmDeleteTitle")}
|
||||
description={
|
||||
pendingDelete
|
||||
? t("confirmDelete", { word: pendingDelete.word })
|
||||
: ""
|
||||
pendingDelete ? t("confirmDelete", { word: pendingDelete.word }) : ""
|
||||
}
|
||||
confirmLabel={t("delete")}
|
||||
cancelLabel={t("form.cancel")}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
createBox,
|
||||
@@ -7,9 +8,8 @@ import {
|
||||
} 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";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -285,9 +285,9 @@ export default async function AdminWriteableBoxes() {
|
||||
name="next"
|
||||
value={box.isActive ? "0" : "1"}
|
||||
/>
|
||||
<Button type="submit" variant="outline">
|
||||
{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)} />
|
||||
|
||||
Reference in new issue
Block a user