Improvements: remove dead config, fix ESM, add URL validation, unify types, add missing logging
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m52s

- 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:
openhands committed 2026-07-26 20:28:11 +02:00
1 parent b922f6d49f
commit 17847545dd
292 files changed
+69195 -67915

No files matched your search

+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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 = {
+5 -11
View File
@@ -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,
}))}
/>
)}
+2 -2
View File
@@ -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"]);
+3 -4
View File
@@ -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)) {
+6 -6
View File
@@ -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>
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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";
+5 -2
View File
@@ -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")}`
+7 -2
View File
@@ -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>
+347 -372
View File
@@ -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 ────────────────────────────────────────────────────────────
+1 -1
View File
@@ -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(
+5 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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";
+2 -2
View File
@@ -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();
+8 -2
View File
@@ -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">
+11 -5
View File
@@ -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 -1
View File
@@ -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";
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -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>
+6 -7
View File
@@ -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 && (
+12 -3
View File
@@ -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">
+2 -2
View File
@@ -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();
+6 -9
View File
@@ -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>
+4 -2
View File
@@ -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>
+1 -1
View File
@@ -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 -1
View File
@@ -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({
+3 -5
View File
@@ -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 (
+1 -2
View File
@@ -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;
+6 -7
View File
@@ -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),
),
];
+1 -1
View File
@@ -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;
+5 -7
View File
@@ -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 />
+3 -4
View File
@@ -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)) {
+3 -2
View File
@@ -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 {
+4 -2
View File
@@ -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>
),
},
];
+2 -2
View File
@@ -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 -2
View File
@@ -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>
),
},
];
+2 -2
View File
@@ -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";
+13 -11
View File
@@ -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 -1
View File
@@ -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);
+1 -1
View File
@@ -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;
+16 -14
View File
@@ -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;
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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";
+1 -3
View File
@@ -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>
+1 -1
View File
@@ -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 -3
View File
@@ -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 -4
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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();
+2 -8
View File
@@ -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">
-1
View File
@@ -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 -1
View File
@@ -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";
-1
View File
@@ -62,7 +62,6 @@ export default async function AdminRadioPointsPage({
return (
<main>
<section className="mt-6">
{saved === "1" ? (
<p>
+1 -5
View File
@@ -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}
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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 -1
View File
@@ -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 -5
View File
@@ -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>
+2 -4
View File
@@ -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 &ldquo;{deleting.name}&rdquo;?
</DialogTitle>
<DialogTitle>Delete &ldquo;{deleting.name}&rdquo;?</DialogTitle>
<DialogDescription>
{deleting.usedBy > 0 ? (
<>
+1 -1
View File
@@ -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 = {
+3 -4
View File
@@ -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)) {
+2 -2
View File
@@ -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";
+1 -4
View File
@@ -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",
+9 -7
View File
@@ -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);
+1 -1
View File
@@ -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;
+4 -7
View File
@@ -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 = {
+4 -2
View File
@@ -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"
+1 -1
View File
@@ -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;
+2 -4
View File
@@ -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} />;
}
+4 -10
View File
@@ -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")}
+3 -5
View File
@@ -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")}
+5 -5
View File
@@ -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)} />