@@ -237,5 +237,5 @@ export function TraxPlayer({ channels, baseUrl, expectedLength }: TraxPlayerProp
)}
- )
+ );
}
diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx
index 4d033f4f..de2fe482 100644
--- a/src/app/admin/subscriptions/page.tsx
+++ b/src/app/admin/subscriptions/page.tsx
@@ -88,7 +88,9 @@ export default async function AdminSubscriptions() {
const soon = s.expiresAt <= soonCutoff;
return (
- {s.id}
+
+ {s.id}
+
{s.userId ?? "—"}
{s.subscriptionType ?? "—"}
diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx
index 10226e3f..c0c16945 100644
--- a/src/app/admin/theme/page.tsx
+++ b/src/app/admin/theme/page.tsx
@@ -53,7 +53,11 @@ function toHex(value: string | null, def: string): string {
return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : def;
}
-function colorGrid(fields: { key: string; label: string; def: string }[], current: Record, suffix = "") {
+function colorGrid(
+ fields: { key: string; label: string; def: string }[],
+ current: Record,
+ suffix = "",
+) {
return (
[
- f.key,
- (await siteSettings.get(`${f.key}_dark`, defaultPreset.dark[f.key as keyof typeof defaultPreset.dark])) ?? defaultPreset.dark[f.key as keyof typeof defaultPreset.dark],
- ] as const),
+ ALL_COLORS.map(
+ async (f) =>
+ [
+ f.key,
+ (await siteSettings.get(
+ `${f.key}_dark`,
+ defaultPreset.dark[f.key as keyof typeof defaultPreset.dark],
+ )) ?? defaultPreset.dark[f.key as keyof typeof defaultPreset.dark],
+ ] as const,
+ ),
),
) as Record;
const radius = (await siteSettings.get("border_radius", "12")) ?? "12";
@@ -123,11 +133,7 @@ export default async function AdminTheme({
- {sp.saved ? (
-
- {t("saved")}
-
- ) : null}
+ {sp.saved ? {t("saved")}
: null}
{sp.preset ? (
{t("presetApplied", { preset: sp.preset })}
@@ -149,7 +155,7 @@ export default async function AdminTheme({
width: 14,
height: 14,
borderRadius: 3,
- // eslint-disable-next-line security/detect-object-injection -- k from hardcoded array
+
background: palette.light[k as keyof typeof palette.light],
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
marginLeft: -3,
diff --git a/src/app/admin/tickets/[id]/admin-ticket-detail.tsx b/src/app/admin/tickets/[id]/admin-ticket-detail.tsx
index 491e8c5e..7a8be545 100644
--- a/src/app/admin/tickets/[id]/admin-ticket-detail.tsx
+++ b/src/app/admin/tickets/[id]/admin-ticket-detail.tsx
@@ -1,74 +1,63 @@
-'use client'
+"use client";
-import { ArrowLeft, Calendar, Globe, Loader2, Mail, Send, Shield, User } from 'lucide-react'
-import Link from 'next/link'
-import { useEffect, useRef, useState } from 'react'
-import {
- adminReplyTicket,
- assignTicket,
- updateTicketPriority,
- updateTicketStatus,
-} from '@/actions/tickets'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { Textarea } from '@/components/ui/textarea'
-import { useServerAction } from '@/hooks/use-server-action'
-import { TICKET_PRIORITIES, TICKET_STATUSES } from '@/lib/validators/ticket'
+import { ArrowLeft, Calendar, Globe, Loader2, Mail, Send, Shield, User } from "lucide-react";
+import Link from "next/link";
+import { useEffect, useRef, useState } from "react";
+import { adminReplyTicket, assignTicket, updateTicketPriority, updateTicketStatus } from "@/actions/tickets";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { useServerAction } from "@/hooks/use-server-action";
+import { TICKET_PRIORITIES, TICKET_STATUSES } from "@/lib/validators/ticket";
interface TicketMessage {
- id: number
- message: string
- isStaff: boolean
- createdAt: string
- user: { id: number; username: string; look: string; rank: number }
+ id: number;
+ message: string;
+ isStaff: boolean;
+ createdAt: string;
+ user: { id: number; username: string; look: string; rank: number };
}
interface TicketInfo {
- id: number
- subject: string
- category: string
- priority: string
- status: string
- createdAt: string
- closedAt: string | null
+ id: number;
+ subject: string;
+ category: string;
+ priority: string;
+ status: string;
+ createdAt: string;
+ closedAt: string | null;
creator: {
- id: number
- username: string
- look: string
- rank: number
- mail: string
- ipRegister: string
- accountCreated: number
- }
- assignee: { id: number; username: string } | null
+ id: number;
+ username: string;
+ look: string;
+ rank: number;
+ mail: string;
+ ipRegister: string;
+ accountCreated: number;
+ };
+ assignee: { id: number; username: string } | null;
}
interface StaffMember {
- id: number
- username: string
+ id: number;
+ username: string;
}
const STATUS_COLORS: Record
= {
- open: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)]',
- in_progress: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]',
- waiting: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]',
- closed: 'bg-muted text-muted-foreground',
-}
+ open: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)]",
+ in_progress: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
+ waiting: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
+ closed: "bg-muted text-muted-foreground",
+};
const PRIORITY_COLORS: Record = {
- low: 'text-muted-foreground',
- normal: 'text-[var(--admin-info)]',
- high: 'text-[var(--admin-warning)]',
- urgent: 'text-[var(--admin-error)]',
-}
+ low: "text-muted-foreground",
+ normal: "text-[var(--admin-info)]",
+ high: "text-[var(--admin-warning)]",
+ urgent: "text-[var(--admin-error)]",
+};
export function AdminTicketDetail({
ticket,
@@ -77,28 +66,28 @@ export function AdminTicketDetail({
currentUserId,
canEdit,
}: {
- ticket: TicketInfo
- messages: TicketMessage[]
- staffMembers: StaffMember[]
- currentUserId: number
- canEdit: boolean
+ ticket: TicketInfo;
+ messages: TicketMessage[];
+ staffMembers: StaffMember[];
+ currentUserId: number;
+ canEdit: boolean;
}) {
- const [messages] = useState(initialMessages)
- const [reply, setReply] = useState('')
- const { run, isPending } = useServerAction()
- const messagesEndRef = useRef(null)
+ const [messages] = useState(initialMessages);
+ const [reply, setReply] = useState("");
+ const { run, isPending } = useServerAction();
+ const messagesEndRef = useRef(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- }, [messages])
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages]);
function handleReply() {
- if (!reply.trim() || isPending) return
+ if (!reply.trim() || isPending) return;
run(() => adminReplyTicket({ ticketId: ticket.id, message: reply.trim() }), {
- successMessage: 'Reply sent!',
- onSuccess: () => setReply(''),
- })
+ successMessage: "Reply sent!",
+ onSuccess: () => setReply(""),
+ });
}
function handleStatusChange(status: string) {
@@ -111,14 +100,14 @@ export function AdminTicketDetail({
{
successMessage: `Status changed to ${status}`,
},
- )
+ );
}
function handleAssign(assigneeId: string) {
- const id = assigneeId === 'unassign' ? null : Number(assigneeId)
+ const id = assigneeId === "unassign" ? null : Number(assigneeId);
run(() => assignTicket({ ticketId: ticket.id, assigneeId: id }), {
- successMessage: id ? 'Ticket assigned' : 'Ticket unassigned',
- })
+ successMessage: id ? "Ticket assigned" : "Ticket unassigned",
+ });
}
function handlePriorityChange(priority: string) {
@@ -131,10 +120,10 @@ export function AdminTicketDetail({
{
successMessage: `Priority changed to ${priority}`,
},
- )
+ );
}
- const isClosed = ticket.status === 'closed'
+ const isClosed = ticket.status === "closed";
return (
@@ -148,9 +137,7 @@ export function AdminTicketDetail({
#{ticket.id}
-
- {ticket.status.replace('_', ' ')}
-
+ {ticket.status.replace("_", " ")}
{ticket.priority}
@@ -180,11 +167,9 @@ export function AdminTicketDetail({
-
+
{msg.user.username}
- {msg.isStaff && ' (Staff)'}
+ {msg.isStaff && " (Staff)"}
{new Date(msg.createdAt).toLocaleString()}
@@ -192,7 +177,7 @@ export function AdminTicketDetail({
{msg.message}
@@ -215,9 +200,9 @@ export function AdminTicketDetail({
maxLength={5000}
className="resize-none"
onKeyDown={(e) => {
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
- e.preventDefault()
- handleReply()
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ handleReply();
}
}}
/>
@@ -227,11 +212,7 @@ export function AdminTicketDetail({
size="icon"
className="shrink-0 h-auto"
>
- {isPending ? (
-
- ) : (
-
- )}
+ {isPending ?
:
}
@@ -242,8 +223,7 @@ export function AdminTicketDetail({
{isClosed && (
- Ticket closed{' '}
- {ticket.closedAt ? `on ${new Date(ticket.closedAt).toLocaleDateString()}` : ''}
+ Ticket closed {ticket.closedAt ? `on ${new Date(ticket.closedAt).toLocaleDateString()}` : ""}
)}
@@ -267,7 +247,7 @@ export function AdminTicketDetail({
{TICKET_STATUSES.map((s) => (
- {s.replace('_', ' ')}
+ {s.replace("_", " ")}
))}
@@ -293,7 +273,7 @@ export function AdminTicketDetail({
Assign to
@@ -317,7 +297,7 @@ export function AdminTicketDetail({
size="sm"
className="w-full"
onClick={() => {
- handleAssign(String(currentUserId))
+ handleAssign(String(currentUserId));
}}
>
Assign to me
@@ -382,5 +362,5 @@ export function AdminTicketDetail({
- )
+ );
}
diff --git a/src/app/admin/tickets/[id]/page.tsx b/src/app/admin/tickets/[id]/page.tsx
index cec6dc3f..b3f6ce15 100644
--- a/src/app/admin/tickets/[id]/page.tsx
+++ b/src/app/admin/tickets/[id]/page.tsx
@@ -1,21 +1,17 @@
-import { notFound, redirect } from 'next/navigation'
-import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
-import { AdminTicketDetail } from './admin-ticket-detail'
+import { notFound, redirect } from "next/navigation";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+import { AdminTicketDetail } from "./admin-ticket-detail";
-export default async function AdminTicketDetailPage({
- params,
-}: {
- params: Promise<{ id: string }>
-}) {
- const { session, permissions } = await getAdminContext()
+export default async function AdminTicketDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank)) {
- redirect('/admin')
+ redirect("/admin");
}
- const { id } = await params
- const ticketId = Number(id)
- if (Number.isNaN(ticketId)) notFound()
+ const { id } = await params;
+ const ticketId = Number(id);
+ if (Number.isNaN(ticketId)) notFound();
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ticketId },
@@ -33,24 +29,24 @@ export default async function AdminTicketDetailPage({
},
assignee: { select: { id: true, username: true } },
messages: {
- orderBy: { createdAt: 'asc' },
+ orderBy: { createdAt: "asc" },
include: {
user: { select: { id: true, username: true, look: true, rank: true } },
},
},
},
- })
+ });
- if (!ticket) notFound()
+ if (!ticket) notFound();
// Get staff members for assignment
const staffMembers = await prisma.user.findMany({
where: { rank: { gte: 4 } },
select: { id: true, username: true },
- orderBy: { username: 'asc' },
- })
+ orderBy: { username: "asc" },
+ });
- const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank)
+ const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
return (
({
id: m.id,
@@ -91,5 +85,5 @@ export default async function AdminTicketDetailPage({
currentUserId={session.user.id}
canEdit={canEdit}
/>
- )
+ );
}
diff --git a/src/app/admin/tickets/page.tsx b/src/app/admin/tickets/page.tsx
index 835edaf6..514fcf23 100644
--- a/src/app/admin/tickets/page.tsx
+++ b/src/app/admin/tickets/page.tsx
@@ -1,51 +1,59 @@
-import { AlertCircle, CheckCircle2, Clock, Loader2, Ticket } from 'lucide-react'
-import Link from 'next/link'
-import { redirect } from 'next/navigation'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
+import { AlertCircle, CheckCircle2, Clock, Loader2, Ticket } from "lucide-react";
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
const STATUS_CONFIG = {
- open: { label: 'Open', icon: AlertCircle, class: 'bg-[var(--admin-info-subtle)] text-[var(--admin-info)]' },
- in_progress: { label: 'In Progress', icon: Loader2, class: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]' },
- waiting: { label: 'Waiting', icon: Clock, class: 'bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]' },
- closed: { label: 'Closed', icon: CheckCircle2, class: 'bg-muted text-muted-foreground' },
-} as const
+ open: { label: "Open", icon: AlertCircle, class: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)]" },
+ in_progress: {
+ label: "In Progress",
+ icon: Loader2,
+ class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
+ },
+ waiting: {
+ label: "Waiting",
+ icon: Clock,
+ class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
+ },
+ closed: { label: "Closed", icon: CheckCircle2, class: "bg-muted text-muted-foreground" },
+} as const;
const PRIORITY_COLORS = {
- low: '',
- normal: 'text-[var(--admin-info)]',
- high: 'text-[var(--admin-warning)]',
- urgent: 'text-[var(--admin-error)] font-bold',
-}
+ low: "",
+ normal: "text-[var(--admin-info)]",
+ high: "text-[var(--admin-warning)]",
+ urgent: "text-[var(--admin-error)] font-bold",
+};
interface PageProps {
- searchParams: Promise<{ status?: string; page?: string }>
+ searchParams: Promise<{ status?: string; page?: string }>;
}
export default async function AdminTicketsPage({ searchParams }: PageProps) {
- const { session, permissions } = await getAdminContext()
+ const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank)) {
- redirect('/admin')
+ redirect("/admin");
}
- const sp = await searchParams
- const statusFilter = sp.status || 'active'
- const page = Math.max(1, Number(sp.page) || 1)
- const perPage = 20
+ const sp = await searchParams;
+ const statusFilter = sp.status || "active";
+ const page = Math.max(1, Number(sp.page) || 1);
+ const perPage = 20;
const where =
- statusFilter === 'active'
- ? { status: { not: 'closed' } }
- : statusFilter === 'all'
+ statusFilter === "active"
+ ? { status: { not: "closed" } }
+ : statusFilter === "all"
? {}
- : { status: statusFilter }
+ : { status: statusFilter };
const [tickets, total, stats] = await Promise.all([
prisma.websiteTicket.findMany({
where,
- orderBy: [{ createdAt: 'desc' }],
+ orderBy: [{ createdAt: "desc" }],
skip: (page - 1) * perPage,
take: perPage,
include: {
@@ -56,15 +64,15 @@ export default async function AdminTicketsPage({ searchParams }: PageProps) {
}),
prisma.websiteTicket.count({ where }),
Promise.all([
- prisma.websiteTicket.count({ where: { status: 'open' } }),
- prisma.websiteTicket.count({ where: { status: 'in_progress' } }),
- prisma.websiteTicket.count({ where: { status: 'waiting' } }),
- prisma.websiteTicket.count({ where: { status: 'closed' } }),
+ prisma.websiteTicket.count({ where: { status: "open" } }),
+ prisma.websiteTicket.count({ where: { status: "in_progress" } }),
+ prisma.websiteTicket.count({ where: { status: "waiting" } }),
+ prisma.websiteTicket.count({ where: { status: "closed" } }),
]),
- ])
+ ]);
- const [openCount, inProgressCount, waitingCount, closedCount] = stats
- const totalPages = Math.ceil(total / perPage)
+ const [openCount, inProgressCount, waitingCount, closedCount] = stats;
+ const totalPages = Math.ceil(total / perPage);
return (
@@ -112,18 +120,15 @@ export default async function AdminTicketsPage({ searchParams }: PageProps) {
{/* Filter tabs */}
{[
- { key: 'active', label: 'Active' },
- { key: 'open', label: 'Open' },
- { key: 'in_progress', label: 'In Progress' },
- { key: 'waiting', label: 'Waiting' },
- { key: 'closed', label: 'Closed' },
- { key: 'all', label: 'All' },
+ { key: "active", label: "Active" },
+ { key: "open", label: "Open" },
+ { key: "in_progress", label: "In Progress" },
+ { key: "waiting", label: "Waiting" },
+ { key: "closed", label: "Closed" },
+ { key: "all", label: "All" },
].map(({ key, label }) => (
-
+
{label}
@@ -147,9 +152,8 @@ export default async function AdminTicketsPage({ searchParams }: PageProps) {
) : (
tickets.map((ticket) => {
- const status =
- STATUS_CONFIG[ticket.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.open
- const StatusIcon = status.icon
+ const status = STATUS_CONFIG[ticket.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.open;
+ const StatusIcon = status.icon;
return (
{ticket.subject}
- by {ticket.creator.username} · {ticket._count.messages} msg ·{' '}
+ by {ticket.creator.username} · {ticket._count.messages} msg ·{" "}
{ticket.category}
@@ -174,18 +178,18 @@ export default async function AdminTicketsPage({ searchParams }: PageProps) {
{status.label}
{ticket.priority}
- {ticket.assignee?.username ?? '—'}
+ {ticket.assignee?.username ?? "—"}
{new Date(ticket.createdAt).toLocaleDateString()}
- )
+ );
})
)}
@@ -213,5 +217,5 @@ export default async function AdminTicketsPage({ searchParams }: PageProps) {
)}
- )
+ );
}
diff --git a/src/app/admin/tickets/templates/page.tsx b/src/app/admin/tickets/templates/page.tsx
index f2082940..affe1175 100644
--- a/src/app/admin/tickets/templates/page.tsx
+++ b/src/app/admin/tickets/templates/page.tsx
@@ -1,17 +1,17 @@
-import { redirect } from 'next/navigation'
-import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
-import { TicketTemplatesManager } from './templates-manager'
+import { redirect } from "next/navigation";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+import { TicketTemplatesManager } from "./templates-manager";
export default async function TicketTemplatesPage() {
- const { session, permissions } = await getAdminContext()
+ const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank)) {
- redirect('/admin/tickets')
+ redirect("/admin/tickets");
}
const templates = await prisma.websiteTicketTemplate.findMany({
- orderBy: { sortOrder: 'asc' },
- })
+ orderBy: { sortOrder: "asc" },
+ });
return (
@@ -21,5 +21,5 @@ export default async function TicketTemplatesPage() {
- )
+ );
}
diff --git a/src/app/admin/tickets/templates/templates-manager.tsx b/src/app/admin/tickets/templates/templates-manager.tsx
index 528e49f3..968e51b0 100644
--- a/src/app/admin/tickets/templates/templates-manager.tsx
+++ b/src/app/admin/tickets/templates/templates-manager.tsx
@@ -1,54 +1,54 @@
-'use client'
+"use client";
-import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
-import { useState } from 'react'
-import { createTemplate, deleteTemplate, updateTemplate } from '@/actions/ticket-templates'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { useServerAction } from '@/hooks/use-server-action'
+import { Copy, Pencil, Plus, Trash2 } from "lucide-react";
+import { useState } from "react";
+import { createTemplate, deleteTemplate, updateTemplate } from "@/actions/ticket-templates";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useServerAction } from "@/hooks/use-server-action";
interface Template {
- id: number
- title: string
- content: string
- category: string
- sortOrder: number
+ id: number;
+ title: string;
+ content: string;
+ category: string;
+ sortOrder: number;
}
interface Props {
- templates: Template[]
+ templates: Template[];
}
export function TicketTemplatesManager({ templates }: Props) {
- const { run, isPending } = useServerAction()
- const [showForm, setShowForm] = useState(false)
- const [editingId, setEditingId] = useState(null)
- const [form, setForm] = useState({ title: '', content: '', category: 'general', sortOrder: 0 })
+ const { run, isPending } = useServerAction();
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState({ title: "", content: "", category: "general", sortOrder: 0 });
function resetForm() {
- setForm({ title: '', content: '', category: 'general', sortOrder: 0 })
- setEditingId(null)
- setShowForm(false)
+ setForm({ title: "", content: "", category: "general", sortOrder: 0 });
+ setEditingId(null);
+ setShowForm(false);
}
function handleEdit(t: Template) {
- setForm({ title: t.title, content: t.content, category: t.category, sortOrder: t.sortOrder })
- setEditingId(t.id)
- setShowForm(true)
+ setForm({ title: t.title, content: t.content, category: t.category, sortOrder: t.sortOrder });
+ setEditingId(t.id);
+ setShowForm(true);
}
function handleSubmit(e: React.FormEvent) {
- e.preventDefault()
+ e.preventDefault();
if (editingId) {
run(() => updateTemplate({ id: editingId, ...form }), {
- successMessage: 'Template updated!',
+ successMessage: "Template updated!",
onSuccess: resetForm,
- })
+ });
} else {
- run(() => createTemplate(form), { successMessage: 'Template created!', onSuccess: resetForm })
+ run(() => createTemplate(form), { successMessage: "Template created!", onSuccess: resetForm });
}
}
@@ -57,8 +57,8 @@ export function TicketTemplatesManager({ templates }: Props) {
{
- resetForm()
- setShowForm(!showForm)
+ resetForm();
+ setShowForm(!showForm);
}}
>
New Template
@@ -68,7 +68,7 @@ export function TicketTemplatesManager({ templates }: Props) {
{showForm && (
- {editingId ? 'Edit Template' : 'New Template'}
+ {editingId ? "Edit Template" : "New Template"}
- {editingId ? 'Update' : 'Create'}
+ {editingId ? "Update" : "Create"}
@@ -143,21 +143,14 @@ export function TicketTemplatesManager({ templates }: Props) {
>
- handleEdit(t)}
- >
+ handleEdit(t)}>
- run(() => deleteTemplate({ id: t.id }), { successMessage: 'Deleted' })
- }
+ onClick={() => run(() => deleteTemplate({ id: t.id }), { successMessage: "Deleted" })}
>
@@ -168,5 +161,5 @@ export function TicketTemplatesManager({ templates }: Props) {
))}
- )
+ );
}
diff --git a/src/app/admin/translations/client/client-translations.tsx b/src/app/admin/translations/client/client-translations.tsx
index ceed5014..0b27e63c 100644
--- a/src/app/admin/translations/client/client-translations.tsx
+++ b/src/app/admin/translations/client/client-translations.tsx
@@ -1,94 +1,88 @@
-'use client'
+"use client";
-import { Download, Gamepad2, Info, Lock, Save } from 'lucide-react'
-import { usePathname, useRouter, useSearchParams } from 'next/navigation'
-import { useEffect, useMemo, useState } from 'react'
-import { toast } from 'sonner'
-import { saveClientTranslations } from '@/actions/translations'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { useServerAction } from '@/hooks/use-server-action'
-import type { ClientTranslationFile } from '@/lib/client-translation-files'
+import { Download, Gamepad2, Info, Lock, Save } from "lucide-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { saveClientTranslations } from "@/actions/translations";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useServerAction } from "@/hooks/use-server-action";
+import type { ClientTranslationFile } from "@/lib/client-translation-files";
interface Props {
- files: ClientTranslationFile[]
- activeFile: ClientTranslationFile
- entries: Record
- parseError: string | null
+ files: ClientTranslationFile[];
+ activeFile: ClientTranslationFile;
+ entries: Record;
+ parseError: string | null;
}
export function ClientTranslations({ files, activeFile, entries, parseError }: Props) {
- const router = useRouter()
- const pathname = usePathname()
- const searchParams = useSearchParams()
- const [data, setData] = useState>(entries)
- const [search, setSearch] = useState('')
- const [page, setPage] = useState(1)
- const { run, isPending } = useServerAction()
- const PER_PAGE = 50
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [data, setData] = useState>(entries);
+ const [search, setSearch] = useState("");
+ const [page, setPage] = useState(1);
+ const { run, isPending } = useServerAction();
+ const PER_PAGE = 50;
// Re-sync local state when the user switches file (server re-renders with
// fresh entries; useState only initializes once).
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
- setData(entries)
- setSearch('')
- setPage(1)
- }, [entries, activeFile.id])
+ setData(entries);
+ setSearch("");
+ setPage(1);
+ }, [entries, activeFile.id]);
const filteredKeys = useMemo(() => {
- const keys = Object.keys(data)
- if (!search) return keys
- const q = search.toLowerCase()
- return keys.filter((k) => k.toLowerCase().includes(q) || data[k].toLowerCase().includes(q))
- }, [data, search])
+ const keys = Object.keys(data);
+ if (!search) return keys;
+ const q = search.toLowerCase();
+ return keys.filter((k) => k.toLowerCase().includes(q) || data[k].toLowerCase().includes(q));
+ }, [data, search]);
- const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE))
- const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE)
+ const totalPages = Math.max(1, Math.ceil(filteredKeys.length / PER_PAGE));
+ const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE);
function changeFile(id: string) {
- const params = new URLSearchParams(searchParams?.toString())
- params.set('file', id)
- router.push(`${pathname}?${params.toString()}`)
+ const params = new URLSearchParams(searchParams?.toString());
+ params.set("file", id);
+ router.push(`${pathname}?${params.toString()}`);
}
function updateValue(key: string, value: string) {
- setData((d) => ({ ...d, [key]: value }))
+ setData((d) => ({ ...d, [key]: value }));
}
function handleSave() {
- if (activeFile.readOnly) return
+ if (activeFile.readOnly) return;
run(() => saveClientTranslations({ fileId: activeFile.id, data }), {
- successMessage: 'File salvato.',
+ successMessage: "File salvato.",
onSuccess: (result) => {
if (result?.commentsLost) {
toast.warning(
- 'Alcune chiavi non corrispondevano al formato standard: file riserializzato e commenti rimossi.',
- )
+ "Alcune chiavi non corrispondevano al formato standard: file riserializzato e commenti rimossi.",
+ );
}
- router.refresh()
+ router.refresh();
},
- })
+ });
}
function handleExport() {
const blob = new Blob([JSON.stringify(data, null, 2)], {
- type: 'application/json',
- })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = activeFile.relPath.split('/').pop() ?? 'translations.json'
- a.click()
- URL.revokeObjectURL(url)
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = activeFile.relPath.split("/").pop() ?? "translations.json";
+ a.click();
+ URL.revokeObjectURL(url);
}
return (
@@ -102,12 +96,8 @@ export function ClientTranslations({ files, activeFile, entries, parseError }: P
Export
-
- {isPending ? 'Salvataggio...' : 'Salva'}
+
+ {isPending ? "Salvataggio..." : "Salva"}
@@ -121,10 +111,8 @@ export function ClientTranslations({ files, activeFile, entries, parseError }: P
{files.map((f) => (
- {f.relPath.replace('public/', '')}
- {f.readOnly && (
- RO
- )}
+ {f.relPath.replace("public/", "")}
+ {f.readOnly && RO }
))}
@@ -135,8 +123,8 @@ export function ClientTranslations({ files, activeFile, entries, parseError }: P
placeholder="Cerca per chiave o valore..."
value={search}
onChange={(e) => {
- setSearch(e.target.value)
- setPage(1)
+ setSearch(e.target.value);
+ setPage(1);
}}
className="max-w-xs"
/>
@@ -149,7 +137,7 @@ export function ClientTranslations({ files, activeFile, entries, parseError }: P
{activeFile.format.toUpperCase()}
- {activeFile.language === 'shared' ? 'shared' : activeFile.language.toUpperCase()}
+ {activeFile.language === "shared" ? "shared" : activeFile.language.toUpperCase()}
{activeFile.readOnly && (
@@ -173,8 +161,8 @@ export function ClientTranslations({ files, activeFile, entries, parseError }: P
- Questo file è di sola lettura.{' '}
- {activeFile.note ?? 'Modificarlo manualmente non è supportato dal CMS.'}
+ Questo file è di sola lettura.{" "}
+ {activeFile.note ?? "Modificarlo manualmente non è supportato dal CMS."}
)}
@@ -183,9 +171,9 @@ export function ClientTranslations({ files, activeFile, entries, parseError }: P
- I commenti vengono preservati: il salvataggio modifica solo le righe dei valori
- cambiati. Se una chiave usa un formato non standard ed è impossibile patchare in-place,
- il file viene riserializzato (commenti persi) e ti avviseremo.
+ I commenti vengono preservati: il salvataggio modifica solo le righe dei valori cambiati. Se una
+ chiave usa un formato non standard ed è impossibile patchare in-place, il file viene
+ riserializzato (commenti persi) e ti avviseremo.
)}
@@ -202,10 +190,7 @@ export function ClientTranslations({ files, activeFile, entries, parseError }: P
)}
{pageKeys.map((key) => (
-
+
{key}
- setPage(page - 1)}
- >
+ setPage(page - 1)}>
Prev
)}
- )
+ );
}
diff --git a/src/app/admin/translations/client/page.tsx b/src/app/admin/translations/client/page.tsx
index d76389ef..ef3c2904 100644
--- a/src/app/admin/translations/client/page.tsx
+++ b/src/app/admin/translations/client/page.tsx
@@ -1,43 +1,40 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import JSON5 from 'json5'
-import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files'
-import { ClientTranslations } from './client-translations'
+import fs from "node:fs/promises";
+import path from "node:path";
+import JSON5 from "json5";
+import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
+import { ClientTranslations } from "./client-translations";
export default async function ClientTranslationsPage({
searchParams,
}: {
- searchParams: Promise>
+ searchParams: Promise>;
}) {
- const sp = await searchParams
- const requested = sp.file
+ const sp = await searchParams;
+ const requested = sp.file;
const file =
(requested && getClientTranslationFile(requested)) ||
CLIENT_TRANSLATION_FILES.find((f) => !f.readOnly) ||
- CLIENT_TRANSLATION_FILES[0]
+ CLIENT_TRANSLATION_FILES[0];
- const entries: Record = {}
- let parseError: string | null = null
+ const entries: Record = {};
+ let parseError: string | null = null;
try {
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's tracer can't prove that — hint avoids tracing the whole project.
- const raw = await fs.readFile(
- path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath),
- 'utf-8',
- )
- const parsed = file.format === 'json5' ? JSON5.parse(raw) : JSON.parse(raw)
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ const raw = await fs.readFile(path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath), "utf-8");
+ const parsed = file.format === "json5" ? JSON5.parse(raw) : JSON.parse(raw);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
// Nitro localization files are flat: { "key.with.dots": "value" }.
// Coerce values to strings so the editor can render them in .
for (const [k, v] of Object.entries(parsed)) {
- entries[k] = v == null ? '' : String(v)
+ entries[k] = v == null ? "" : String(v);
}
} else {
- parseError = 'File root is not an object.'
+ parseError = "File root is not an object.";
}
} catch (err) {
- parseError = err instanceof Error ? err.message : 'Failed to read file.'
+ parseError = err instanceof Error ? err.message : "Failed to read file.";
}
return (
@@ -47,5 +44,5 @@ export default async function ClientTranslationsPage({
entries={entries}
parseError={parseError}
/>
- )
+ );
}
diff --git a/src/app/admin/translations/cms/page.tsx b/src/app/admin/translations/cms/page.tsx
index 6d7cea9f..0a2f4de7 100644
--- a/src/app/admin/translations/cms/page.tsx
+++ b/src/app/admin/translations/cms/page.tsx
@@ -1,11 +1,11 @@
-import fs from 'node:fs/promises'
-import path from 'node:path'
-import { TranslationsClient } from '../translations-client'
+import fs from "node:fs/promises";
+import path from "node:path";
+import { TranslationsClient } from "../translations-client";
export default async function CmsTranslationsPage() {
- const messagesDir = path.join(process.cwd(), 'messages')
- const enContent = await fs.readFile(path.join(messagesDir, 'en.json'), 'utf-8')
- const itContent = await fs.readFile(path.join(messagesDir, 'it.json'), 'utf-8')
+ const messagesDir = path.join(process.cwd(), "messages");
+ const enContent = await fs.readFile(path.join(messagesDir, "en.json"), "utf-8");
+ const itContent = await fs.readFile(path.join(messagesDir, "it.json"), "utf-8");
- return
+ return ;
}
diff --git a/src/app/admin/translations/emulator/page.tsx b/src/app/admin/translations/emulator/page.tsx
index 00751f32..0af00e3c 100644
--- a/src/app/admin/translations/emulator/page.tsx
+++ b/src/app/admin/translations/emulator/page.tsx
@@ -1,55 +1,55 @@
-import { ExternalLink, Server } from 'lucide-react'
-import Link from 'next/link'
-import { EmulatorSettingsForm } from '@/components/admin/emulator-settings-form'
-import { Button } from '@/components/ui/button'
-import type { Prisma } from '@/generated/prisma/client'
-import { calcPagination } from '@/lib/admin-helpers'
-import { prisma } from '@/lib/prisma'
+import { ExternalLink, Server } from "lucide-react";
+import Link from "next/link";
+import { EmulatorSettingsForm } from "@/components/admin/emulator-settings-form";
+import { Button } from "@/components/ui/button";
+import type { Prisma } from "@/generated/prisma/client";
+import { calcPagination } from "@/lib/admin-helpers";
+import { prisma } from "@/lib/prisma";
// SQL `LIKE` patterns that capture the user-facing strings inside
// `emulator_settings`. Anything not matching is config (timeouts, ports, IDs)
// and lives in /admin/settings/emulator instead.
const TRANSLATION_KEY_PATTERNS = [
- 'commands.description.%',
- 'commands.keys.%',
- 'commands.cmd_%',
- 'wordfilter.%',
- 'pet.%message%',
- 'matchmaking.%message%',
- 'message.%',
- 'notification.%',
-] as const
+ "commands.description.%",
+ "commands.keys.%",
+ "commands.cmd_%",
+ "wordfilter.%",
+ "pet.%message%",
+ "matchmaking.%message%",
+ "message.%",
+ "notification.%",
+] as const;
export default async function EmulatorTranslationsPage({
searchParams,
}: {
- searchParams: Promise>
+ searchParams: Promise>;
}) {
- const sp = await searchParams
- const search = sp.search || ''
- const group = sp.group || ''
- const perPage = Math.min(Math.max(parseInt(sp.perPage || '50', 10), 10), 200)
- const page = Math.max(parseInt(sp.page || '1', 10), 1)
+ const sp = await searchParams;
+ const search = sp.search || "";
+ const group = sp.group || "";
+ const perPage = Math.min(Math.max(parseInt(sp.perPage || "50", 10), 10), 200);
+ const page = Math.max(parseInt(sp.page || "1", 10), 1);
const patternFilters: Prisma.EmulatorSettingsWhereInput[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
- key: { contains: p.replace(/%/g, '') },
- }))
+ key: { contains: p.replace(/%/g, "") },
+ }));
- const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters }
+ const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters };
- const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter]
- if (group) conditions.push({ key: { startsWith: `${group}.` } })
+ const conditions: Prisma.EmulatorSettingsWhereInput[] = [baseFilter];
+ if (group) conditions.push({ key: { startsWith: `${group}.` } });
if (search) {
conditions.push({
OR: [{ key: { contains: search } }, { value: { contains: search } }],
- })
+ });
}
- const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions }
+ const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions };
const [settings, total, allKeys] = await Promise.all([
prisma.emulatorSettings.findMany({
where,
- orderBy: { key: 'asc' },
+ orderBy: { key: "asc" },
skip: (page - 1) * perPage,
take: perPage,
}),
@@ -60,19 +60,19 @@ export default async function EmulatorTranslationsPage({
where: baseFilter,
select: { key: true },
}),
- ])
+ ]);
- const groupCounts = new Map()
+ const groupCounts = new Map();
for (const { key } of allKeys) {
- const g = key.includes('.') ? key.split('.')[0] : 'general'
- groupCounts.set(g, (groupCounts.get(g) ?? 0) + 1)
+ const g = key.includes(".") ? key.split(".")[0] : "general";
+ groupCounts.set(g, (groupCounts.get(g) ?? 0) + 1);
}
const allGroups = Array.from(groupCounts.entries())
.map(([name, count]) => ({ name, count }))
- .sort((a, b) => a.name.localeCompare(b.name))
+ .sort((a, b) => a.name.localeCompare(b.name));
- const pagination = calcPagination(total, page, perPage)
- const settingsMap = settings.map((s) => ({ key: s.key, value: s.value }))
+ const pagination = calcPagination(total, page, perPage);
+ const settingsMap = settings.map((s) => ({ key: s.key, value: s.value }));
return (
@@ -83,8 +83,8 @@ export default async function EmulatorTranslationsPage({
Emulator
- Sottoinsieme di emulator_settings con stringhe utente
- (comandi, wordfilter, messaggi pet/matchmaking).
+ Sottoinsieme di emulator_settings con stringhe utente (comandi,
+ wordfilter, messaggi pet/matchmaking).
@@ -96,9 +96,8 @@ export default async function EmulatorTranslationsPage({
- Le impostazioni di configurazione (porte, timeout, ID) non compaiono qui — usa il link
- «Impostazioni complete». Le modifiche salvate sono sincronizzate con l'emulatore via
- RCON.
+ Le impostazioni di configurazione (porte, timeout, ID) non compaiono qui — usa il link «Impostazioni
+ complete». Le modifiche salvate sono sincronizzate con l'emulatore via RCON.
- )
+ );
}
diff --git a/src/app/admin/translations/layout.tsx b/src/app/admin/translations/layout.tsx
index 0ac62c83..b3b879b4 100644
--- a/src/app/admin/translations/layout.tsx
+++ b/src/app/admin/translations/layout.tsx
@@ -1,11 +1,11 @@
-import { redirect } from 'next/navigation'
-import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
-import { TranslationsTabs } from './translations-tabs'
+import { redirect } from "next/navigation";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+import { TranslationsTabs } from "./translations-tabs";
export default async function TranslationsLayout({ children }: { children: React.ReactNode }) {
- const { session, permissions } = await getAdminContext()
+ const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
- redirect('/admin')
+ redirect("/admin");
}
return (
@@ -13,5 +13,5 @@ export default async function TranslationsLayout({ children }: { children: React
{children}
- )
+ );
}
diff --git a/src/app/admin/translations/loading.tsx b/src/app/admin/translations/loading.tsx
index db84d68c..3d5db003 100644
--- a/src/app/admin/translations/loading.tsx
+++ b/src/app/admin/translations/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/ui/skeleton'
+import { Skeleton } from "@/components/ui/skeleton";
export default function TranslationsLoading() {
return (
@@ -24,5 +24,5 @@ export default function TranslationsLoading() {
))}
- )
+ );
}
diff --git a/src/app/admin/translations/page.tsx b/src/app/admin/translations/page.tsx
index 36de5a05..1120d0d5 100644
--- a/src/app/admin/translations/page.tsx
+++ b/src/app/admin/translations/page.tsx
@@ -1,7 +1,7 @@
-import { redirect } from 'next/navigation'
+import { redirect } from "next/navigation";
// Default landing for /admin/translations → first tab.
// The shared TranslationsTabs (in layout.tsx) gives access to every section.
export default function TranslationsHubPage() {
- redirect('/admin/translations/cms')
+ redirect("/admin/translations/cms");
}
diff --git a/src/app/admin/translations/translations-client.tsx b/src/app/admin/translations/translations-client.tsx
index 1d8b7234..0a7ae74a 100644
--- a/src/app/admin/translations/translations-client.tsx
+++ b/src/app/admin/translations/translations-client.tsx
@@ -1,74 +1,74 @@
-'use client'
+"use client";
-import { Download, Globe, Save } from 'lucide-react'
-import { useMemo, useState } from 'react'
-import { saveTranslations } from '@/actions/translations'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { useServerAction } from '@/hooks/use-server-action'
-import { flattenObject, unflattenObject } from '@/lib/translations-utils'
+import { Download, Globe, Save } from "lucide-react";
+import { useMemo, useState } from "react";
+import { saveTranslations } from "@/actions/translations";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useServerAction } from "@/hooks/use-server-action";
+import { flattenObject, unflattenObject } from "@/lib/translations-utils";
interface Props {
- enJson: string
- itJson: string
+ enJson: string;
+ itJson: string;
}
export function TranslationsClient({ enJson, itJson }: Props) {
- const [en, setEn] = useState(() => flattenObject(JSON.parse(enJson)))
- const [it, setIt] = useState(() => flattenObject(JSON.parse(itJson)))
- const [search, setSearch] = useState('')
- const [group, setGroup] = useState('')
- const [activeLang, setActiveLang] = useState<'en' | 'it'>('en')
- const { run, isPending } = useServerAction()
- const PER_PAGE = 30
- const [page, setPage] = useState(1)
+ const [en, setEn] = useState(() => flattenObject(JSON.parse(enJson)));
+ const [it, setIt] = useState(() => flattenObject(JSON.parse(itJson)));
+ const [search, setSearch] = useState("");
+ const [group, setGroup] = useState("");
+ const [activeLang, setActiveLang] = useState<"en" | "it">("en");
+ const { run, isPending } = useServerAction();
+ const PER_PAGE = 30;
+ const [page, setPage] = useState(1);
- const activeData = activeLang === 'en' ? en : it
- const setActiveData = activeLang === 'en' ? setEn : setIt
+ const activeData = activeLang === "en" ? en : it;
+ const setActiveData = activeLang === "en" ? setEn : setIt;
const groups = useMemo(() => {
- const g = new Set()
+ const g = new Set();
Object.keys(en).forEach((k) => {
- const parts = k.split('.')
- if (parts.length > 1) g.add(parts[0])
- })
- return Array.from(g).sort()
- }, [en])
+ const parts = k.split(".");
+ if (parts.length > 1) g.add(parts[0]);
+ });
+ return Array.from(g).sort();
+ }, [en]);
const filteredKeys = useMemo(() => {
return Object.keys(activeData).filter((key) => {
const matchSearch =
!search ||
key.toLowerCase().includes(search.toLowerCase()) ||
- activeData[key].toLowerCase().includes(search.toLowerCase())
- const matchGroup = !group || key.startsWith(`${group}.`)
- return matchSearch && matchGroup
- })
- }, [activeData, search, group])
+ activeData[key].toLowerCase().includes(search.toLowerCase());
+ const matchGroup = !group || key.startsWith(`${group}.`);
+ return matchSearch && matchGroup;
+ });
+ }, [activeData, search, group]);
- const totalPages = Math.ceil(filteredKeys.length / PER_PAGE)
- const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE)
+ const totalPages = Math.ceil(filteredKeys.length / PER_PAGE);
+ const pageKeys = filteredKeys.slice((page - 1) * PER_PAGE, page * PER_PAGE);
function updateValue(key: string, value: string) {
- setActiveData({ ...activeData, [key]: value })
+ setActiveData({ ...activeData, [key]: value });
}
function handleSave() {
run(() => saveTranslations({ locale: activeLang, data: unflattenObject(activeData) }), {
- successMessage: 'Translations saved!',
- })
+ successMessage: "Translations saved!",
+ });
}
function handleExport() {
const blob = new Blob([JSON.stringify(unflattenObject(activeData), null, 2)], {
- type: 'application/json',
- })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `${activeLang}.json`
- a.click()
- URL.revokeObjectURL(url)
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${activeLang}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
}
return (
@@ -83,7 +83,7 @@ export function TranslationsClient({ enJson, itJson }: Props) {
Export
- {isPending ? 'Saving...' : 'Save'}
+ {isPending ? "Saving..." : "Save"}
@@ -91,16 +91,16 @@ export function TranslationsClient({ enJson, itJson }: Props) {
setActiveLang('en')}
+ onClick={() => setActiveLang("en")}
>
EN
setActiveLang('it')}
+ onClick={() => setActiveLang("it")}
>
IT
@@ -109,8 +109,8 @@ export function TranslationsClient({ enJson, itJson }: Props) {
placeholder="Filter by key or value..."
value={search}
onChange={(e) => {
- setSearch(e.target.value)
- setPage(1)
+ setSearch(e.target.value);
+ setPage(1);
}}
className="max-w-xs"
/>
@@ -118,8 +118,8 @@ export function TranslationsClient({ enJson, itJson }: Props) {
className="rounded-md border bg-background px-3 py-1 text-sm"
value={group}
onChange={(e) => {
- setGroup(e.target.value)
- setPage(1)
+ setGroup(e.target.value);
+ setPage(1);
}}
>
All groups
@@ -129,17 +129,13 @@ export function TranslationsClient({ enJson, itJson }: Props) {
))}
-
- {filteredKeys.length} keys
-
+ {filteredKeys.length} keys
{pageKeys.map((key) => (
-
- {key}
-
+
{key}
updateValue(key, e.target.value)}
@@ -155,12 +151,7 @@ export function TranslationsClient({ enJson, itJson }: Props) {
Page {page} of {totalPages}
- setPage(page - 1)}
- >
+ setPage(page - 1)}>
Prev
)}
- )
+ );
}
diff --git a/src/app/admin/translations/translations-tabs.tsx b/src/app/admin/translations/translations-tabs.tsx
index 9d0d7b26..3dafa82c 100644
--- a/src/app/admin/translations/translations-tabs.tsx
+++ b/src/app/admin/translations/translations-tabs.tsx
@@ -1,40 +1,40 @@
-'use client'
+"use client";
-import { Gamepad2, Globe, Server } from 'lucide-react'
-import Link from 'next/link'
-import { usePathname } from 'next/navigation'
-import { cn } from '@/lib/utils'
+import { Gamepad2, Globe, Server } from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
const TABS = [
- { href: '/admin/translations/cms', label: 'CMS', icon: Globe },
- { href: '/admin/translations/client', label: 'Client (Nitro)', icon: Gamepad2 },
- { href: '/admin/translations/emulator', label: 'Emulator', icon: Server },
-] as const
+ { href: "/admin/translations/cms", label: "CMS", icon: Globe },
+ { href: "/admin/translations/client", label: "Client (Nitro)", icon: Gamepad2 },
+ { href: "/admin/translations/emulator", label: "Emulator", icon: Server },
+] as const;
export function TranslationsTabs() {
- const pathname = usePathname()
+ const pathname = usePathname();
return (
{TABS.map((t) => {
- const active = pathname === t.href || pathname.startsWith(`${t.href}/`)
- const Icon = t.icon
+ const active = pathname === t.href || pathname.startsWith(`${t.href}/`);
+ const Icon = t.icon;
return (
{t.label}
- )
+ );
})}
- )
+ );
}
diff --git a/src/app/admin/users/[id]/loading.tsx b/src/app/admin/users/[id]/loading.tsx
index 20d056ba..4ff4adc0 100644
--- a/src/app/admin/users/[id]/loading.tsx
+++ b/src/app/admin/users/[id]/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/ui/skeleton'
+import { Skeleton } from "@/components/ui/skeleton";
export default function UserDetailLoading() {
return (
@@ -46,5 +46,5 @@ export default function UserDetailLoading() {
- )
+ );
}
diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx
index e7d558cd..0603c456 100644
--- a/src/app/admin/users/[id]/page.tsx
+++ b/src/app/admin/users/[id]/page.tsx
@@ -1,11 +1,11 @@
-import { redirect } from 'next/navigation'
+import { redirect } from "next/navigation";
/** Legacy route — redirects to /admin/users/show/[id] */
export default async function UserLegacyPage({
params,
}: {
- params: Promise<{ locale: string; id: string }>
+ params: Promise<{ locale: string; id: string }>;
}) {
- const { id } = await params
- redirect(`/admin/users/show/${id}`)
+ const { id } = await params;
+ redirect(`/admin/users/show/${id}`);
}
diff --git a/src/app/admin/users/[id]/user-activity-log.tsx b/src/app/admin/users/[id]/user-activity-log.tsx
index a545df83..e9ce7d6b 100644
--- a/src/app/admin/users/[id]/user-activity-log.tsx
+++ b/src/app/admin/users/[id]/user-activity-log.tsx
@@ -1,37 +1,37 @@
-import { MessageSquare, Terminal } from 'lucide-react'
-import Link from 'next/link'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { MessageSquare, Terminal } from "lucide-react";
+import Link from "next/link";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ChatMessage {
- id: number
- roomId: number
- timestamp: number
- message: string
+ id: number;
+ roomId: number;
+ timestamp: number;
+ message: string;
}
interface CommandEntry {
- id: number
- timestamp: number
- command: string
- params: string
- succes: string
+ id: number;
+ timestamp: number;
+ command: string;
+ params: string;
+ succes: string;
}
interface UserActivityLogProps {
- chatLogs: ChatMessage[]
- commandLogs: CommandEntry[]
+ chatLogs: ChatMessage[];
+ commandLogs: CommandEntry[];
}
function formatTime(ts: number) {
- if (!ts) return '-'
- const d = new Date(ts * 1000)
+ if (!ts) return "-";
+ const d = new Date(ts * 1000);
return d.toLocaleString(undefined, {
- day: '2-digit',
- month: 'short',
- hour: '2-digit',
- minute: '2-digit',
- })
+ day: "2-digit",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
}
export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps) {
@@ -59,9 +59,7 @@ export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps)
>
Room #{log.roomId}
-
- {formatTime(log.timestamp)}
-
+ {formatTime(log.timestamp)}
{log.message}
@@ -90,14 +88,12 @@ export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps)
:{log.command}
- {log.succes === 'yes' ? 'OK' : 'FAIL'}
+ {log.succes === "yes" ? "OK" : "FAIL"}
-
- {formatTime(log.timestamp)}
-
+ {formatTime(log.timestamp)}
{log.params && (
@@ -110,5 +106,5 @@ export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps)
- )
+ );
}
diff --git a/src/app/admin/users/[id]/user-badges-section.tsx b/src/app/admin/users/[id]/user-badges-section.tsx
index 1ecc88a6..914a0d05 100644
--- a/src/app/admin/users/[id]/user-badges-section.tsx
+++ b/src/app/admin/users/[id]/user-badges-section.tsx
@@ -1,39 +1,39 @@
-'use client'
+"use client";
-import { Plus, X } from 'lucide-react'
-import { useState } from 'react'
-import { giveBadge, removeBadge } from '@/actions/users'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { useServerAction } from '@/hooks/use-server-action'
+import { Plus, X } from "lucide-react";
+import { useState } from "react";
+import { giveBadge, removeBadge } from "@/actions/users";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { useServerAction } from "@/hooks/use-server-action";
interface BadgeInfo {
- id: number
- badgeCode: string
+ id: number;
+ badgeCode: string;
}
interface UserBadgesSectionProps {
- userId: number
- badges: BadgeInfo[]
+ userId: number;
+ badges: BadgeInfo[];
}
export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
- const { isPending, run } = useServerAction()
- const [newBadge, setNewBadge] = useState('')
+ const { isPending, run } = useServerAction();
+ const [newBadge, setNewBadge] = useState("");
function handleAddBadge() {
- if (!newBadge.trim()) return
+ if (!newBadge.trim()) return;
run(() => giveBadge({ userId, badgeCode: newBadge.trim() }), {
successMessage: `Badge "${newBadge.trim()}" added.`,
- onSuccess: () => setNewBadge(''),
- })
+ onSuccess: () => setNewBadge(""),
+ });
}
function handleRemoveBadge(badgeCode: string) {
run(() => removeBadge({ userId, badgeCode }), {
successMessage: `Badge "${badgeCode}" removed.`,
- })
+ });
}
return (
@@ -47,7 +47,7 @@ export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
value={newBadge}
onChange={(e) => setNewBadge(e.target.value)}
placeholder="Badge code..."
- onKeyDown={(e) => e.key === 'Enter' && handleAddBadge()}
+ onKeyDown={(e) => e.key === "Enter" && handleAddBadge()}
/>
@@ -69,7 +69,7 @@ export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
alt={badge.badgeCode}
className="h-5 w-5"
onError={(e) => {
- ;(e.target as HTMLImageElement).style.display = 'none'
+ (e.target as HTMLImageElement).style.display = "none";
}}
/>
{badge.badgeCode}
@@ -88,5 +88,5 @@ export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
- )
+ );
}
diff --git a/src/app/admin/users/[id]/user-ban-section.tsx b/src/app/admin/users/[id]/user-ban-section.tsx
index 2ced67a7..3cb0d17a 100644
--- a/src/app/admin/users/[id]/user-ban-section.tsx
+++ b/src/app/admin/users/[id]/user-ban-section.tsx
@@ -1,49 +1,43 @@
-'use client'
+"use client";
-import { Ban, ShieldOff } from 'lucide-react'
-import { useState } from 'react'
-import { banUser, unbanUser } from '@/actions/users'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { Textarea } from '@/components/ui/textarea'
-import { useServerAction } from '@/hooks/use-server-action'
+import { Ban, ShieldOff } from "lucide-react";
+import { useState } from "react";
+import { banUser, unbanUser } from "@/actions/users";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { useServerAction } from "@/hooks/use-server-action";
interface BanInfo {
- id: number
- reason: string
- type: string
- staffName: string
- timestamp: number
- banExpire: number
+ id: number;
+ reason: string;
+ type: string;
+ staffName: string;
+ timestamp: number;
+ banExpire: number;
}
interface UserBanSectionProps {
- userId: number
- username: string
- bans: BanInfo[]
+ userId: number;
+ username: string;
+ bans: BanInfo[];
}
export function UserBanSection({ userId, username, bans }: UserBanSectionProps) {
- const { isPending, run } = useServerAction()
- const [showBanForm, setShowBanForm] = useState(false)
- const [reason, setReason] = useState('')
- const [duration, setDuration] = useState('24')
- const [banType, setBanType] = useState('account')
+ const { isPending, run } = useServerAction();
+ const [showBanForm, setShowBanForm] = useState(false);
+ const [reason, setReason] = useState("");
+ const [duration, setDuration] = useState("24");
+ const [banType, setBanType] = useState("account");
const activeBan = bans.find((b) => {
- const now = Math.floor(Date.now() / 1000)
- return b.banExpire === 0 || b.banExpire > now
- })
+ const now = Math.floor(Date.now() / 1000);
+ return b.banExpire === 0 || b.banExpire > now;
+ });
function handleBan() {
run(
@@ -52,22 +46,22 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
userId,
reason,
duration: parseInt(duration, 10),
- type: banType as 'account' | 'ip' | 'machine',
+ type: banType as "account" | "ip" | "machine",
}),
{
successMessage: `${username} has been banned.`,
onSuccess: () => {
- setShowBanForm(false)
- setReason('')
+ setShowBanForm(false);
+ setReason("");
},
},
- )
+ );
}
function handleUnban() {
run(() => unbanUser({ userId }), {
successMessage: `${username} has been unbanned.`,
- })
+ });
}
return (
@@ -94,9 +88,9 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
Active Ban
{activeBan.reason}
- By {activeBan.staffName} ·{' '}
+ By {activeBan.staffName} ·{" "}
{activeBan.banExpire === 0
- ? 'Permanent'
+ ? "Permanent"
: `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`}
@@ -137,7 +131,7 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
- {isPending ? 'Banning...' : 'Confirm Ban'}
+ {isPending ? "Banning..." : "Confirm Ban"}
)}
@@ -162,5 +156,5 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
)}
- )
+ );
}
diff --git a/src/app/admin/users/[id]/user-edit-form.tsx b/src/app/admin/users/[id]/user-edit-form.tsx
index 6d8dd391..d23b09b2 100644
--- a/src/app/admin/users/[id]/user-edit-form.tsx
+++ b/src/app/admin/users/[id]/user-edit-form.tsx
@@ -1,52 +1,46 @@
-'use client'
+"use client";
-import { zodResolver } from '@hookform/resolvers/zod'
-import { useForm } from 'react-hook-form'
-import { updateUser } from '@/actions/users'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { useServerAction } from '@/hooks/use-server-action'
-import { type UpdateUserInput, updateUserSchema } from '@/lib/validators/user'
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { updateUser } from "@/actions/users";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useServerAction } from "@/hooks/use-server-action";
+import { type UpdateUserInput, updateUserSchema } from "@/lib/validators/user";
interface UserEditFormProps {
user: {
- id: number
- username: string
- mail: string
- rank: number
- motto: string
- credits: number
- pixels: number
- diamonds: number
- duckets: number
- }
+ id: number;
+ username: string;
+ mail: string;
+ rank: number;
+ motto: string;
+ credits: number;
+ pixels: number;
+ diamonds: number;
+ duckets: number;
+ };
/** Ranks loaded from the `permissions` table (id + human-readable name). */
- ranks?: Array<{ id: number; name: string }>
+ ranks?: Array<{ id: number; name: string }>;
}
// Fallback used if the page does not supply the DB-loaded rank list.
const FALLBACK_RANKS: Array<{ id: number; name: string }> = [
- { id: 1, name: 'User' },
- { id: 2, name: 'Bronze' },
- { id: 3, name: 'Silver' },
- { id: 4, name: 'Gold' },
- { id: 5, name: 'Moderator' },
- { id: 6, name: 'Admin' },
- { id: 7, name: 'Super Admin' },
-]
+ { id: 1, name: "User" },
+ { id: 2, name: "Bronze" },
+ { id: 3, name: "Silver" },
+ { id: 4, name: "Gold" },
+ { id: 5, name: "Moderator" },
+ { id: 6, name: "Admin" },
+ { id: 7, name: "Super Admin" },
+];
export function UserEditForm({ user, ranks }: UserEditFormProps) {
- const rankOptions = ranks && ranks.length > 0 ? ranks : FALLBACK_RANKS
- const { isPending, run } = useServerAction()
+ const rankOptions = ranks && ranks.length > 0 ? ranks : FALLBACK_RANKS;
+ const { isPending, run } = useServerAction();
const {
register,
@@ -65,12 +59,12 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
diamonds: user.diamonds,
duckets: user.duckets,
},
- })
+ });
function onSubmit(data: UpdateUserInput) {
run(() => updateUser({ id: user.id, ...data }), {
- successMessage: 'User updated successfully.',
- })
+ successMessage: "User updated successfully.",
+ });
}
return (
@@ -83,14 +77,12 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
Username
-
- {errors.username && (
-
{errors.username.message}
- )}
+
+ {errors.username &&
{errors.username.message}
}
@@ -100,7 +92,7 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
Rank
setValue('rank', parseInt(v, 10))}
+ onValueChange={(v) => setValue("rank", parseInt(v, 10))}
>
@@ -116,38 +108,34 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
Motto
-
+
- {isPending ? 'Saving...' : 'Save Changes'}
+ {isPending ? "Saving..." : "Save Changes"}
- )
+ );
}
diff --git a/src/app/admin/users/[id]/user-quick-actions.tsx b/src/app/admin/users/[id]/user-quick-actions.tsx
index 2a36d93d..ed763b9a 100644
--- a/src/app/admin/users/[id]/user-quick-actions.tsx
+++ b/src/app/admin/users/[id]/user-quick-actions.tsx
@@ -1,102 +1,89 @@
-'use client'
+"use client";
-import { Check, Coins, Copy, KeyRound, MessageCircle, Unplug, Volume2, VolumeX } from 'lucide-react'
-import { useState } from 'react'
-import {
- alertUser,
- disconnectUser,
- muteUser,
- resetPassword,
- sendCredits,
- unmuteUser,
-} from '@/actions/users'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { useServerAction } from '@/hooks/use-server-action'
+import { Check, Coins, Copy, KeyRound, MessageCircle, Unplug, Volume2, VolumeX } from "lucide-react";
+import { useState } from "react";
+import { alertUser, disconnectUser, muteUser, resetPassword, sendCredits, unmuteUser } from "@/actions/users";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useServerAction } from "@/hooks/use-server-action";
interface UserQuickActionsProps {
- userId: number
- username: string
- isOnline: boolean
+ userId: number;
+ username: string;
+ isOnline: boolean;
}
export function UserQuickActions({ userId, username, isOnline }: UserQuickActionsProps) {
- const { run, isPending } = useServerAction()
+ const { run, isPending } = useServerAction();
// Reset password state
- const [newPassword, setNewPassword] = useState(null)
- const [copied, setCopied] = useState(false)
+ const [newPassword, setNewPassword] = useState(null);
+ const [copied, setCopied] = useState(false);
// Alert state
- const [alertMessage, setAlertMessage] = useState('')
+ const [alertMessage, setAlertMessage] = useState("");
// Mute state
- const [muteDuration, setMuteDuration] = useState('10')
+ const [muteDuration, setMuteDuration] = useState("10");
// Credits state
- const [creditAmount, setCreditAmount] = useState('1000')
+ const [creditAmount, setCreditAmount] = useState("1000");
function handleResetPassword() {
- if (!confirm(`Reset password for ${username}? A new random password will be generated.`)) return
+ if (!confirm(`Reset password for ${username}? A new random password will be generated.`)) return;
run(() => resetPassword({ userId }), {
- successMessage: 'Password reset successfully.',
+ successMessage: "Password reset successfully.",
onSuccess: (data) => {
- setNewPassword(data.newPassword as string)
+ setNewPassword(data.newPassword as string);
},
- })
+ });
}
function handleDisconnect() {
run(() => disconnectUser({ userId }), {
successMessage: `${username} disconnected.`,
- })
+ });
}
function handleAlert() {
- if (!alertMessage.trim()) return
+ if (!alertMessage.trim()) return;
run(() => alertUser({ userId, message: alertMessage.trim() }), {
- successMessage: 'Alert sent.',
- onSuccess: () => setAlertMessage(''),
- })
+ successMessage: "Alert sent.",
+ onSuccess: () => setAlertMessage(""),
+ });
}
function handleMute() {
- const duration = Number(muteDuration)
- if (Number.isNaN(duration) || duration < 0) return
+ const duration = Number(muteDuration);
+ if (Number.isNaN(duration) || duration < 0) return;
run(() => muteUser({ userId, duration }), {
successMessage: `${username} muted.`,
- })
+ });
}
function handleUnmute() {
run(() => unmuteUser({ userId }), {
successMessage: `${username} unmuted.`,
- })
+ });
}
function handleSendCredits() {
- const amount = parseInt(creditAmount, 10)
- if (!amount || amount <= 0) return
+ const amount = parseInt(creditAmount, 10);
+ if (!amount || amount <= 0) return;
run(() => sendCredits({ userId, amount }), {
successMessage: `${amount.toLocaleString()} credits sent to ${username}.`,
- })
+ });
}
function copyPassword() {
if (newPassword) {
- navigator.clipboard.writeText(newPassword)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
+ navigator.clipboard.writeText(newPassword);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
}
}
@@ -142,14 +129,10 @@ export function UserQuickActions({ userId, username, isOnline }: UserQuickAction
placeholder="Message to send..."
value={alertMessage}
onChange={(e) => setAlertMessage(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && handleAlert()}
+ onKeyDown={(e) => e.key === "Enter" && handleAlert()}
className="flex-1"
/>
-
+
@@ -200,8 +183,8 @@ export function UserQuickActions({ userId, username, isOnline }: UserQuickAction
Password Reset
- New password generated for {username} . Copy it now — it won't be
- shown again.
+ New password generated for {username} . Copy it now — it won't be shown
+ again.
@@ -209,11 +192,15 @@ export function UserQuickActions({ userId, username, isOnline }: UserQuickAction
{newPassword}
- {copied ? : }
+ {copied ? (
+
+ ) : (
+
+ )}
>
- )
+ );
}
diff --git a/src/app/admin/users/[id]/user-rooms.tsx b/src/app/admin/users/[id]/user-rooms.tsx
index 45875dce..488957f1 100644
--- a/src/app/admin/users/[id]/user-rooms.tsx
+++ b/src/app/admin/users/[id]/user-rooms.tsx
@@ -1,26 +1,26 @@
-import { DoorOpen, Users } from 'lucide-react'
-import Link from 'next/link'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { DoorOpen, Users } from "lucide-react";
+import Link from "next/link";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface UserRoom {
- id: number
- name: string
- state: string
- users: number
- usersMax: number
+ id: number;
+ name: string;
+ state: string;
+ users: number;
+ usersMax: number;
}
interface UserRoomsProps {
- rooms: UserRoom[]
+ rooms: UserRoom[];
}
-const stateColors: Record = {
- open: 'default',
- locked: 'secondary',
- password: 'destructive',
- invisible: 'outline',
-}
+const stateColors: Record = {
+ open: "default",
+ locked: "secondary",
+ password: "destructive",
+ invisible: "outline",
+};
export function UserRooms({ rooms }: UserRoomsProps) {
return (
@@ -45,7 +45,7 @@ export function UserRooms({ rooms }: UserRoomsProps) {
{room.name}
-
+
{room.state}
@@ -61,5 +61,5 @@ export function UserRooms({ rooms }: UserRoomsProps) {
)}
- )
+ );
}
diff --git a/src/app/admin/users/_components/create-user-dialog.tsx b/src/app/admin/users/_components/create-user-dialog.tsx
index 4e2eeb4d..b9898025 100644
--- a/src/app/admin/users/_components/create-user-dialog.tsx
+++ b/src/app/admin/users/_components/create-user-dialog.tsx
@@ -1,10 +1,10 @@
-'use client'
+"use client";
-import { Loader2, UserPlus } from 'lucide-react'
-import { useState } from 'react'
-import { toast } from 'sonner'
-import { createUser } from '@/actions/users'
-import { Button } from '@/components/ui/button'
+import { Loader2, UserPlus } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { createUser } from "@/actions/users";
+import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -12,75 +12,69 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { useServerAction } from '@/hooks/use-server-action'
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useServerAction } from "@/hooks/use-server-action";
interface CreateUserDialogProps {
- sessionRank: number
+ sessionRank: number;
}
const RANK_OPTIONS: { value: number; label: string }[] = [
- { value: 1, label: 'User (1)' },
- { value: 2, label: 'Bronze (2)' },
- { value: 3, label: 'Silver (3)' },
- { value: 4, label: 'Gold (4)' },
- { value: 5, label: 'Moderator (5)' },
- { value: 6, label: 'Admin (6)' },
- { value: 7, label: 'Super Admin (7)' },
-]
+ { value: 1, label: "User (1)" },
+ { value: 2, label: "Bronze (2)" },
+ { value: 3, label: "Silver (3)" },
+ { value: 4, label: "Gold (4)" },
+ { value: 5, label: "Moderator (5)" },
+ { value: 6, label: "Admin (6)" },
+ { value: 7, label: "Super Admin (7)" },
+];
export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
- const { run, isPending } = useServerAction()
- const [open, setOpen] = useState(false)
- const [username, setUsername] = useState('')
- const [mail, setMail] = useState('')
- const [password, setPassword] = useState('')
- const [rank, setRank] = useState('1')
- const [motto, setMotto] = useState('')
+ const { run, isPending } = useServerAction();
+ const [open, setOpen] = useState(false);
+ const [username, setUsername] = useState("");
+ const [mail, setMail] = useState("");
+ const [password, setPassword] = useState("");
+ const [rank, setRank] = useState("1");
+ const [motto, setMotto] = useState("");
- const allowedRanks = RANK_OPTIONS.filter((r) => (sessionRank >= 7 ? true : r.value < sessionRank))
+ const allowedRanks = RANK_OPTIONS.filter((r) => (sessionRank >= 7 ? true : r.value < sessionRank));
function reset() {
- setUsername('')
- setMail('')
- setPassword('')
- setRank('1')
- setMotto('')
+ setUsername("");
+ setMail("");
+ setPassword("");
+ setRank("1");
+ setMotto("");
}
function generatePassword() {
- const charset = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
- let out = ''
- const buf = new Uint32Array(12)
- crypto.getRandomValues(buf)
- for (let i = 0; i < buf.length; i++) out += charset[buf[i] % charset.length]
- setPassword(out)
- toast.message('Password generated', { description: out })
+ const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
+ let out = "";
+ const buf = new Uint32Array(12);
+ crypto.getRandomValues(buf);
+ for (let i = 0; i < buf.length; i++) out += charset[buf[i] % charset.length];
+ setPassword(out);
+ toast.message("Password generated", { description: out });
}
function handleCreate() {
- const u = username.trim()
- const m = mail.trim()
+ const u = username.trim();
+ const m = mail.trim();
if (!u || !m || !password) {
- toast.error('Username, email and password are required')
- return
+ toast.error("Username, email and password are required");
+ return;
}
if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) {
- toast.error('Invalid username (3-20 chars, letters/digits/_/-)')
- return
+ toast.error("Invalid username (3-20 chars, letters/digits/_/-)");
+ return;
}
if (password.length < 6) {
- toast.error('Password must be at least 6 characters')
- return
+ toast.error("Password must be at least 6 characters");
+ return;
}
run(
@@ -95,19 +89,19 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
{
successMessage: `User "${u}" created.`,
onSuccess: () => {
- setOpen(false)
- reset()
+ setOpen(false);
+ reset();
},
},
- )
+ );
}
return (
{
- setOpen(next)
- if (!next) reset()
+ setOpen(next);
+ if (!next) reset();
}}
>
@@ -200,5 +194,5 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
- )
+ );
}
diff --git a/src/app/admin/users/_components/user-account-info.tsx b/src/app/admin/users/_components/user-account-info.tsx
index deaddfd2..a3a9aa1b 100644
--- a/src/app/admin/users/_components/user-account-info.tsx
+++ b/src/app/admin/users/_components/user-account-info.tsx
@@ -1,19 +1,19 @@
-import { Calendar, Clock, ExternalLink, Globe, MonitorSmartphone, Shield } from 'lucide-react'
-import Link from 'next/link'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Calendar, Clock, ExternalLink, Globe, MonitorSmartphone, Shield } from "lucide-react";
+import Link from "next/link";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface UserAccountInfoProps {
user: {
- accountCreated: number
- lastLogin: number | null
- ipRegister: string
- ipCurrent: string
- twoFactorConfirmed: number
- machine_id: string
- mail: string
- gender: string
- }
+ accountCreated: number;
+ lastLogin: number | null;
+ ipRegister: string;
+ ipCurrent: string;
+ twoFactorConfirmed: number;
+ machine_id: string;
+ mail: string;
+ gender: string;
+ };
}
export function UserAccountInfo({ user }: UserAccountInfoProps) {
@@ -29,10 +29,10 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
Registered
- {new Date(user.accountCreated * 1000).toLocaleDateString('it-IT', {
- day: '2-digit',
- month: 'short',
- year: 'numeric',
+ {new Date(user.accountCreated * 1000).toLocaleDateString("it-IT", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
})}
@@ -41,27 +41,22 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
Last Login
{user.lastLogin
- ? new Date(user.lastLogin * 1000).toLocaleDateString('it-IT', {
- day: '2-digit',
- month: 'short',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
+ ? new Date(user.lastLogin * 1000).toLocaleDateString("it-IT", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
})
- : 'Never'}
+ : "Never"}
IP Register
-
- {user.ipRegister}
-
-
+ {user.ipRegister}
+
@@ -70,13 +65,8 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
IP Current
-
- {user.ipCurrent}
-
-
+ {user.ipCurrent}
+
@@ -86,15 +76,15 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
2FA
-
- {user.twoFactorConfirmed ? 'Enabled' : 'Disabled'}
+
+ {user.twoFactorConfirmed ? "Enabled" : "Disabled"}
Machine ID
- {user.machine_id || 'N/A'}
+ {user.machine_id || "N/A"}
@@ -111,5 +101,5 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
- )
+ );
}
diff --git a/src/app/admin/users/_components/user-badges-readonly.tsx b/src/app/admin/users/_components/user-badges-readonly.tsx
index ee23ab54..cc411199 100644
--- a/src/app/admin/users/_components/user-badges-readonly.tsx
+++ b/src/app/admin/users/_components/user-badges-readonly.tsx
@@ -1,22 +1,19 @@
-'use client'
+"use client";
interface Badge {
- id: number
- badgeCode: string
+ id: number;
+ badgeCode: string;
}
export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
if (badges.length === 0) {
- return
No badges
+ return
No badges
;
}
return (
{badges.map((badge) => (
-
+
{
- ;(e.target as HTMLImageElement).style.display = 'none'
+ (e.target as HTMLImageElement).style.display = "none";
}}
/>
{badge.badgeCode}
))}
- )
+ );
}
diff --git a/src/app/admin/users/_components/user-hero.tsx b/src/app/admin/users/_components/user-hero.tsx
index bff1d867..10050400 100644
--- a/src/app/admin/users/_components/user-hero.tsx
+++ b/src/app/admin/users/_components/user-hero.tsx
@@ -11,38 +11,38 @@ import {
Sparkles,
Wifi,
WifiOff,
-} from 'lucide-react'
-import Link from 'next/link'
-import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
-import { UserWatchToggle } from '@/components/admin/user-watch-toggle'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { getAvatarUrl } from '@/lib/imager'
+} from "lucide-react";
+import Link from "next/link";
+import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
+import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { getAvatarUrl } from "@/lib/imager";
interface UserHeroProps {
user: {
- id: number
- username: string
- motto: string
- look: string
- rank: number
- credits: number
- pixels: number
- online: string
- permission: { rankName: string }
- twoFactorConfirmed: number
- }
- diamonds: number
- duckets: number
- roomCount: number
- chatCount: number
- tradeCount: number
- isOnline: boolean
- activeBan: boolean
- mode: 'show' | 'edit'
- canEdit: boolean
+ id: number;
+ username: string;
+ motto: string;
+ look: string;
+ rank: number;
+ credits: number;
+ pixels: number;
+ online: string;
+ permission: { rankName: string };
+ twoFactorConfirmed: number;
+ };
+ diamonds: number;
+ duckets: number;
+ roomCount: number;
+ chatCount: number;
+ tradeCount: number;
+ isOnline: boolean;
+ activeBan: boolean;
+ mode: "show" | "edit";
+ canEdit: boolean;
/** When non-null, render the watch toggle with this initial state. */
- watching?: boolean | null
+ watching?: boolean | null;
}
export function UserHero({
@@ -62,9 +62,9 @@ export function UserHero({
<>
@@ -76,16 +76,14 @@ export function UserHero({
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
alt={user.username}
className="h-28 rounded-lg border bg-card shadow-sm"
- style={{ imageRendering: 'pixelated' }}
+ style={{ imageRendering: "pixelated" }}
/>
-
+
{isOnline && (
-
+
)}
@@ -98,14 +96,14 @@ export function UserHero({
ID: {user.id}
{/* Mode switch buttons */}
- {mode === 'show' && canEdit && (
+ {mode === "show" && canEdit && (
Edit
)}
- {mode === 'edit' && (
+ {mode === "edit" && (
View
@@ -113,23 +111,17 @@ export function UserHero({
)}
{watching != null && (
-
+
)}
- {user.motto && (
- “{user.motto}”
- )}
+ {user.motto && “{user.motto}”
}
{user.permission.rankName || `Rank ${user.rank}`}
{isOnline ? (
<>
@@ -143,7 +135,10 @@ export function UserHero({
{activeBan && Banned }
{user.twoFactorConfirmed ? (
-
+
2FA
) : null}
@@ -198,5 +193,5 @@ export function UserHero({
>
- )
+ );
}
diff --git a/src/app/admin/users/_lib/load-user.ts b/src/app/admin/users/_lib/load-user.ts
index 8179df0a..e3da2cff 100644
--- a/src/app/admin/users/_lib/load-user.ts
+++ b/src/app/admin/users/_lib/load-user.ts
@@ -7,32 +7,52 @@ export async function loadUserById(id: string) {
const user = await prisma.user.findUnique({ where: isNumeric ? { id: numericId } : { username: id } });
if (!user) notFound();
- const [currencies, badges, bansReceived, permissionRows, roomCount, tradeCount, userRooms, chatLogs, commandLogs] =
- await Promise.all([
- prisma.usersCurrency.findMany({ where: { userId: user.id } }),
- prisma.usersBadges.findMany({ where: { userId: user.id }, orderBy: { slotId: "asc" } }),
- prisma.$queryRaw>`
+ const [
+ currencies,
+ badges,
+ bansReceived,
+ permissionRows,
+ roomCount,
+ tradeCount,
+ userRooms,
+ chatLogs,
+ commandLogs,
+ ] = await Promise.all([
+ prisma.usersCurrency.findMany({ where: { userId: user.id } }),
+ prisma.usersBadges.findMany({ where: { userId: user.id }, orderBy: { slotId: "asc" } }),
+ prisma.$queryRaw<
+ Array<{
+ id: number;
+ banReason: string;
+ type: string;
+ timestamp: number;
+ banExpire: number;
+ staffName: string;
+ }>
+ >`
SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire,
COALESCE(s.username, 'Unknown') AS staffName
FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`,
- prisma.$queryRaw>`
+ prisma.$queryRaw>`
SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`,
- prisma.rooms.count({ where: { ownerId: user.id } }),
- prisma.roomTradeLog.count({ where: { OR: [{ userOneId: user.id }, { userTwoId: user.id }] } }),
- prisma.rooms.findMany({
- where: { ownerId: user.id },
- orderBy: { users: "desc" },
- take: 20,
- select: { id: true, name: true, state: true, users: true, usersMax: true },
- }),
- prisma.$queryRaw>`
+ prisma.rooms.count({ where: { ownerId: user.id } }),
+ prisma.roomTradeLog.count({ where: { OR: [{ userOneId: user.id }, { userTwoId: user.id }] } }),
+ prisma.rooms.findMany({
+ where: { ownerId: user.id },
+ orderBy: { users: "desc" },
+ take: 20,
+ select: { id: true, name: true, state: true, users: true, usersMax: true },
+ }),
+ prisma.$queryRaw>`
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`,
- prisma.$queryRaw>`
+ prisma.$queryRaw<
+ Array<{ id: number; timestamp: number; command: string; params: string; succes: string }>
+ >`
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`,
- ]);
+ ]);
const diamonds = currencies.find((currency) => currency.type === 5)?.amount ?? 0;
const duckets = currencies.find((currency) => currency.type === 0)?.amount ?? 0;
diff --git a/src/app/admin/users/edit/[id]/page.tsx b/src/app/admin/users/edit/[id]/page.tsx
index 50036559..af99bc2c 100644
--- a/src/app/admin/users/edit/[id]/page.tsx
+++ b/src/app/admin/users/edit/[id]/page.tsx
@@ -1,41 +1,37 @@
-import { redirect } from 'next/navigation'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { Prisma } from '@/generated/prisma/client'
-import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
-import { getWatchedUserIds } from '@/lib/services/watch'
-import { UserAccountInfo } from '../../_components/user-account-info'
-import { UserHero } from '../../_components/user-hero'
-import { loadUserById } from '../../_lib/load-user'
-import { UserActivityLog } from '../../[id]/user-activity-log'
-import { UserBadgesSection } from '../../[id]/user-badges-section'
-import { UserBanSection } from '../../[id]/user-ban-section'
-import { UserEditForm } from '../../[id]/user-edit-form'
-import { UserQuickActions } from '../../[id]/user-quick-actions'
-import { UserRooms } from '../../[id]/user-rooms'
+import { redirect } from "next/navigation";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Prisma } from "@/generated/prisma/client";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+import { getWatchedUserIds } from "@/lib/services/watch";
+import { UserAccountInfo } from "../../_components/user-account-info";
+import { UserHero } from "../../_components/user-hero";
+import { loadUserById } from "../../_lib/load-user";
+import { UserActivityLog } from "../../[id]/user-activity-log";
+import { UserBadgesSection } from "../../[id]/user-badges-section";
+import { UserBanSection } from "../../[id]/user-ban-section";
+import { UserEditForm } from "../../[id]/user-edit-form";
+import { UserQuickActions } from "../../[id]/user-quick-actions";
+import { UserRooms } from "../../[id]/user-rooms";
-export default async function UserEditPage({
- params,
-}: {
- params: Promise<{ locale: string; id: string }>
-}) {
- const { id } = await params
- const { session, permissions } = await getAdminContext()
+export default async function UserEditPage({ params }: { params: Promise<{ locale: string; id: string }> }) {
+ const { id } = await params;
+ const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.USERS_EDIT, session.user.rank)) {
- redirect('/admin')
+ redirect("/admin");
}
- const data = await loadUserById(id)
- const watched = await getWatchedUserIds(session.user.id)
+ const data = await loadUserById(id);
+ const watched = await getWatchedUserIds(session.user.id);
// Fetch rank list from permissions table for the rank dropdown
const rawRanks: { id: number; rank_name: string }[] = await prisma.$queryRaw(
Prisma.sql`SELECT id, rank_name FROM permissions ORDER BY id ASC`,
- )
+ );
const ranks = rawRanks.map((r) => ({
id: Number(r.id),
name: String(r.rank_name || `Rank ${r.id}`),
- }))
+ }));
return (
@@ -59,9 +55,7 @@ export default async function UserEditPage({
Edit Profile
Bans
- {data.activeBan && (
-
- )}
+ {data.activeBan && }
Badges ({data.user.badges.length})
Rooms ({data.roomCount})
@@ -125,13 +119,9 @@ export default async function UserEditPage({
- )
+ );
}
diff --git a/src/app/admin/users/loading.tsx b/src/app/admin/users/loading.tsx
index b4c33404..3fc9dbce 100644
--- a/src/app/admin/users/loading.tsx
+++ b/src/app/admin/users/loading.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from '@/components/ui/skeleton'
+import { Skeleton } from "@/components/ui/skeleton";
export default function UsersLoading() {
return (
@@ -42,5 +42,5 @@ export default function UsersLoading() {
))}
- )
+ );
}
diff --git a/src/app/admin/users/multi-accounts/page.tsx b/src/app/admin/users/multi-accounts/page.tsx
index 2b297bea..38e6f015 100644
--- a/src/app/admin/users/multi-accounts/page.tsx
+++ b/src/app/admin/users/multi-accounts/page.tsx
@@ -1,34 +1,34 @@
-import Link from 'next/link'
-import { redirect } from 'next/navigation'
-import { detectMultiAccounts, type MultiAccountCluster } from '@/actions/multi-account-detect'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { detectMultiAccounts, type MultiAccountCluster } from "@/actions/multi-account-detect";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function MultiAccountsPage({
searchParams,
}: {
- searchParams: Promise>
+ searchParams: Promise>;
}) {
- const { session, permissions } = await getAdminContext()
- if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) redirect('/admin')
+ const { session, permissions } = await getAdminContext();
+ if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) redirect("/admin");
- const params = await searchParams
- const minAccounts = Math.max(2, Number(params.min) || 2)
- const limit = Math.min(200, Number(params.limit) || 50)
+ const params = await searchParams;
+ const minAccounts = Math.max(2, Number(params.min) || 2);
+ const limit = Math.min(200, Number(params.limit) || 50);
- const result = await detectMultiAccounts({ minAccounts, limit })
+ const result = await detectMultiAccounts({ minAccounts, limit });
const clusters: MultiAccountCluster[] = result.ok
? ((result.data as { clusters: MultiAccountCluster[] }).clusters ?? [])
- : []
+ : [];
return (
Multi-account detector
- Clusters of users sharing a registration IP, current IP, or machine ID. Detection is
- passive — no AI, just a GROUP BY with index support.
+ Clusters of users sharing a registration IP, current IP, or machine ID. Detection is passive — no
+ AI, just a GROUP BY with index support.
@@ -55,10 +55,7 @@ export default async function MultiAccountsPage({
className="mt-1 h-9 w-24 rounded-md border bg-background px-2"
/>
-
+
Refresh
@@ -92,7 +89,7 @@ export default async function MultiAccountsPage({
{acc.username}
#{acc.id}
{acc.rank >= 5 && staff }
- {acc.online === '1' && (
+ {acc.online === "1" && (
)}
@@ -109,5 +106,5 @@ export default async function MultiAccountsPage({
)}
- )
+ );
}
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx
index 4b97a96c..8f91c627 100644
--- a/src/app/admin/users/page.tsx
+++ b/src/app/admin/users/page.tsx
@@ -1,20 +1,20 @@
-import { fetchAdminList } from '@/lib/admin-list'
-import { canAccess, PERMS } from '@/lib/permissions'
-import { CreateUserDialog } from './_components/create-user-dialog'
-import { UsersTable } from './users-table'
+import { fetchAdminList } from "@/lib/admin-list";
+import { canAccess, PERMS } from "@/lib/permissions";
+import { CreateUserDialog } from "./_components/create-user-dialog";
+import { UsersTable } from "./users-table";
export default async function UsersPage({
params: paramsPromise,
searchParams,
}: {
- params: Promise<{ locale: string }>
- searchParams: Promise>
+ params: Promise<{ locale: string }>;
+ searchParams: Promise>;
}) {
const result = await fetchAdminList(
{
permission: PERMS.USERS_VIEW,
- model: 'user',
- searchFields: ['username', 'mail', 'ipRegister', 'ipCurrent'],
+ model: "user",
+ searchFields: ["username", "mail", "ipRegister", "ipCurrent"],
select: {
id: true,
username: true,
@@ -31,20 +31,20 @@ export default async function UsersPage({
},
filterMap: {
filter_rank: (v) => {
- const n = Number(v)
- if (!Number.isFinite(n)) return null
+ const n = Number(v);
+ if (!Number.isFinite(n)) return null;
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
- return n >= 7 ? { rank: { gte: 7 } } : { rank: n }
+ return n >= 7 ? { rank: { gte: 7 } } : { rank: n };
},
filter_online: (v) => ({ online: v }),
},
},
paramsPromise,
searchParams,
- )
+ );
- const canEdit = canAccess(result.permissions, PERMS.USERS_EDIT, result.rank)
- const canBan = canAccess(result.permissions, PERMS.USERS_BAN, result.rank)
+ const canEdit = canAccess(result.permissions, PERMS.USERS_EDIT, result.rank);
+ const canBan = canAccess(result.permissions, PERMS.USERS_BAN, result.rank);
return (
@@ -68,5 +68,5 @@ export default async function UsersPage({
canBan={canBan}
/>
- )
+ );
}
diff --git a/src/app/admin/users/show/[id]/page.tsx b/src/app/admin/users/show/[id]/page.tsx
index 04e2c49c..d7d83c61 100644
--- a/src/app/admin/users/show/[id]/page.tsx
+++ b/src/app/admin/users/show/[id]/page.tsx
@@ -1,29 +1,25 @@
-import { redirect } from 'next/navigation'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
-import { getWatchedUserIds } from '@/lib/services/watch'
-import { UserAccountInfo } from '../../_components/user-account-info'
-import { UserBadgesReadonly } from '../../_components/user-badges-readonly'
-import { UserHero } from '../../_components/user-hero'
-import { loadUserById } from '../../_lib/load-user'
-import { UserActivityLog } from '../../[id]/user-activity-log'
-import { UserQuickActions } from '../../[id]/user-quick-actions'
-import { UserRooms } from '../../[id]/user-rooms'
+import { redirect } from "next/navigation";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+import { getWatchedUserIds } from "@/lib/services/watch";
+import { UserAccountInfo } from "../../_components/user-account-info";
+import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
+import { UserHero } from "../../_components/user-hero";
+import { loadUserById } from "../../_lib/load-user";
+import { UserActivityLog } from "../../[id]/user-activity-log";
+import { UserQuickActions } from "../../[id]/user-quick-actions";
+import { UserRooms } from "../../[id]/user-rooms";
-export default async function UserShowPage({
- params,
-}: {
- params: Promise<{ locale: string; id: string }>
-}) {
- const { id } = await params
- const { session, permissions } = await getAdminContext()
+export default async function UserShowPage({ params }: { params: Promise<{ locale: string; id: string }> }) {
+ const { id } = await params;
+ const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
- redirect('/admin')
+ redirect("/admin");
}
- const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank)
- const data = await loadUserById(id)
- const watched = await getWatchedUserIds(session.user.id)
+ const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
+ const data = await loadUserById(id);
+ const watched = await getWatchedUserIds(session.user.id);
return (
@@ -41,11 +37,7 @@ export default async function UserShowPage({
watching={watched.has(data.user.id)}
/>
-
+
Account Info
@@ -76,14 +68,10 @@ export default async function UserShowPage({
{canEdit && (
)}
- )
+ );
}
diff --git a/src/app/admin/users/users-table.tsx b/src/app/admin/users/users-table.tsx
index 65bedcd5..ac4f6601 100644
--- a/src/app/admin/users/users-table.tsx
+++ b/src/app/admin/users/users-table.tsx
@@ -1,13 +1,13 @@
-'use client'
+"use client";
-import { Eye, Pencil, Wifi, WifiOff } from 'lucide-react'
-import Link from 'next/link'
-import { useState } from 'react'
-import { toast } from 'sonner'
-import { bulkBan, bulkGiveBadge, bulkGiveCurrency, bulkUnban } from '@/actions/bulk-users'
-import { DataTable } from '@/components/admin/data-table'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
+import { Eye, Pencil, Wifi, WifiOff } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+import { toast } from "sonner";
+import { bulkBan, bulkGiveBadge, bulkGiveCurrency, bulkUnban } from "@/actions/bulk-users";
+import { DataTable } from "@/components/admin/data-table";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -15,113 +15,102 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { getAvatarUrl } from '@/lib/imager'
-import type { DataTableColumn, PaginatedResult } from '@/types'
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { getAvatarUrl } from "@/lib/imager";
+import type { DataTableColumn, PaginatedResult } from "@/types";
interface UserRow {
- id: number
- username: string
- mail: string
- rank: number
- credits: number
- pixels: number
- online: string
- motto: string
- look: string
- ipRegister: string
- accountCreated: number
- lastLogin: number | null
+ id: number;
+ username: string;
+ mail: string;
+ rank: number;
+ credits: number;
+ pixels: number;
+ online: string;
+ motto: string;
+ look: string;
+ ipRegister: string;
+ accountCreated: number;
+ lastLogin: number | null;
}
const RANK_LABELS: Record
= {
- 1: 'User',
- 2: 'Bronze',
- 3: 'Silver',
- 4: 'Gold',
- 5: 'Moderator',
- 6: 'Admin',
- 7: 'Super Admin',
-}
+ 1: "User",
+ 2: "Bronze",
+ 3: "Silver",
+ 4: "Gold",
+ 5: "Moderator",
+ 6: "Admin",
+ 7: "Super Admin",
+};
function getColumns(canEdit: boolean): DataTableColumn[] {
const cols: DataTableColumn[] = [
- { key: 'id', label: 'ID', sortable: true },
+ { key: "id", label: "ID", sortable: true },
{
- key: 'username',
- label: 'User',
+ key: "username",
+ label: "User",
sortable: true,
render: (_, row) => (
-
+
{row.username}
{row.motto && (
-
- {row.motto}
-
+ {row.motto}
)}
),
},
- { key: 'mail', label: 'Email', sortable: true },
+ { key: "mail", label: "Email", sortable: true },
{
- key: 'rank',
- label: 'Rank',
+ key: "rank",
+ label: "Rank",
sortable: true,
- filterKey: 'filter_rank',
+ filterKey: "filter_rank",
filterOptions: [
- { label: 'User (1)', value: '1' },
- { label: 'Bronze (2)', value: '2' },
- { label: 'Silver (3)', value: '3' },
- { label: 'Gold (4)', value: '4' },
- { label: 'Moderator (5)', value: '5' },
- { label: 'Admin (6)', value: '6' },
- { label: 'Super Admin (7+)', value: '7' },
+ { label: "User (1)", value: "1" },
+ { label: "Bronze (2)", value: "2" },
+ { label: "Silver (3)", value: "3" },
+ { label: "Gold (4)", value: "4" },
+ { label: "Moderator (5)", value: "5" },
+ { label: "Admin (6)", value: "6" },
+ { label: "Super Admin (7+)", value: "7" },
],
render: (value) => {
- const r = Number(value)
+ const r = Number(value);
return (
- = 7 ? 'default' : r >= 5 ? 'secondary' : 'outline'}>
+ = 7 ? "default" : r >= 5 ? "secondary" : "outline"}>
{RANK_LABELS[r] || `Rank ${r}`}
- )
+ );
},
},
{
- key: 'credits',
- label: 'Credits',
+ key: "credits",
+ label: "Credits",
sortable: true,
render: (value) => {Number(value).toLocaleString()} ,
},
{
- key: 'online',
- label: 'Status',
- filterKey: 'filter_online',
+ key: "online",
+ label: "Status",
+ filterKey: "filter_online",
filterOptions: [
- { label: 'Online', value: '1' },
- { label: 'Offline', value: '0' },
+ { label: "Online", value: "1" },
+ { label: "Offline", value: "0" },
],
render: (value) =>
- value === '1' ? (
+ value === "1" ? (
Online
@@ -132,26 +121,26 @@ function getColumns(canEdit: boolean): DataTableColumn[] {
),
},
{
- key: 'accountCreated',
- label: 'Registered',
+ key: "accountCreated",
+ label: "Registered",
sortable: true,
render: (value) => {
- const ts = Number(value)
- if (!ts) return '-'
+ const ts = Number(value);
+ if (!ts) return "-";
return (
- {new Date(ts * 1000).toLocaleDateString('it-IT', {
- day: '2-digit',
- month: 'short',
- year: 'numeric',
+ {new Date(ts * 1000).toLocaleDateString("it-IT", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
})}
- )
+ );
},
},
{
- key: 'actions',
- label: '',
+ key: "actions",
+ label: "",
render: (_, row) => (
@@ -169,147 +158,147 @@ function getColumns(canEdit: boolean): DataTableColumn[] {
),
},
- ]
+ ];
- return cols
+ return cols;
}
interface UsersTableProps {
- data: PaginatedResult
- canEdit: boolean
- canBan: boolean
+ data: PaginatedResult;
+ canEdit: boolean;
+ canBan: boolean;
}
type PendingDialog =
- | { kind: 'ban'; ids: number[] }
- | { kind: 'give-currency'; ids: number[] }
- | { kind: 'give-badge'; ids: number[] }
- | null
+ | { kind: "ban"; ids: number[] }
+ | { kind: "give-currency"; ids: number[] }
+ | { kind: "give-badge"; ids: number[] }
+ | null;
export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
- const [pending, setPending] = useState(null)
- const [busy, setBusy] = useState(false)
- const [banReason, setBanReason] = useState('Bulk ban')
- const [banDuration, setBanDuration] = useState('-1')
- const [currencyType, setCurrencyType] = useState<'credits' | 'pixels' | 'points'>('credits')
- const [currencyAmount, setCurrencyAmount] = useState('100')
- const [badgeCode, setBadgeCode] = useState('')
+ const [pending, setPending] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [banReason, setBanReason] = useState("Bulk ban");
+ const [banDuration, setBanDuration] = useState("-1");
+ const [currencyType, setCurrencyType] = useState<"credits" | "pixels" | "points">("credits");
+ const [currencyAmount, setCurrencyAmount] = useState("100");
+ const [badgeCode, setBadgeCode] = useState("");
function reportResult(label: string, ok: number, total: number, failures = 0) {
- if (ok > 0) toast.success(`${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ''}`)
- if (failures > 0 && ok === 0) toast.error(`${label} failed for ${failures} users`)
+ if (ok > 0) toast.success(`${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`);
+ if (failures > 0 && ok === 0) toast.error(`${label} failed for ${failures} users`);
}
async function handleBulkAction(action: string, ids: (number | string)[]) {
- const numericIds = ids.map((i) => Number(i))
+ const numericIds = ids.map((i) => Number(i));
- if (action === 'unban') {
- setBusy(true)
+ if (action === "unban") {
+ setBusy(true);
try {
- const result = await bulkUnban({ userIds: numericIds })
+ const result = await bulkUnban({ userIds: numericIds });
if (result.ok) {
- const data = result.data as { unbanned: number; total: number }
- reportResult('Unbanned', data.unbanned, data.total)
- } else toast.error(result.error)
+ const data = result.data as { unbanned: number; total: number };
+ reportResult("Unbanned", data.unbanned, data.total);
+ } else toast.error(result.error);
} finally {
- setBusy(false)
+ setBusy(false);
}
- return
+ return;
}
- if (action === 'ban') {
- setPending({ kind: 'ban', ids: numericIds })
- return
+ if (action === "ban") {
+ setPending({ kind: "ban", ids: numericIds });
+ return;
}
- if (action === 'give-currency') {
- setPending({ kind: 'give-currency', ids: numericIds })
- return
+ if (action === "give-currency") {
+ setPending({ kind: "give-currency", ids: numericIds });
+ return;
}
- if (action === 'give-badge') {
- setPending({ kind: 'give-badge', ids: numericIds })
- return
+ if (action === "give-badge") {
+ setPending({ kind: "give-badge", ids: numericIds });
+ return;
}
}
async function confirmBan() {
- if (pending?.kind !== 'ban') return
- setBusy(true)
+ if (pending?.kind !== "ban") return;
+ setBusy(true);
try {
const result = await bulkBan({
userIds: pending.ids,
reason: banReason,
duration: Number(banDuration),
- })
+ });
if (result.ok) {
- const data = result.data as { banned: number }
- reportResult('Banned', data.banned, pending.ids.length)
- } else toast.error(result.error)
- setPending(null)
+ const data = result.data as { banned: number };
+ reportResult("Banned", data.banned, pending.ids.length);
+ } else toast.error(result.error);
+ setPending(null);
} finally {
- setBusy(false)
+ setBusy(false);
}
}
async function confirmGiveCurrency() {
- if (pending?.kind !== 'give-currency') return
- setBusy(true)
+ if (pending?.kind !== "give-currency") return;
+ setBusy(true);
try {
const result = await bulkGiveCurrency({
userIds: pending.ids,
amount: Number(currencyAmount),
type: currencyType,
- })
+ });
if (result.ok) {
const data = result.data as {
- given: number
- total: number
- failedIds: { userId: number; reason: string }[]
- }
+ given: number;
+ total: number;
+ failedIds: { userId: number; reason: string }[];
+ };
reportResult(
`Given ${currencyAmount} ${currencyType}`,
data.given,
data.total,
data.failedIds.length,
- )
- } else toast.error(result.error)
- setPending(null)
+ );
+ } else toast.error(result.error);
+ setPending(null);
} finally {
- setBusy(false)
+ setBusy(false);
}
}
async function confirmGiveBadge() {
- if (pending?.kind !== 'give-badge') return
- setBusy(true)
+ if (pending?.kind !== "give-badge") return;
+ setBusy(true);
try {
const result = await bulkGiveBadge({
userIds: pending.ids,
badgeCode: badgeCode.trim().toUpperCase(),
- })
+ });
if (result.ok) {
const data = result.data as {
- given: number
- total: number
- failedIds: { userId: number; reason: string }[]
- }
- reportResult(`Given badge ${badgeCode}`, data.given, data.total, data.failedIds.length)
- } else toast.error(result.error)
- setPending(null)
+ given: number;
+ total: number;
+ failedIds: { userId: number; reason: string }[];
+ };
+ reportResult(`Given badge ${badgeCode}`, data.given, data.total, data.failedIds.length);
+ } else toast.error(result.error);
+ setPending(null);
} finally {
- setBusy(false)
+ setBusy(false);
}
}
- const bulkActions: { label: string; value: string; variant?: 'destructive' | 'default' }[] = []
+ const bulkActions: { label: string; value: string; variant?: "destructive" | "default" }[] = [];
if (canBan) {
- bulkActions.push({ label: 'Ban', value: 'ban', variant: 'destructive' })
- bulkActions.push({ label: 'Unban', value: 'unban' })
+ bulkActions.push({ label: "Ban", value: "ban", variant: "destructive" });
+ bulkActions.push({ label: "Unban", value: "unban" });
}
if (canEdit) {
- bulkActions.push({ label: 'Give credits', value: 'give-currency' })
- bulkActions.push({ label: 'Give badge', value: 'give-badge' })
+ bulkActions.push({ label: "Give credits", value: "give-currency" });
+ bulkActions.push({ label: "Give badge", value: "give-badge" });
}
return (
@@ -319,29 +308,25 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
columns={getColumns(canEdit)}
searchPlaceholder="Search by username, email or IP..."
presets={[
- { label: 'Online now', params: { filter_online: '1' } },
- { label: 'Staff (rank ≥ 5)', params: { filter_rank: '5' } },
- { label: 'Super admins', params: { filter_rank: '7' } },
+ { label: "Online now", params: { filter_online: "1" } },
+ { label: "Staff (rank ≥ 5)", params: { filter_rank: "5" } },
+ { label: "Super admins", params: { filter_rank: "7" } },
]}
bulkActions={bulkActions.length > 0 ? bulkActions : undefined}
onBulkAction={bulkActions.length > 0 ? handleBulkAction : undefined}
- exportUrl={canEdit ? '/api/admin/export?type=users' : undefined}
+ exportUrl={canEdit ? "/api/admin/export?type=users" : undefined}
/>
- !o && setPending(null)}>
+ !o && setPending(null)}>
- Ban {pending?.kind === 'ban' ? pending.ids.length : 0} users
+ Ban {pending?.kind === "ban" ? pending.ids.length : 0} users
Permanent ban: leave duration at -1.
Reason
- setBanReason(e.target.value)}
- />
+ setBanReason(e.target.value)} />
Duration (seconds, -1 = permanent)
@@ -364,20 +349,17 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
-
!o && setPending(null)}>
+ !o && setPending(null)}>
- Give currency to {pending?.kind === 'give-currency' ? pending.ids.length : 0} users
+ Give currency to {pending?.kind === "give-currency" ? pending.ids.length : 0} users
Type
-
setCurrencyType(v as typeof currencyType)}
- >
+ setCurrencyType(v as typeof currencyType)}>
@@ -409,11 +391,11 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
- !o && setPending(null)}>
+ !o && setPending(null)}>
- Give badge to {pending?.kind === 'give-badge' ? pending.ids.length : 0} users
+ Give badge to {pending?.kind === "give-badge" ? pending.ids.length : 0} users
@@ -438,5 +420,5 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
>
- )
+ );
}
diff --git a/src/app/api/admin/analytics/export/route.ts b/src/app/api/admin/analytics/export/route.ts
index d4366420..93c5bee3 100644
--- a/src/app/api/admin/analytics/export/route.ts
+++ b/src/app/api/admin/analytics/export/route.ts
@@ -1,21 +1,21 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permission-slugs'
-import { prisma } from '@/lib/prisma'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permission-slugs";
+import { prisma } from "@/lib/prisma";
-const VALID_REPORTS = new Set(['registrations', 'online-by-hour', 'economy'])
+const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]);
function csvEscape(value: unknown): string {
- if (value == null) return ''
- const str = String(value)
+ if (value == null) return "";
+ const str = String(value);
if (/[",\n\r]/.test(str)) {
- return `"${str.replace(/"/g, '""')}"`
+ return `"${str.replace(/"/g, '""')}"`;
}
- return str
+ return str;
}
function csvRow(values: unknown[]): string {
- return `${values.map(csvEscape).join(',')}\n`
+ return `${values.map(csvEscape).join(",")}\n`;
}
async function streamRegistrations(): Promise
> {
@@ -25,17 +25,17 @@ async function streamRegistrations(): Promise> {
WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY)
GROUP BY day
ORDER BY day ASC
- `
- const encoder = new TextEncoder()
+ `;
+ const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
- controller.enqueue(encoder.encode(csvRow(['day', 'registrations'])))
+ controller.enqueue(encoder.encode(csvRow(["day", "registrations"])));
for (const r of rows) {
- controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])))
+ controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])));
}
- controller.close()
+ controller.close();
},
- })
+ });
}
async function streamOnlineByHour(): Promise> {
@@ -45,57 +45,53 @@ async function streamOnlineByHour(): Promise> {
WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
GROUP BY hour
ORDER BY hour ASC
- `
- const encoder = new TextEncoder()
+ `;
+ const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
- controller.enqueue(encoder.encode(csvRow(['hour', 'active_users'])))
+ controller.enqueue(encoder.encode(csvRow(["hour", "active_users"])));
for (const r of rows) {
- controller.enqueue(encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])))
+ controller.enqueue(encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])));
}
- controller.close()
+ controller.close();
},
- })
+ });
}
async function streamEconomy(): Promise> {
const [totals] = await prisma.$queryRaw<{ total_credits: bigint; total_pixels: bigint }[]>`
SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
- `
- const encoder = new TextEncoder()
+ `;
+ const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
- controller.enqueue(encoder.encode(csvRow(['metric', 'value'])))
- controller.enqueue(
- encoder.encode(csvRow(['total_credits', Number(totals?.total_credits ?? 0)])),
- )
- controller.enqueue(
- encoder.encode(csvRow(['total_pixels', Number(totals?.total_pixels ?? 0)])),
- )
- controller.close()
+ controller.enqueue(encoder.encode(csvRow(["metric", "value"])));
+ controller.enqueue(encoder.encode(csvRow(["total_credits", Number(totals?.total_credits ?? 0)])));
+ controller.enqueue(encoder.encode(csvRow(["total_pixels", Number(totals?.total_pixels ?? 0)])));
+ controller.close();
},
- })
+ });
}
export const GET = withAdmin({ permission: PERMS.ANALYTICS_EXPORT }, async (request) => {
- const url = new URL(request.url)
- const report = url.searchParams.get('report') ?? ''
+ const url = new URL(request.url);
+ const report = url.searchParams.get("report") ?? "";
if (!VALID_REPORTS.has(report)) {
- return apiError(`Unknown report. Use one of: ${[...VALID_REPORTS].join(', ')}`, 400)
+ return apiError(`Unknown report. Use one of: ${[...VALID_REPORTS].join(", ")}`, 400);
}
const stream =
- report === 'registrations'
+ report === "registrations"
? await streamRegistrations()
- : report === 'online-by-hour'
+ : report === "online-by-hour"
? await streamOnlineByHour()
- : await streamEconomy()
+ : await streamEconomy();
return new Response(stream, {
headers: {
- 'Content-Type': 'text/csv; charset=utf-8',
- 'Content-Disposition': `attachment; filename="${report}.csv"`,
- 'Cache-Control': 'no-store',
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${report}.csv"`,
+ "Cache-Control": "no-store",
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/devops/health/route.ts b/src/app/api/admin/devops/health/route.ts
index 9b8e1997..feac8091 100644
--- a/src/app/api/admin/devops/health/route.ts
+++ b/src/app/api/admin/devops/health/route.ts
@@ -1,53 +1,53 @@
-import net from 'node:net'
-import { NextResponse } from 'next/server'
-import { env } from '@/env'
-import { withAdmin } from '@/lib/api-handler'
-import { PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
+import net from "node:net";
+import { NextResponse } from "next/server";
+import { env } from "@/env";
+import { withAdmin } from "@/lib/api-handler";
+import { PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
function checkRcon(): Promise {
return new Promise((resolve) => {
- const host = env.RCON_HOST
- const port = env.RCON_PORT
+ const host = env.RCON_HOST;
+ const port = env.RCON_PORT;
- const socket = new net.Socket()
+ const socket = new net.Socket();
const timeout = setTimeout(() => {
- socket.destroy()
- resolve(false)
- }, 3000)
+ socket.destroy();
+ resolve(false);
+ }, 3000);
socket.connect(port, host, () => {
- clearTimeout(timeout)
- socket.destroy()
- resolve(true)
- })
+ clearTimeout(timeout);
+ socket.destroy();
+ resolve(true);
+ });
- socket.on('error', () => {
- clearTimeout(timeout)
- socket.destroy()
- resolve(false)
- })
- })
+ socket.on("error", () => {
+ clearTimeout(timeout);
+ socket.destroy();
+ resolve(false);
+ });
+ });
}
export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
// DB health
- let dbOk = false
- let dbLatency = 0
+ let dbOk = false;
+ let dbLatency = 0;
try {
- const start = Date.now()
- await prisma.$queryRaw`SELECT 1`
- dbLatency = Date.now() - start
- dbOk = true
+ const start = Date.now();
+ await prisma.$queryRaw`SELECT 1`;
+ dbLatency = Date.now() - start;
+ dbOk = true;
} catch {
- dbOk = false
+ dbOk = false;
}
// Emulator health (RCON TCP check)
- const emulator = await checkRcon()
+ const emulator = await checkRcon();
// Online users
- const onlineUsers = await prisma.user.count({ where: { online: '1' } })
+ const onlineUsers = await prisma.user.count({ where: { online: "1" } });
return NextResponse.json({
database: dbOk,
@@ -55,5 +55,5 @@ export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
emulator,
onlineUsers,
timestamp: new Date().toISOString(),
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/badges/edit/route.ts b/src/app/api/admin/import/badges/edit/route.ts
index ff2fc528..6d15e383 100644
--- a/src/app/api/admin/import/badges/edit/route.ts
+++ b/src/app/api/admin/import/badges/edit/route.ts
@@ -1,28 +1,25 @@
-import { promises as fs } from 'node:fs'
-import path from 'node:path'
-import { withAdmin } from '@/lib/api-handler'
-import { apiError, apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
-const EXTERNAL_TEXTS_PATH = path.join(
- process.cwd(),
- 'public/nitro-assets/gamedata/ExternalTexts.json',
-)
+const EXTERNAL_TEXTS_PATH = path.join(process.cwd(), "public/nitro-assets/gamedata/ExternalTexts.json");
/** GET - Read badge name/desc from ExternalTexts.json */
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const code = request.nextUrl.searchParams.get('code')?.toUpperCase()
- if (!code) return apiError('Missing code', 400)
+ const code = request.nextUrl.searchParams.get("code")?.toUpperCase();
+ if (!code) return apiError("Missing code", 400);
try {
- const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, 'utf-8')
- const texts: Record = JSON.parse(raw)
+ const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
+ const texts: Record = JSON.parse(raw);
- const name = texts[`badge_name_${code}`] ?? texts[`${code}_badge_name`] ?? ''
- const desc = texts[`badge_desc_${code}`] ?? texts[`${code}_badge_desc`] ?? ''
+ const name = texts[`badge_name_${code}`] ?? texts[`${code}_badge_name`] ?? "";
+ const desc = texts[`badge_desc_${code}`] ?? texts[`${code}_badge_desc`] ?? "";
- return apiOk({ name, desc })
+ return apiOk({ name, desc });
} catch {
- return apiError('Failed to read ExternalTexts.json', 500)
+ return apiError("Failed to read ExternalTexts.json", 500);
}
-})
+});
diff --git a/src/app/api/admin/import/badges/route.ts b/src/app/api/admin/import/badges/route.ts
index 633c6916..566852fe 100644
--- a/src/app/api/admin/import/badges/route.ts
+++ b/src/app/api/admin/import/badges/route.ts
@@ -1,32 +1,29 @@
-import { promises as fs } from 'node:fs'
-import path from 'node:path'
-import { withAdmin } from '@/lib/api-handler'
-import { apiError, apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { searchBadges } from '@/lib/services/habboassets'
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { searchBadges } from "@/lib/services/habboassets";
-const EXTERNAL_TEXTS_PATH = path.join(
- process.cwd(),
- 'public/nitro-assets/gamedata/ExternalTexts.json',
-)
+const EXTERNAL_TEXTS_PATH = path.join(process.cwd(), "public/nitro-assets/gamedata/ExternalTexts.json");
// ── Search badges via official Habbo external texts ────────────────
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const search = request.nextUrl.searchParams.get('search') || ''
- const offset = parseInt(request.nextUrl.searchParams.get('offset') || '0', 10)
- const allHotels = request.nextUrl.searchParams.get('allHotels') === '1'
+ const search = request.nextUrl.searchParams.get("search") || "";
+ const offset = parseInt(request.nextUrl.searchParams.get("offset") || "0", 10);
+ const allHotels = request.nextUrl.searchParams.get("allHotels") === "1";
- const badges = await searchBadges(search, 100, offset, allHotels ? null : undefined)
+ const badges = await searchBadges(search, 100, offset, allHotels ? null : undefined);
// Check which badges already exist in ExternalTexts.json
- const existingCodes = new Set()
+ const existingCodes = new Set();
try {
- const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, 'utf-8')
- const texts = JSON.parse(raw)
+ const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
+ const texts = JSON.parse(raw);
for (const badge of badges) {
if (texts[`badge_name_${badge.code}`] !== undefined) {
- existingCodes.add(badge.code)
+ existingCodes.add(badge.code);
}
}
} catch {
@@ -36,44 +33,44 @@ export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
const enriched = badges.map((badge) => ({
...badge,
alreadyImported: existingCodes.has(badge.code),
- }))
+ }));
- return apiOk({ badges: enriched })
-})
+ return apiOk({ badges: enriched });
+});
// ── Import a single badge ───────────────────────────────────────────
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
- const { code, name, description } = body
+ const body = await request.json();
+ const { code, name, description } = body;
- if (!code || !name) return apiError('Missing code or name', 400)
+ if (!code || !name) return apiError("Missing code or name", 400);
try {
- const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, 'utf-8')
- const texts = JSON.parse(raw)
+ const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
+ const texts = JSON.parse(raw);
- const nameKey = `badge_name_${code}`
- const descKey = `badge_desc_${code}`
+ const nameKey = `badge_name_${code}`;
+ const descKey = `badge_desc_${code}`;
if (texts[nameKey] !== undefined) {
- return apiError('Badge already exists in ExternalTexts.json', 409)
+ return apiError("Badge already exists in ExternalTexts.json", 409);
}
- texts[nameKey] = name
- texts[descKey] = description || ''
+ texts[nameKey] = name;
+ texts[descKey] = description || "";
- await fs.writeFile(EXTERNAL_TEXTS_PATH, JSON.stringify(texts, null, 4), 'utf-8')
+ await fs.writeFile(EXTERNAL_TEXTS_PATH, JSON.stringify(texts, null, 4), "utf-8");
} catch (err) {
- console.warn('[import-badges] Failed to update ExternalTexts.json:', (err as Error).message)
- return apiError('Failed to update ExternalTexts.json', 500)
+ console.warn("[import-badges] Failed to update ExternalTexts.json:", (err as Error).message);
+ return apiError("Failed to update ExternalTexts.json", 500);
}
logAudit({
userId: ctx.session.user.id,
- action: 'badge_import',
- target: 'ExternalTexts',
+ action: "badge_import",
+ target: "ExternalTexts",
after: { code, name, description },
- })
+ });
- return apiOk()
-})
+ return apiOk();
+});
diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts
index 60786848..32744cf4 100644
--- a/src/app/api/admin/import/clone/batch/route.ts
+++ b/src/app/api/admin/import/clone/batch/route.ts
@@ -1,71 +1,71 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { cloneSingleFurni, fetchSourceFurnidata } from '@/lib/services/clone-import'
-import { getSource } from '@/lib/services/clone-sources'
-import { runSseBatch } from '@/lib/services/import/core/sse-batch'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { cloneSingleFurni, fetchSourceFurnidata } from "@/lib/services/clone-import";
+import { getSource } from "@/lib/services/clone-sources";
+import { runSseBatch } from "@/lib/services/import/core/sse-batch";
interface BatchItem {
- classname: string
+ classname: string;
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
- const sourceId: string = body.sourceId || ''
- const rawClassnames: string[] = body.classnames || []
+ const body = await request.json();
+ const sourceId: string = body.sourceId || "";
+ const rawClassnames: string[] = body.classnames || [];
- if (!sourceId) return apiError('Missing sourceId', 400)
- if (rawClassnames.length === 0) return apiError('No classnames to clone', 400)
+ if (!sourceId) return apiError("Missing sourceId", 400);
+ if (rawClassnames.length === 0) return apiError("No classnames to clone", 400);
- const source = await getSource(sourceId)
- if (!source) return apiError('Source not found', 404)
+ const source = await getSource(sourceId);
+ if (!source) return apiError("Source not found", 404);
// Fetch furnidata once so we can resolve entries by classname.
- let allEntries: Awaited>
+ let allEntries: Awaited>;
try {
- allEntries = await fetchSourceFurnidata(source.furnidataUrl)
+ allEntries = await fetchSourceFurnidata(source.furnidataUrl);
} catch (err) {
- return apiError(`Failed to fetch furnidata: ${(err as Error).message}`, 502)
+ return apiError(`Failed to fetch furnidata: ${(err as Error).message}`, 502);
}
- const entryMap = new Map(allEntries.map((e) => [e.classname, e]))
+ const entryMap = new Map(allEntries.map((e) => [e.classname, e]));
// Deduplicate classnames preserving order.
- const seen = new Set()
- const items: BatchItem[] = []
+ const seen = new Set();
+ const items: BatchItem[] = [];
for (const cn of rawClassnames) {
if (!seen.has(cn)) {
- seen.add(cn)
- items.push({ classname: cn })
+ seen.add(cn);
+ items.push({ classname: cn });
}
}
- if (items.length > 500) return apiError('Too many classnames (max 500)', 400)
+ if (items.length > 500) return apiError("Too many classnames (max 500)", 400);
return runSseBatch({
items,
concurrency: body.concurrency || 2,
labelOf: (it) => it.classname,
worker: async (it, _index, report) => {
- const entry = entryMap.get(it.classname)
+ const entry = entryMap.get(it.classname);
if (!entry) {
- return { ok: false, error: 'classname not found in source furnidata' }
+ return { ok: false, error: "classname not found in source furnidata" };
}
- const result = await cloneSingleFurni({ source, entry, onProgress: report })
+ const result = await cloneSingleFurni({ source, entry, onProgress: report });
if (result.ok) {
logAudit({
userId: ctx.session.user.id,
- action: 'clone_furni',
- target: 'FurniAsset',
+ action: "clone_furni",
+ target: "FurniAsset",
targetId: 0,
after: { classname: it.classname, sourceId, sourceName: source.name },
- })
+ });
}
- return { ok: result.ok, warnings: result.warnings, error: result.error }
+ return { ok: result.ok, warnings: result.warnings, error: result.error };
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/clone/icon/route.ts b/src/app/api/admin/import/clone/icon/route.ts
index 2548800d..597dfdfa 100644
--- a/src/app/api/admin/import/clone/icon/route.ts
+++ b/src/app/api/admin/import/clone/icon/route.ts
@@ -1,52 +1,52 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { extractFurniIconPng } from '@/lib/services/clone-icon'
-import { getSource } from '@/lib/services/clone-sources'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { extractFurniIconPng } from "@/lib/services/clone-icon";
+import { getSource } from "@/lib/services/clone-sources";
// In-process cache of extracted icons, keyed by source+classname.
// null = known-missing (don't refetch the bundle every render).
-const iconCache = new Map()
+const iconCache = new Map();
// GET ?source=&classname=
// Fetches the source's .nitro bundle and returns the embedded furni icon as a
// PNG. Used by the clone preview grid for sources that don't serve standalone
// {classname}_icon.png files (icons live inside the .nitro).
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const sp = request.nextUrl.searchParams
- const sourceId = sp.get('source')
- const classname = sp.get('classname')
- if (!sourceId || !classname) return apiError('Missing source or classname', 400)
- if (!/^[\w\-.*]+$/.test(classname)) return apiError('Invalid classname', 400)
+ const sp = request.nextUrl.searchParams;
+ const sourceId = sp.get("source");
+ const classname = sp.get("classname");
+ if (!sourceId || !classname) return apiError("Missing source or classname", 400);
+ if (!/^[\w\-.*]+$/.test(classname)) return apiError("Invalid classname", 400);
- const cacheKey = `${sourceId}:${classname}`
- let icon = iconCache.get(cacheKey)
+ const cacheKey = `${sourceId}:${classname}`;
+ let icon = iconCache.get(cacheKey);
if (icon === undefined) {
- const source = await getSource(sourceId)
- if (!source) return apiError('Source not found', 404)
+ const source = await getSource(sourceId);
+ if (!source) return apiError("Source not found", 404);
try {
const res = await fetch(`${source.nitroBaseUrl}/${classname}.nitro`, {
signal: AbortSignal.timeout(15000),
- })
+ });
if (!res.ok) {
- iconCache.set(cacheKey, null)
- return apiError('Bundle not found', 404)
+ iconCache.set(cacheKey, null);
+ return apiError("Bundle not found", 404);
}
- icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()))
- iconCache.set(cacheKey, icon)
+ icon = extractFurniIconPng(Buffer.from(await res.arrayBuffer()));
+ iconCache.set(cacheKey, icon);
} catch {
- iconCache.set(cacheKey, null)
- return apiError('Failed to fetch bundle', 502)
+ iconCache.set(cacheKey, null);
+ return apiError("Failed to fetch bundle", 502);
}
}
- if (!icon) return apiError('No icon in bundle', 404)
+ if (!icon) return apiError("No icon in bundle", 404);
return new Response(new Uint8Array(icon), {
headers: {
- 'Content-Type': 'image/png',
- 'Cache-Control': 'public, max-age=86400',
+ "Content-Type": "image/png",
+ "Cache-Control": "public, max-age=86400",
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/clone/route.ts b/src/app/api/admin/import/clone/route.ts
index c49f33a2..61afa915 100644
--- a/src/app/api/admin/import/clone/route.ts
+++ b/src/app/api/admin/import/clone/route.ts
@@ -1,94 +1,94 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError, apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { getClonableClassnames, getCloneList, getCloneStats } from '@/lib/services/clone-import'
-import { deleteSource, getSource, listSources, upsertSource } from '@/lib/services/clone-sources'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { getClonableClassnames, getCloneList, getCloneStats } from "@/lib/services/clone-import";
+import { deleteSource, getSource, listSources, upsertSource } from "@/lib/services/clone-sources";
// GET ?action=sources → list presets
// GET ?source=&search=&page=&perPage= → { items, meta, stats }
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const sp = request.nextUrl.searchParams
+ const sp = request.nextUrl.searchParams;
- if (sp.get('action') === 'sources') {
- return apiOk({ sources: await listSources() })
+ if (sp.get("action") === "sources") {
+ return apiOk({ sources: await listSources() });
}
- const sourceId = sp.get('source')
- if (!sourceId) return apiError('Missing source or action', 400)
+ const sourceId = sp.get("source");
+ if (!sourceId) return apiError("Missing source or action", 400);
- const source = await getSource(sourceId)
- if (!source) return apiError('Source not found', 404)
+ const source = await getSource(sourceId);
+ if (!source) return apiError("Source not found", 404);
// GET ?source=&action=clonable → all not-yet-present classnames (for "clone all")
- if (sp.get('action') === 'clonable') {
- return apiOk({ classnames: await getClonableClassnames(source) })
+ if (sp.get("action") === "clonable") {
+ return apiOk({ classnames: await getClonableClassnames(source) });
}
- const search = sp.get('search') || ''
- const page = Math.max(Number.parseInt(sp.get('page') || '1', 10) || 1, 1)
- const perPage = Math.min(Math.max(Number.parseInt(sp.get('perPage') || '50', 10) || 50, 1), 200)
- const filterParam = sp.get('filter')
- const filter = filterParam === 'missing' || filterParam === 'present' ? filterParam : 'all'
+ const search = sp.get("search") || "";
+ const page = Math.max(Number.parseInt(sp.get("page") || "1", 10) || 1, 1);
+ const perPage = Math.min(Math.max(Number.parseInt(sp.get("perPage") || "50", 10) || 50, 1), 200);
+ const filterParam = sp.get("filter");
+ const filter = filterParam === "missing" || filterParam === "present" ? filterParam : "all";
const [listResult, stats] = await Promise.all([
getCloneList({ source, search, page, perPage, filter }),
getCloneStats(source),
- ])
+ ]);
- return apiOk({ items: listResult.items, meta: listResult.meta, stats })
-})
+ return apiOk({ items: listResult.items, meta: listResult.meta, stats });
+});
// POST — upsert a source preset
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
- const { name, furnidataUrl, nitroBaseUrl, iconBaseUrl, id } = body as Record
+ const body = await request.json();
+ const { name, furnidataUrl, nitroBaseUrl, iconBaseUrl, id } = body as Record;
const isHttps = (u: string) => {
try {
- return new URL(u).protocol === 'https:'
+ return new URL(u).protocol === "https:";
} catch {
- return false
+ return false;
}
- }
+ };
- if (!name?.trim()) return apiError('name is required', 400)
- if (!furnidataUrl?.trim()) return apiError('furnidataUrl is required', 400)
- if (!nitroBaseUrl?.trim()) return apiError('nitroBaseUrl is required', 400)
- if (!iconBaseUrl?.trim()) return apiError('iconBaseUrl is required', 400)
+ if (!name?.trim()) return apiError("name is required", 400);
+ if (!furnidataUrl?.trim()) return apiError("furnidataUrl is required", 400);
+ if (!nitroBaseUrl?.trim()) return apiError("nitroBaseUrl is required", 400);
+ if (!iconBaseUrl?.trim()) return apiError("iconBaseUrl is required", 400);
if (!isHttps(furnidataUrl) || !isHttps(nitroBaseUrl) || !isHttps(iconBaseUrl))
- return apiError('Source URLs must be valid https:// URLs', 400)
+ return apiError("Source URLs must be valid https:// URLs", 400);
- const entry = await upsertSource({ id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl })
+ const entry = await upsertSource({ id, name, furnidataUrl, nitroBaseUrl, iconBaseUrl });
logAudit({
userId: ctx.session.user.id,
- action: id ? 'clone_source_update' : 'clone_source_create',
- target: 'CloneSource',
+ action: id ? "clone_source_update" : "clone_source_create",
+ target: "CloneSource",
targetId: 0,
after: { id: entry.id, name: entry.name },
- })
+ });
- return apiOk({ source: entry })
-})
+ return apiOk({ source: entry });
+});
// DELETE ?id=
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const id = request.nextUrl.searchParams.get('id')
- if (!id) return apiError('Missing id', 400)
+ const id = request.nextUrl.searchParams.get("id");
+ if (!id) return apiError("Missing id", 400);
- const source = await getSource(id)
- if (!source) return apiError('Source not found', 404)
+ const source = await getSource(id);
+ if (!source) return apiError("Source not found", 404);
- await deleteSource(id)
+ await deleteSource(id);
logAudit({
userId: ctx.session.user.id,
- action: 'clone_source_delete',
- target: 'CloneSource',
+ action: "clone_source_delete",
+ target: "CloneSource",
targetId: 0,
after: { id, name: source.name },
- })
+ });
- return apiOk({ id })
-})
+ return apiOk({ id });
+});
diff --git a/src/app/api/admin/import/clothing/batch/route.ts b/src/app/api/admin/import/clothing/batch/route.ts
index 1e6ecd15..0cd6e4cf 100644
--- a/src/app/api/admin/import/clothing/batch/route.ts
+++ b/src/app/api/admin/import/clothing/batch/route.ts
@@ -1,45 +1,45 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { ensureFigureDirs, importSingleFigure } from '@/lib/services/figure-import'
-import { runSseBatch } from '@/lib/services/import/core/sse-batch'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { ensureFigureDirs, importSingleFigure } from "@/lib/services/figure-import";
+import { runSseBatch } from "@/lib/services/import/core/sse-batch";
interface FigureBatchItem {
- lib: string
+ lib: string;
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
- const rawItems: FigureBatchItem[] = body.items || []
- const concurrency = body.concurrency || 3
- if (rawItems.length === 0) return apiError('No libraries to import', 400)
+ const body = await request.json();
+ const rawItems: FigureBatchItem[] = body.items || [];
+ const concurrency = body.concurrency || 3;
+ if (rawItems.length === 0) return apiError("No libraries to import", 400);
- const seen = new Set()
+ const seen = new Set();
const items = rawItems.filter((it) => {
- if (seen.has(it.lib)) return false
- seen.add(it.lib)
- return true
- })
+ if (seen.has(it.lib)) return false;
+ seen.add(it.lib);
+ return true;
+ });
- await ensureFigureDirs()
+ await ensureFigureDirs();
return runSseBatch({
items,
concurrency,
labelOf: (it) => it.lib,
worker: async (it, _index, report) => {
- const r = await importSingleFigure({ lib: it.lib, onProgress: report })
+ const r = await importSingleFigure({ lib: it.lib, onProgress: report });
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
- action: 'figure_import',
- target: 'FigureAsset',
+ action: "figure_import",
+ target: "FigureAsset",
targetId: 0,
after: { lib: it.lib, batch: true },
- })
+ });
}
- return { ok: r.ok, warnings: r.warnings, error: r.error }
+ return { ok: r.ok, warnings: r.warnings, error: r.error };
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/clothing/route.ts b/src/app/api/admin/import/clothing/route.ts
index 0c7c82ae..a2bad865 100644
--- a/src/app/api/admin/import/clothing/route.ts
+++ b/src/app/api/admin/import/clothing/route.ts
@@ -1,28 +1,28 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError, apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { deleteImportedFigure, getFigureList, getFigureStats } from '@/lib/services/figure-import'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { deleteImportedFigure, getFigureList, getFigureStats } from "@/lib/services/figure-import";
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- if (request.nextUrl.searchParams.get('action') === 'stats') return apiOk(await getFigureStats())
- const search = request.nextUrl.searchParams.get('search') || ''
- const page = Number.parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
- const { items, meta } = await getFigureList({ search, page: page || 1, perPage: 100 })
- return apiOk({ items, meta })
-})
+ if (request.nextUrl.searchParams.get("action") === "stats") return apiOk(await getFigureStats());
+ const search = request.nextUrl.searchParams.get("search") || "";
+ const page = Number.parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
+ const { items, meta } = await getFigureList({ search, page: page || 1, perPage: 100 });
+ return apiOk({ items, meta });
+});
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const lib = request.nextUrl.searchParams.get('lib')
- if (!lib) return apiError('Missing lib', 400)
- const { deletedFile } = await deleteImportedFigure(lib)
- if (!deletedFile) return apiError(`Library "${lib}" not found`, 404)
+ const lib = request.nextUrl.searchParams.get("lib");
+ if (!lib) return apiError("Missing lib", 400);
+ const { deletedFile } = await deleteImportedFigure(lib);
+ if (!deletedFile) return apiError(`Library "${lib}" not found`, 404);
logAudit({
userId: ctx.session.user.id,
- action: 'figure_delete',
- target: 'FigureAsset',
+ action: "figure_delete",
+ target: "FigureAsset",
targetId: 0,
after: { lib },
- })
- return apiOk({ lib, deletedFile })
-})
+ });
+ return apiOk({ lib, deletedFile });
+});
diff --git a/src/app/api/admin/import/clothing/sets/batch/route.ts b/src/app/api/admin/import/clothing/sets/batch/route.ts
index 83a0e725..0603922c 100644
--- a/src/app/api/admin/import/clothing/sets/batch/route.ts
+++ b/src/app/api/admin/import/clothing/sets/batch/route.ts
@@ -1,28 +1,28 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { importClothingSet } from '@/lib/services/clothing-set-import'
-import { runSseBatch } from '@/lib/services/import/core/sse-batch'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { importClothingSet } from "@/lib/services/clothing-set-import";
+import { runSseBatch } from "@/lib/services/import/core/sse-batch";
interface ClothingSetBatchItem {
- setType: string
- setId: number
+ setType: string;
+ setId: number;
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
- const rawItems: ClothingSetBatchItem[] = body.items || []
- const concurrency = body.concurrency || 3
- if (rawItems.length === 0) return apiError('No clothing sets to import', 400)
+ const body = await request.json();
+ const rawItems: ClothingSetBatchItem[] = body.items || [];
+ const concurrency = body.concurrency || 3;
+ if (rawItems.length === 0) return apiError("No clothing sets to import", 400);
- const seen = new Set()
+ const seen = new Set();
const items = rawItems.filter((it) => {
- const key = `${it.setType}:${it.setId}`
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
+ const key = `${it.setType}:${it.setId}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
return runSseBatch({
items,
@@ -33,17 +33,17 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
setType: it.setType,
setId: it.setId,
onProgress: report,
- })
+ });
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
- action: 'clothing_set_import',
- target: 'ClothingSet',
+ action: "clothing_set_import",
+ target: "ClothingSet",
targetId: it.setId,
after: { setType: it.setType, setId: it.setId, batch: true },
- })
+ });
}
- return { ok: r.ok, warnings: r.warnings, error: r.error }
+ return { ok: r.ok, warnings: r.warnings, error: r.error };
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/clothing/sets/route.ts b/src/app/api/admin/import/clothing/sets/route.ts
index eb22f36d..3658f480 100644
--- a/src/app/api/admin/import/clothing/sets/route.ts
+++ b/src/app/api/admin/import/clothing/sets/route.ts
@@ -1,20 +1,20 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { getClothingSetList, getClothingSetStats } from '@/lib/services/clothing-set-import'
+import { withAdmin } from "@/lib/api-handler";
+import { apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { getClothingSetList, getClothingSetStats } from "@/lib/services/clothing-set-import";
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- if (request.nextUrl.searchParams.get('action') === 'stats') {
- return apiOk((await getClothingSetStats()) as unknown as Record)
+ if (request.nextUrl.searchParams.get("action") === "stats") {
+ return apiOk((await getClothingSetStats()) as unknown as Record);
}
- const search = request.nextUrl.searchParams.get('search') || ''
- const page = Number.parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
- const setType = request.nextUrl.searchParams.get('setType') || undefined
+ const search = request.nextUrl.searchParams.get("search") || "";
+ const page = Number.parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
+ const setType = request.nextUrl.searchParams.get("setType") || undefined;
const { items, meta } = await getClothingSetList({
search,
page: page || 1,
perPage: 100,
setType,
- })
- return apiOk({ items, meta })
-})
+ });
+ return apiOk({ items, meta });
+});
diff --git a/src/app/api/admin/import/effects/batch/route.ts b/src/app/api/admin/import/effects/batch/route.ts
index 419a456b..257cb9cd 100644
--- a/src/app/api/admin/import/effects/batch/route.ts
+++ b/src/app/api/admin/import/effects/batch/route.ts
@@ -1,26 +1,26 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { ensureEffectDirs, importSingleEffect } from '@/lib/services/effect-import'
-import { runSseBatch } from '@/lib/services/import/core/sse-batch'
-import type { EffectMapEntry } from '@/types/effects'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { ensureEffectDirs, importSingleEffect } from "@/lib/services/effect-import";
+import { runSseBatch } from "@/lib/services/import/core/sse-batch";
+import type { EffectMapEntry } from "@/types/effects";
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
- const rawItems: EffectMapEntry[] = body.items || []
- const concurrency = body.concurrency || 3
- if (rawItems.length === 0) return apiError('No effects to import', 400)
+ const body = await request.json();
+ const rawItems: EffectMapEntry[] = body.items || [];
+ const concurrency = body.concurrency || 3;
+ if (rawItems.length === 0) return apiError("No effects to import", 400);
// Dedupe by lib (the .nitro filename) to avoid double work in one request.
- const seen = new Set()
+ const seen = new Set();
const items = rawItems.filter((it) => {
- if (seen.has(it.lib)) return false
- seen.add(it.lib)
- return true
- })
+ if (seen.has(it.lib)) return false;
+ seen.add(it.lib);
+ return true;
+ });
- await ensureEffectDirs()
+ await ensureEffectDirs();
return runSseBatch({
items,
@@ -33,17 +33,17 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
type: it.type,
revision: it.revision,
onProgress: report,
- })
+ });
if (r.ok) {
logAudit({
userId: ctx.session.user.id,
- action: 'effect_import',
- target: 'EffectMap',
+ action: "effect_import",
+ target: "EffectMap",
targetId: 0,
after: { id: it.id, lib: it.lib, type: it.type, batch: true },
- })
+ });
}
- return { ok: r.ok, warnings: r.warnings, error: r.error }
+ return { ok: r.ok, warnings: r.warnings, error: r.error };
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/effects/route.ts b/src/app/api/admin/import/effects/route.ts
index 856a76be..31e01462 100644
--- a/src/app/api/admin/import/effects/route.ts
+++ b/src/app/api/admin/import/effects/route.ts
@@ -1,30 +1,30 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError, apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { deleteImportedEffect, getEffectList, getEffectStats } from '@/lib/services/effect-import'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { deleteImportedEffect, getEffectList, getEffectStats } from "@/lib/services/effect-import";
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const action = request.nextUrl.searchParams.get('action')
- if (action === 'stats') {
- return apiOk(await getEffectStats())
+ const action = request.nextUrl.searchParams.get("action");
+ if (action === "stats") {
+ return apiOk(await getEffectStats());
}
- const search = request.nextUrl.searchParams.get('search') || ''
- const effects = await getEffectList(search)
- return apiOk({ effects })
-})
+ const search = request.nextUrl.searchParams.get("search") || "";
+ const effects = await getEffectList(search);
+ return apiOk({ effects });
+});
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const idOrLib = request.nextUrl.searchParams.get('lib') || request.nextUrl.searchParams.get('id')
- if (!idOrLib) return apiError('Missing lib/id', 400)
- const { removed, deletedFile } = await deleteImportedEffect(idOrLib)
- if (!removed && !deletedFile) return apiError(`Effect "${idOrLib}" not found`, 404)
+ const idOrLib = request.nextUrl.searchParams.get("lib") || request.nextUrl.searchParams.get("id");
+ if (!idOrLib) return apiError("Missing lib/id", 400);
+ const { removed, deletedFile } = await deleteImportedEffect(idOrLib);
+ if (!removed && !deletedFile) return apiError(`Effect "${idOrLib}" not found`, 404);
logAudit({
userId: ctx.session.user.id,
- action: 'effect_delete',
- target: 'EffectMap',
+ action: "effect_delete",
+ target: "EffectMap",
targetId: 0,
after: { idOrLib, removed, deletedFile },
- })
- return apiOk({ idOrLib, removed, deletedFile })
-})
+ });
+ return apiOk({ idOrLib, removed, deletedFile });
+});
diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts
index c6c2b4fb..9c740182 100644
--- a/src/app/api/admin/import/furni/batch-regen/route.ts
+++ b/src/app/api/admin/import/furni/batch-regen/route.ts
@@ -1,19 +1,19 @@
-import { existsSync, promises as fs } from 'node:fs'
-import path from 'node:path'
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
-import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
-import { appendFurniEntriesBatch, buildFurniEntry, readFurniData } from '@/lib/services/furni-data'
-import { downloadFile, ensureDirectories } from '@/lib/services/furni-import'
-import { getHabboItFurnidata } from '@/lib/services/habbo-furnidata-cache'
-import { rcon } from '@/lib/services/rcon'
-import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
+import { existsSync, promises as fs } from "node:fs";
+import path from "node:path";
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
+import { appendFurniEntriesBatch, buildFurniEntry, readFurniData } from "@/lib/services/furni-data";
+import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
+import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
+import { rcon } from "@/lib/services/rcon";
+import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
interface RegenItem {
- classname: string
- revision: number
+ classname: string;
+ revision: number;
}
/**
@@ -21,210 +21,209 @@ interface RegenItem {
* Streams progress events for each item.
*/
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const body = await request.json()
- const items: RegenItem[] = body.items || []
- const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5)
+ const body = await request.json();
+ const items: RegenItem[] = body.items || [];
+ const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
- if (items.length === 0) return apiError('No items to regenerate', 400)
+ if (items.length === 0) return apiError("No items to regenerate", 400);
- await ensureDirectories()
- const { swfDir, nitroDir } = await getFurniAssetDirs()
- const encoder = new TextEncoder()
+ await ensureDirectories();
+ const { swfDir, nitroDir } = await getFurniAssetDirs();
+ const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
/* stream closed */
}
- }
+ };
- const startTime = Date.now()
- send({ type: 'regen_start', total: items.length })
+ const startTime = Date.now();
+ send({ type: "regen_start", total: items.length });
- let succeeded = 0
- let failed = 0
- let skipped = 0
- const furniDataEntries: Array<{ entry: Record; itemType: string }> = []
+ let succeeded = 0;
+ let failed = 0;
+ let skipped = 0;
+ const furniDataEntries: Array<{ entry: Record; itemType: string }> = [];
// Load furnidata once for all items
- const furnidata = await getHabboItFurnidata()
+ const furnidata = await getHabboItFurnidata();
// Load existing FurnitureData.json classnames for dedup
const existingFD = (await readFurniData()) as {
- roomitemtypes?: { furnitype?: Array<{ classname?: string }> }
- wallitemtypes?: { furnitype?: Array<{ classname?: string }> }
- }
+ roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
+ wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
+ };
const fdClassnames = new Set([
...(existingFD.roomitemtypes?.furnitype || []).map((e) => e.classname),
...(existingFD.wallitemtypes?.furnitype || []).map((e) => e.classname),
- ])
+ ]);
for (let i = 0; i < items.length; i += concurrency) {
- const chunk = items.slice(i, i + concurrency)
+ const chunk = items.slice(i, i + concurrency);
const promises = chunk.map(async (item) => {
- const starIdx = item.classname.indexOf('*')
- const baseClassname =
- starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname
- const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`)
+ const starIdx = item.classname.indexOf("*");
+ const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname;
+ const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`);
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
- skipped++
- send({ type: 'regen_progress', classname: item.classname, status: 'skipped' })
- return
+ skipped++;
+ send({ type: "regen_progress", classname: item.classname, status: "skipped" });
+ return;
}
- send({ type: 'regen_progress', classname: item.classname, status: 'downloading' })
+ send({ type: "regen_progress", classname: item.classname, status: "downloading" });
// Resolve revision
- let rev = item.revision || 0
+ let rev = item.revision || 0;
if (rev === 0) {
const fd =
furnidata.get(item.classname) ||
furnidata.get(baseClassname) ||
- furnidata.get(`${baseClassname}*0`)
- if (fd) rev = fd.revision
+ furnidata.get(`${baseClassname}*0`);
+ if (fd) rev = fd.revision;
}
// Try spriteId as fallback revision
- const revsToTry: number[] = []
- if (rev > 0) revsToTry.push(rev)
+ const revsToTry: number[] = [];
+ if (rev > 0) revsToTry.push(rev);
const dbItem = await prisma.itemsBase.findFirst({
where: { itemName: item.classname },
select: { id: true, spriteId: true, publicName: true, type: true },
- })
+ });
if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) {
- revsToTry.push(dbItem.spriteId)
+ revsToTry.push(dbItem.spriteId);
}
if (revsToTry.length === 0) {
- failed++
+ failed++;
send({
- type: 'regen_progress',
+ type: "regen_progress",
classname: item.classname,
- status: 'failed',
- message: 'No revision found',
- })
- return
+ status: "failed",
+ message: "No revision found",
+ });
+ return;
}
- const swfPath = path.join(swfDir, `${baseClassname}.swf`)
+ const swfPath = path.join(swfDir, `${baseClassname}.swf`);
// Download SWF if needed
if (!existsSync(swfPath)) {
- let downloaded = false
+ let downloaded = false;
for (const tryRev of revsToTry) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
swfPath,
- { validate: 'swf' },
- )
+ { validate: "swf" },
+ );
if (dl.ok) {
- rev = tryRev
- downloaded = true
- break
+ rev = tryRev;
+ downloaded = true;
+ break;
}
}
if (!downloaded) {
- failed++
+ failed++;
send({
- type: 'regen_progress',
+ type: "regen_progress",
classname: item.classname,
- status: 'failed',
- message: `SWF not found (revs: ${revsToTry.join(', ')})`,
- })
- return
+ status: "failed",
+ message: `SWF not found (revs: ${revsToTry.join(", ")})`,
+ });
+ return;
}
}
// Convert
- send({ type: 'regen_progress', classname: item.classname, status: 'converting' })
+ send({ type: "regen_progress", classname: item.classname, status: "converting" });
try {
- const swfBuffer = await fs.readFile(swfPath)
- const conversion = convertSwfToNitro(swfBuffer, baseClassname)
- await fs.writeFile(nitroPath, conversion.bundle)
+ const swfBuffer = await fs.readFile(swfPath);
+ const conversion = convertSwfToNitro(swfBuffer, baseClassname);
+ await fs.writeFile(nitroPath, conversion.bundle);
// Build FurnitureData entry if missing
if (!fdClassnames.has(baseClassname) && !fdClassnames.has(item.classname)) {
- const fd = furnidata.get(item.classname) || furnidata.get(baseClassname)
- const itemType = dbItem?.type === 'i' ? 'i' : 's'
+ const fd = furnidata.get(item.classname) || furnidata.get(baseClassname);
+ const itemType = dbItem?.type === "i" ? "i" : "s";
const entry = buildFurniEntry({
id: dbItem?.spriteId || 0,
classname: baseClassname,
revision: rev,
- category: fd?.category || '',
+ category: fd?.category || "",
name: dbItem?.publicName || baseClassname,
- description: fd?.description || '',
+ description: fd?.description || "",
spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null,
habboIt: fd || null,
- })
- furniDataEntries.push({ entry, itemType })
- fdClassnames.add(baseClassname)
+ });
+ furniDataEntries.push({ entry, itemType });
+ fdClassnames.add(baseClassname);
}
- succeeded++
- send({ type: 'regen_progress', classname: item.classname, status: 'done' })
+ succeeded++;
+ send({ type: "regen_progress", classname: item.classname, status: "done" });
} catch (err) {
- failed++
+ failed++;
send({
- type: 'regen_progress',
+ type: "regen_progress",
classname: item.classname,
- status: 'failed',
+ status: "failed",
message: (err as Error).message,
- })
+ });
}
- })
+ });
- await Promise.allSettled(promises)
+ await Promise.allSettled(promises);
}
// Batch write FurnitureData.json
if (furniDataEntries.length > 0) {
try {
- await appendFurniEntriesBatch(furniDataEntries)
+ await appendFurniEntriesBatch(furniDataEntries);
send({
- type: 'regen_info',
+ type: "regen_info",
message: `Added ${furniDataEntries.length} entries to FurnitureData.json`,
- })
+ });
} catch (err) {
send({
- type: 'regen_info',
+ type: "regen_info",
message: `FurnitureData.json write failed: ${(err as Error).message}`,
- })
+ });
}
}
// RCON refresh
try {
- await rcon.updateItems()
- await rcon.updateCatalog()
+ await rcon.updateItems();
+ await rcon.updateCatalog();
} catch {
/* non-fatal */
}
send({
- type: 'regen_complete',
+ type: "regen_complete",
succeeded,
failed,
skipped,
furniDataAdded: furniDataEntries.length,
duration: Date.now() - startTime,
- })
+ });
- controller.close()
+ controller.close();
},
- })
+ });
return new Response(stream, {
headers: {
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
- Connection: 'keep-alive',
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts
index b038402f..4a44b133 100644
--- a/src/app/api/admin/import/furni/batch/route.ts
+++ b/src/app/api/admin/import/furni/batch/route.ts
@@ -1,20 +1,20 @@
-import { withAdmin } from '@/lib/api-handler'
-import { apiError } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { logAudit } from '@/lib/services/audit'
-import { appendFurniEntriesBatch } from '@/lib/services/furni-data'
-import { ensureDirectories, importSingleFurni } from '@/lib/services/furni-import'
-import { rcon } from '@/lib/services/rcon'
-import type { ImportSingleResult } from '@/types/furni'
+import { withAdmin } from "@/lib/api-handler";
+import { apiError } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { logAudit } from "@/lib/services/audit";
+import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
+import { ensureDirectories, importSingleFurni } from "@/lib/services/furni-import";
+import { rcon } from "@/lib/services/rcon";
+import type { ImportSingleResult } from "@/types/furni";
interface BatchItem {
- id: number
- classname: string
- name: string
- description: string
- type: string
- revision: number
- category: string
+ id: number;
+ classname: string;
+ name: string;
+ description: string;
+ type: string;
+ revision: number;
+ category: string;
}
/**
@@ -23,135 +23,135 @@ interface BatchItem {
* streaming real-time progress events to the client.
*/
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
- const rawItems: BatchItem[] = body.items || []
- const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5)
+ const body = await request.json();
+ const rawItems: BatchItem[] = body.items || [];
+ const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
- if (rawItems.length === 0) return apiError('No items to import', 400)
+ if (rawItems.length === 0) return apiError("No items to import", 400);
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
// items_base.id when the same item is listed twice in the request body.
- const seen = new Set()
- const items: BatchItem[] = []
+ const seen = new Set();
+ const items: BatchItem[] = [];
for (const it of rawItems) {
- const key = `${it.id}:${it.classname}`
- if (seen.has(key)) continue
- seen.add(key)
- items.push(it)
+ const key = `${it.id}:${it.classname}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ items.push(it);
}
- await ensureDirectories()
- const encoder = new TextEncoder()
+ await ensureDirectories();
+ const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
/* stream closed by client */
}
- }
+ };
- const startTime = Date.now()
- send({ type: 'batch_start', total: items.length, concurrency })
+ const startTime = Date.now();
+ send({ type: "batch_start", total: items.length, concurrency });
- let succeeded = 0
- let failed = 0
- let withWarnings = 0
- const furniDataEntries: Array<{ entry: Record; itemType: string }> = []
+ let succeeded = 0;
+ let failed = 0;
+ let withWarnings = 0;
+ const furniDataEntries: Array<{ entry: Record; itemType: string }> = [];
// Process in chunks of `concurrency`
for (let i = 0; i < items.length; i += concurrency) {
- const chunk = items.slice(i, i + concurrency)
+ const chunk = items.slice(i, i + concurrency);
const promises = chunk.map(async (item, chunkIdx) => {
- const index = i + chunkIdx
- send({ type: 'item_progress', classname: item.classname, status: 'started', index })
+ const index = i + chunkIdx;
+ send({ type: "item_progress", classname: item.classname, status: "started", index });
try {
const result: ImportSingleResult = await importSingleFurni({
id: item.id ?? 0,
classname: item.classname,
name: item.name,
- description: item.description ?? '',
- type: item.type ?? 'flooritem',
+ description: item.description ?? "",
+ type: item.type ?? "flooritem",
revision: item.revision ?? 0,
- category: item.category ?? 'unknown',
+ category: item.category ?? "unknown",
skipFurniDataWrite: true,
onProgress: (status: string) => {
- send({ type: 'item_progress', classname: item.classname, status, index })
+ send({ type: "item_progress", classname: item.classname, status, index });
},
- })
+ });
if (result.ok) {
- succeeded++
- if (result.warnings.length > 0) withWarnings++
- if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry)
+ succeeded++;
+ if (result.warnings.length > 0) withWarnings++;
+ if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry);
send({
- type: 'item_progress',
+ type: "item_progress",
classname: item.classname,
- status: 'done',
+ status: "done",
index,
itemId: result.itemId,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
- })
+ });
logAudit({
userId: ctx.session.user.id,
- action: 'furni_import',
- target: 'ItemsBase',
+ action: "furni_import",
+ target: "ItemsBase",
targetId: result.itemId!,
after: {
classname: item.classname,
name: item.name,
- type: item.type === 'wallitem' ? 'i' : 's',
+ type: item.type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
batch: true,
},
- })
+ });
} else {
- failed++
+ failed++;
send({
- type: 'item_progress',
+ type: "item_progress",
classname: item.classname,
- status: 'failed',
+ status: "failed",
index,
message: result.error,
- })
+ });
}
} catch (err) {
- failed++
+ failed++;
send({
- type: 'item_progress',
+ type: "item_progress",
classname: item.classname,
- status: 'failed',
+ status: "failed",
index,
message: (err as Error).message,
- })
+ });
}
- })
+ });
- await Promise.allSettled(promises)
+ await Promise.allSettled(promises);
}
// Batch write FurnitureData.json once at the end — BEFORE RCON so the
// emulator never sees items that the Nitro client can't render.
- let furniDataWriteOk = true
+ let furniDataWriteOk = true;
if (furniDataEntries.length > 0) {
try {
- await appendFurniEntriesBatch(furniDataEntries)
+ await appendFurniEntriesBatch(furniDataEntries);
} catch (err) {
- furniDataWriteOk = false
+ furniDataWriteOk = false;
send({
- type: 'item_progress',
- classname: '_batch_',
- status: 'failed',
+ type: "item_progress",
+ classname: "_batch_",
+ status: "failed",
index: -1,
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
- })
+ });
}
}
@@ -159,39 +159,39 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
// to avoid an inconsistent state (emulator sees items, client doesn't).
if (furniDataWriteOk) {
try {
- const okCat = await rcon.updateCatalog()
- const okItems = await rcon.updateItems()
+ const okCat = await rcon.updateCatalog();
+ const okItems = await rcon.updateItems();
if (!okCat || !okItems) {
send({
- type: 'item_progress',
- classname: '_rcon_',
- status: 'failed',
+ type: "item_progress",
+ classname: "_rcon_",
+ status: "failed",
index: -1,
- message: 'RCON refresh failed — emulator cache may be stale',
- })
+ message: "RCON refresh failed — emulator cache may be stale",
+ });
}
} catch (err) {
- console.warn('[import-furni] RCON update failed after batch:', (err as Error).message)
+ console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
}
}
send({
- type: 'batch_complete',
+ type: "batch_complete",
succeeded,
failed,
warnings: withWarnings,
duration: Date.now() - startTime,
- })
+ });
- controller.close()
+ controller.close();
},
- })
+ });
return new Response(stream, {
headers: {
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
- Connection: 'keep-alive',
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
},
- })
-})
+ });
+});
diff --git a/src/app/api/admin/import/furni/nitro-editor/route.ts b/src/app/api/admin/import/furni/nitro-editor/route.ts
index 839de288..f7128371 100644
--- a/src/app/api/admin/import/furni/nitro-editor/route.ts
+++ b/src/app/api/admin/import/furni/nitro-editor/route.ts
@@ -1,165 +1,161 @@
-import { existsSync, promises as fs } from 'node:fs'
-import path from 'node:path'
-import type { NextRequest } from 'next/server'
-import { withAdmin } from '@/lib/api-handler'
-import { apiError, apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
-import { logAudit } from '@/lib/services/audit'
-import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
-import { readFurniData, withFurniDataLock, writeFurniData } from '@/lib/services/furni-data'
-import { rcon } from '@/lib/services/rcon'
+import { existsSync, promises as fs } from "node:fs";
+import path from "node:path";
+import type { NextRequest } from "next/server";
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+import { logAudit } from "@/lib/services/audit";
+import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
+import { readFurniData, withFurniDataLock, writeFurniData } from "@/lib/services/furni-data";
+import { rcon } from "@/lib/services/rcon";
import {
computeDefaultDir,
createNitroBundle,
extractPartColors,
parseNitroBundle,
-} from '@/lib/services/swf-to-nitro'
+} from "@/lib/services/swf-to-nitro";
function baseClassname(classname: string): string {
- return classname.includes('*') ? classname.split('*')[0] : classname
+ return classname.includes("*") ? classname.split("*")[0] : classname;
}
// ── GET: Read .nitro metadata ──────────────────────────────────────
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest) => {
- const rawClassname = request.nextUrl.searchParams.get('classname')
- if (!rawClassname) return apiError('Missing classname', 400)
+ const rawClassname = request.nextUrl.searchParams.get("classname");
+ if (!rawClassname) return apiError("Missing classname", 400);
- const base = baseClassname(rawClassname)
- const { nitroDir } = await getFurniAssetDirs()
- const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
+ const base = baseClassname(rawClassname);
+ const { nitroDir } = await getFurniAssetDirs();
+ const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
- if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError('Nitro file not found', 404)
+ if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError("Nitro file not found", 404);
try {
- const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath)
- const { json } = parseNitroBundle(buffer)
+ const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
+ const { json } = parseNitroBundle(buffer);
// Fetch interaction flags from DB
const items = await prisma.$queryRaw<
Array<{
- allow_sit: string
- allow_lay: string
- allow_walk: string
+ allow_sit: string;
+ allow_lay: string;
+ allow_walk: string;
}>
>`
SELECT allow_sit, allow_lay, allow_walk
FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
- `
+ `;
- const dbRow = items[0]
+ const dbRow = items[0];
const flags = {
- canstandon: dbRow?.allow_walk === '1',
- cansiton: dbRow?.allow_sit === '1',
- canlayon: dbRow?.allow_lay === '1',
- }
+ canstandon: dbRow?.allow_walk === "1",
+ cansiton: dbRow?.allow_sit === "1",
+ canlayon: dbRow?.allow_lay === "1",
+ };
- return apiOk({ metadata: json, flags })
+ return apiOk({ metadata: json, flags });
} catch (err) {
- console.error('[nitro-editor] Failed to parse .nitro:', (err as Error).message)
- return apiError('Failed to parse .nitro file', 500)
+ console.error("[nitro-editor] Failed to parse .nitro:", (err as Error).message);
+ return apiError("Failed to parse .nitro file", 500);
}
-})
+});
// ── PUT: Save .nitro metadata ──────────────────────────────────────
-export const PUT = withAdmin(
- { permission: PERMS.ASSETS_IMPORT },
- async (request: NextRequest, ctx) => {
- const body = await request.json()
- const { classname: rawClassname, metadata, canstandon, cansiton, canlayon } = body
+export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest, ctx) => {
+ const body = await request.json();
+ const { classname: rawClassname, metadata, canstandon, cansiton, canlayon } = body;
- if (!rawClassname || !metadata) return apiError('Missing classname or metadata', 400)
+ if (!rawClassname || !metadata) return apiError("Missing classname or metadata", 400);
- const base = baseClassname(rawClassname)
- const { nitroDir } = await getFurniAssetDirs()
- const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
+ const base = baseClassname(rawClassname);
+ const { nitroDir } = await getFurniAssetDirs();
+ const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
- if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
- return apiError('Nitro file not found', 404)
+ if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError("Nitro file not found", 404);
- // 1. Read existing .nitro to extract PNG
- const existingBuffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath)
- const { png } = parseNitroBundle(existingBuffer)
+ // 1. Read existing .nitro to extract PNG
+ const existingBuffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
+ const { png } = parseNitroBundle(existingBuffer);
- // 2. Rebuild .nitro with updated JSON + same PNG
- const newBundle = createNitroBundle(metadata, png, base)
- await fs.writeFile(nitroPath, newBundle)
+ // 2. Rebuild .nitro with updated JSON + same PNG
+ const newBundle = createNitroBundle(metadata, png, base);
+ await fs.writeFile(nitroPath, newBundle);
- // 3. Extract dimensions from metadata
- const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 }
- const directions: number[] = metadata.logic?.model?.directions || [0]
- const visualizations = metadata.visualizations || []
+ // 3. Extract dimensions from metadata
+ const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
+ const directions: number[] = metadata.logic?.model?.directions || [0];
+ const visualizations = metadata.visualizations || [];
- // 4. Update FurnitureData.json (best-effort)
- try {
- await withFurniDataLock(async () => {
- const furniData = (await readFurniData()) as Record<
- string,
- { furnitype: Array> }
- >
+ // 4. Update FurnitureData.json (best-effort)
+ try {
+ await withFurniDataLock(async () => {
+ const furniData = (await readFurniData()) as Record<
+ string,
+ { furnitype: Array> }
+ >;
- for (const section of ['roomitemtypes', 'wallitemtypes'] as const) {
- const types = furniData[section]?.furnitype
- if (!Array.isArray(types)) continue
- const idx = types.findIndex((e) => e.classname === rawClassname)
- if (idx !== -1) {
- types[idx].xdim = dims.x
- types[idx].ydim = dims.y
- types[idx].defaultdir = computeDefaultDir(directions)
- types[idx].partcolors = extractPartColors(visualizations)
- types[idx].canstandon = canstandon ?? false
- types[idx].cansiton = cansiton ?? false
- types[idx].canlayon = canlayon ?? false
- break
- }
+ for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
+ const types = furniData[section]?.furnitype;
+ if (!Array.isArray(types)) continue;
+ const idx = types.findIndex((e) => e.classname === rawClassname);
+ if (idx !== -1) {
+ types[idx].xdim = dims.x;
+ types[idx].ydim = dims.y;
+ types[idx].defaultdir = computeDefaultDir(directions);
+ types[idx].partcolors = extractPartColors(visualizations);
+ types[idx].canstandon = canstandon ?? false;
+ types[idx].cansiton = cansiton ?? false;
+ types[idx].canlayon = canlayon ?? false;
+ break;
}
+ }
- await writeFurniData(furniData as Record)
- })
- } catch (err) {
- console.warn('[nitro-editor] FurnitureData.json update failed:', (err as Error).message)
- }
+ await writeFurniData(furniData as Record);
+ });
+ } catch (err) {
+ console.warn("[nitro-editor] FurnitureData.json update failed:", (err as Error).message);
+ }
- // 5. Update items_base (best-effort)
- try {
- await prisma.$executeRaw`
+ // 5. Update items_base (best-effort)
+ try {
+ await prisma.$executeRaw`
UPDATE items_base
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z},
- allow_sit = ${cansiton ? '1' : '0'},
- allow_lay = ${canlayon ? '1' : '0'},
- allow_walk = ${canstandon ? '1' : '0'}
+ allow_sit = ${cansiton ? "1" : "0"},
+ allow_lay = ${canlayon ? "1" : "0"},
+ allow_walk = ${canstandon ? "1" : "0"}
WHERE item_name = ${rawClassname}
- `
- } catch (err) {
- console.warn('[nitro-editor] items_base update failed:', (err as Error).message)
- }
+ `;
+ } catch (err) {
+ console.warn("[nitro-editor] items_base update failed:", (err as Error).message);
+ }
- // 6. RCON refresh (best-effort)
- try {
- await rcon.updateItems()
- } catch (err) {
- console.warn('[nitro-editor] RCON updateItems failed:', (err as Error).message)
- }
+ // 6. RCON refresh (best-effort)
+ try {
+ await rcon.updateItems();
+ } catch (err) {
+ console.warn("[nitro-editor] RCON updateItems failed:", (err as Error).message);
+ }
- // 7. Audit log
- try {
- const dbItem = await prisma.itemsBase.findFirst({
- where: { itemName: rawClassname },
- select: { id: true },
- })
- await logAudit({
- userId: ctx.session.user.id,
- action: 'furni_edit_nitro',
- target: 'ItemsBase',
- targetId: dbItem?.id ?? 0,
- after: { classname: rawClassname, dimensions: dims, canstandon, cansiton, canlayon },
- })
- } catch (err) {
- console.warn('[nitro-editor] Audit log failed:', (err as Error).message)
- }
+ // 7. Audit log
+ try {
+ const dbItem = await prisma.itemsBase.findFirst({
+ where: { itemName: rawClassname },
+ select: { id: true },
+ });
+ await logAudit({
+ userId: ctx.session.user.id,
+ action: "furni_edit_nitro",
+ target: "ItemsBase",
+ targetId: dbItem?.id ?? 0,
+ after: { classname: rawClassname, dimensions: dims, canstandon, cansiton, canlayon },
+ });
+ } catch (err) {
+ console.warn("[nitro-editor] Audit log failed:", (err as Error).message);
+ }
- return apiOk({ dimensions: dims })
- },
-)
+ return apiOk({ dimensions: dims });
+});
diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts
index ce91f504..b9ac771b 100644
--- a/src/app/api/admin/import/furni/resync/route.ts
+++ b/src/app/api/admin/import/furni/resync/route.ts
@@ -1,16 +1,16 @@
-import { existsSync } from 'node:fs'
-import { withAdmin } from '@/lib/api-handler'
-import { apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
+import { existsSync } from "node:fs";
+import { withAdmin } from "@/lib/api-handler";
+import { apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
import {
appendFurniEntriesBatch,
buildFurniEntry,
getFurnitureDataPath,
readFurniData,
-} from '@/lib/services/furni-data'
-import { lookupHabboIt } from '@/lib/services/habbo-furnidata-cache'
-import { rcon } from '@/lib/services/rcon'
+} from "@/lib/services/furni-data";
+import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
+import { rcon } from "@/lib/services/rcon";
/**
* POST /api/admin/import/furni/resync
@@ -27,38 +27,38 @@ import { rcon } from '@/lib/services/rcon'
* Triggers RCON updateCatalog + updateItems at the end.
*/
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const qp = request.nextUrl.searchParams
- const mode: 'days' | 'missing' | 'broken' | 'all' =
- qp.get('all') === '1'
- ? 'all'
- : qp.get('broken') === '1'
- ? 'broken'
- : qp.get('missing') === '1'
- ? 'missing'
- : 'days'
+ const qp = request.nextUrl.searchParams;
+ const mode: "days" | "missing" | "broken" | "all" =
+ qp.get("all") === "1"
+ ? "all"
+ : qp.get("broken") === "1"
+ ? "broken"
+ : qp.get("missing") === "1"
+ ? "missing"
+ : "days";
- const days = Math.max(1, Math.min(parseInt(qp.get('days') || '7', 10) || 7, 365))
+ const days = Math.max(1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365));
- const targetIds = await resolveTargetIds(mode, days)
+ const targetIds = await resolveTargetIds(mode, days);
if (targetIds.length === 0) {
return apiOk({
mode,
- days: mode === 'days' ? days : undefined,
+ days: mode === "days" ? days : undefined,
examined: 0,
resynced: 0,
failed: 0,
errors: [],
- })
+ });
}
// Process in chunks to keep memory flat and make progress visible.
- const CHUNK = 500
- let resynced = 0
- const errors: Array<{ classname: string; message: string }> = []
+ const CHUNK = 500;
+ let resynced = 0;
+ const errors: Array<{ classname: string; message: string }> = [];
for (let i = 0; i < targetIds.length; i += CHUNK) {
- const slice = targetIds.slice(i, i + CHUNK)
+ const slice = targetIds.slice(i, i + CHUNK);
const rows = await prisma.itemsBase.findMany({
where: { id: { in: slice } },
select: {
@@ -71,15 +71,15 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
length: true,
stackHeight: true,
},
- })
+ });
- const entries: Array<{ entry: Record; itemType: string }> = []
+ const entries: Array<{ entry: Record; itemType: string }> = [];
for (const row of rows) {
try {
- const classname = row.itemName
- let habboIt = null
+ const classname = row.itemName;
+ let habboIt = null;
try {
- habboIt = await lookupHabboIt(classname)
+ habboIt = await lookupHabboIt(classname);
} catch {
/* optional */
}
@@ -88,108 +88,108 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
id: row.id,
classname,
revision: habboIt?.revision ?? 0,
- category: habboIt?.category || 'unknown',
+ category: habboIt?.category || "unknown",
name: row.publicName || habboIt?.name || classname,
- description: habboIt?.description || '',
+ description: habboIt?.description || "",
spriteId: row.spriteId,
dims: { x: row.width, y: row.length, z: Math.round(Number(row.stackHeight)) },
metadata: null,
habboIt,
- })
+ });
- entries.push({ entry, itemType: row.type === 'i' ? 'i' : 's' })
+ entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
} catch (err) {
- errors.push({ classname: row.itemName, message: (err as Error).message })
+ errors.push({ classname: row.itemName, message: (err as Error).message });
}
}
if (entries.length > 0) {
try {
- await appendFurniEntriesBatch(entries)
- resynced += entries.length
+ await appendFurniEntriesBatch(entries);
+ resynced += entries.length;
} catch (err) {
errors.push({
classname: `_chunk_${i}_${i + slice.length}_`,
message: `FurnitureData.json write failed: ${(err as Error).message}`,
- })
- break
+ });
+ break;
}
}
}
- let rconOk = false
+ let rconOk = false;
try {
- const a = await rcon.updateCatalog()
- const b = await rcon.updateItems()
- rconOk = a && b
+ const a = await rcon.updateCatalog();
+ const b = await rcon.updateItems();
+ rconOk = a && b;
} catch {
/* logged in rcon service */
}
return apiOk({
mode,
- days: mode === 'days' ? days : undefined,
+ days: mode === "days" ? days : undefined,
examined: targetIds.length,
resynced,
failed: errors.length,
rconOk,
errors: errors.slice(0, 50),
- })
-})
+ });
+});
async function resolveTargetIds(
- mode: 'days' | 'missing' | 'broken' | 'all',
+ mode: "days" | "missing" | "broken" | "all",
days: number,
): Promise {
- if (mode === 'all') {
- const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: 'asc' } })
- return rows.map((r) => r.id)
+ if (mode === "all") {
+ const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" } });
+ return rows.map((r) => r.id);
}
- if (mode === 'missing' || mode === 'broken') {
- const furniDataPath = await getFurnitureDataPath()
+ if (mode === "missing" || mode === "broken") {
+ const furniDataPath = await getFurnitureDataPath();
if (!existsSync(furniDataPath)) {
- const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: 'asc' } })
- return rows.map((r) => r.id)
+ const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" } });
+ return rows.map((r) => r.id);
}
const furniData = (await readFurniData()) as {
- roomitemtypes?: { furnitype: Array> }
- wallitemtypes?: { furnitype: Array> }
- }
- const byClassname = new Map>()
+ roomitemtypes?: { furnitype: Array> };
+ wallitemtypes?: { furnitype: Array> };
+ };
+ const byClassname = new Map>();
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
for (const e of section?.furnitype ?? []) {
- if (typeof e?.classname === 'string') byClassname.set(e.classname, e)
+ if (typeof e?.classname === "string") byClassname.set(e.classname, e);
}
}
const rows = await prisma.itemsBase.findMany({
select: { id: true, itemName: true, spriteId: true, width: true, length: true },
- orderBy: { id: 'asc' },
- })
+ orderBy: { id: "asc" },
+ });
- if (mode === 'missing') {
- return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id)
+ if (mode === "missing") {
+ return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
}
// broken: entry present but inconsistent with items_base
return rows
.filter((r) => {
- const e = byClassname.get(r.itemName)
- if (!e) return true
- const entryId = Number(e.id ?? -1)
- if (entryId !== r.id && entryId !== r.spriteId) return true
- const xdim = Number(e.xdim ?? 0)
- const ydim = Number(e.ydim ?? 0)
- if (xdim <= 0 || ydim <= 0) return true
- if (xdim !== r.width || ydim !== r.length) return true
- return false
+ const e = byClassname.get(r.itemName);
+ if (!e) return true;
+ const entryId = Number(e.id ?? -1);
+ if (entryId !== r.id && entryId !== r.spriteId) return true;
+ const xdim = Number(e.xdim ?? 0);
+ const ydim = Number(e.ydim ?? 0);
+ if (xdim <= 0 || ydim <= 0) return true;
+ if (xdim !== r.width || ydim !== r.length) return true;
+ return false;
})
- .map((r) => r.id)
+ .map((r) => r.id);
}
// mode === 'days'
- const sinceIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
- const sinceMysql = sinceIso.slice(0, 19).replace('T', ' ')
+ const sinceIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
+ const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
const auditRows = await prisma.$queryRaw>`
SELECT DISTINCT target_id
FROM admin_audit_log
@@ -198,6 +198,6 @@ async function resolveTargetIds(
AND target_id IS NOT NULL
AND created_at >= ${sinceMysql}
ORDER BY target_id ASC
- `
- return auditRows.map((r) => r.target_id).filter(Boolean)
+ `;
+ return auditRows.map((r) => r.target_id).filter(Boolean);
}
diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts
index e939d7c0..8b7c661f 100644
--- a/src/app/api/admin/import/furni/route.ts
+++ b/src/app/api/admin/import/furni/route.ts
@@ -1,11 +1,11 @@
-import { existsSync, promises as fs } from 'node:fs'
-import path from 'node:path'
-import { withAdmin } from '@/lib/api-handler'
-import { apiError, apiOk } from '@/lib/api-response'
-import { PERMS } from '@/lib/permissions'
-import { prisma } from '@/lib/prisma'
-import { logAudit } from '@/lib/services/audit'
-import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
+import { existsSync, promises as fs } from "node:fs";
+import path from "node:path";
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import { PERMS } from "@/lib/permissions";
+import { prisma } from "@/lib/prisma";
+import { logAudit } from "@/lib/services/audit";
+import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
autoPriceFurni,
CATEGORY_PAGE,
@@ -16,56 +16,56 @@ import {
ensureDirectories,
IMPORTED_PAGE_CAPTION_SAVE,
importSingleFurni,
-} from '@/lib/services/furni-import'
-import { getHabboItFurnidata } from '@/lib/services/habbo-furnidata-cache'
-import { listFurniture } from '@/lib/services/habbofurni'
-import { rcon } from '@/lib/services/rcon'
-import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
+} from "@/lib/services/furni-import";
+import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
+import { listFurniture } from "@/lib/services/habbofurni";
+import { rcon } from "@/lib/services/rcon";
+import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
// Re-export for batch route backward compatibility
-export { ensureDirectories, importSingleFurni } from '@/lib/services/furni-import'
-export type { ImportSingleResult } from '@/types/furni'
+export { ensureDirectories, importSingleFurni } from "@/lib/services/furni-import";
+export type { ImportSingleResult } from "@/types/furni";
// ── Delete imported item or clean up SWF files ──────────────────────
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const classname = request.nextUrl.searchParams.get('classname')
+ const classname = request.nextUrl.searchParams.get("classname");
// ── Delete specific imported item ─────────────────────────────────
if (classname) {
try {
- const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname)
+ const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname);
// Refresh game server caches
try {
- await rcon.updateCatalog()
- await rcon.updateItems()
+ await rcon.updateCatalog();
+ await rcon.updateItems();
} catch (err) {
- errors.push(`RCON: ${(err as Error).message}`)
+ errors.push(`RCON: ${(err as Error).message}`);
}
logAudit({
userId: ctx.session.user.id,
- action: 'furni_delete',
- target: 'ItemsBase',
+ action: "furni_delete",
+ target: "ItemsBase",
targetId: spriteId,
after: { classname, deletedFiles, errors: errors.length > 0 ? errors : undefined },
- })
+ });
return apiOk({
classname,
deletedFiles,
errors: errors.length > 0 ? errors : undefined,
- })
+ });
} catch (err) {
- return apiError((err as Error).message, 404)
+ return apiError((err as Error).message, 404);
}
}
// ── Clean up SWF files that have been converted to Nitro ──────────
- const result = await cleanupConvertedSwfs()
+ const result = await cleanupConvertedSwfs();
if (result.total === 0 && result.deleted === 0) {
- return apiOk({ deleted: 0, message: 'SWF directory does not exist' })
+ return apiOk({ deleted: 0, message: "SWF directory does not exist" });
}
return apiOk({
@@ -73,164 +73,162 @@ export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (requ
total: result.total,
remaining: result.remaining,
errors: result.errors.length > 0 ? result.errors : undefined,
- })
-})
+ });
+});
// ── Search furni via official Habbo furnidata ───────────────────────
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const { nitroDir } = await getFurniAssetDirs()
+ const { nitroDir } = await getFurniAssetDirs();
// Stats endpoint
- const action = request.nextUrl.searchParams.get('action')
- if (action === 'stats') {
+ const action = request.nextUrl.searchParams.get("action");
+ if (action === "stats") {
const [totalResult, catalogResult, nitroMissingResult] = await Promise.all([
prisma.$queryRaw<[{ cnt: bigint }]>`SELECT COUNT(*) as cnt FROM items_base`,
- prisma.$queryRaw<
- [{ cnt: bigint }]
- >`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
+ prisma.$queryRaw<[{ cnt: bigint }]>`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
prisma.$queryRaw<
[{ cnt: bigint }]
>`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`,
- ])
+ ]);
- const totalInDb = Number(totalResult[0].cnt)
- const inCatalog = Number(catalogResult[0].cnt)
- const notInCatalog = Number(nitroMissingResult[0].cnt)
+ const totalInDb = Number(totalResult[0].cnt);
+ const inCatalog = Number(catalogResult[0].cnt);
+ const notInCatalog = Number(nitroMissingResult[0].cnt);
// Count missing .nitro files by checking filesystem
const importedItems = await prisma.$queryRaw>`
SELECT ib.item_name FROM items_base ib
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
LIMIT 10000
- `
+ `;
- let missingNitro = 0
+ let missingNitro = 0;
for (const item of importedItems) {
- const starIdx = item.item_name.indexOf('*')
- const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name
- const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
- if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++
+ const starIdx = item.item_name.indexOf("*");
+ const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name;
+ const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
+ if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++;
}
- return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro })
+ return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro });
}
// Missing nitro endpoint: returns only imported items missing .nitro files
- if (action === 'missing-nitro') {
- const pageNum = parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
- const perPage = 50
+ if (action === "missing-nitro") {
+ const pageNum = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
+ const perPage = 50;
const importedItems = await prisma.$queryRaw<
Array<{
- item_name: string
- public_name: string
- sprite_id: number
- type: string
+ item_name: string;
+ public_name: string;
+ sprite_id: number;
+ type: string;
}>
>`
SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
- `
+ `;
// Filter to only those missing .nitro on filesystem
const missing = importedItems.filter((item) => {
- const starIdx = item.item_name.indexOf('*')
- const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name
- const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
- return !existsSync(/*turbopackIgnore: true*/ p)
- })
+ const starIdx = item.item_name.indexOf("*");
+ const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name;
+ const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
+ return !existsSync(/*turbopackIgnore: true*/ p);
+ });
- const total = missing.length
- const lastPage = Math.max(Math.ceil(total / perPage), 1)
- const start = (pageNum - 1) * perPage
- const pageItems = missing.slice(start, start + perPage)
+ const total = missing.length;
+ const lastPage = Math.max(Math.ceil(total / perPage), 1);
+ const start = (pageNum - 1) * perPage;
+ const pageItems = missing.slice(start, start + perPage);
// Enrich with revision from official furnidata
- const furnidata = await getHabboItFurnidata()
+ const furnidata = await getHabboItFurnidata();
const items = pageItems.map((item) => {
const fd =
furnidata.get(item.item_name) ||
- furnidata.get(item.item_name.split('*')[0]) ||
- furnidata.get(`${item.item_name}*0`)
+ furnidata.get(item.item_name.split("*")[0]) ||
+ furnidata.get(`${item.item_name}*0`);
return {
id: item.sprite_id,
classname: item.item_name,
name: item.public_name || item.item_name,
- description: fd?.description || '',
- type: item.type === 'i' ? 'wallitem' : 'flooritem',
+ description: fd?.description || "",
+ type: item.type === "i" ? "wallitem" : "flooritem",
revision: fd?.revision || 0,
- category: fd?.category || fd?.furniline || '',
+ category: fd?.category || fd?.furniline || "",
alreadyImported: true,
nitroExists: false,
- }
- })
+ };
+ });
return apiOk({
items,
meta: { currentPage: pageNum, lastPage, total, perPage },
- })
+ });
}
- const search = request.nextUrl.searchParams.get('search') || ''
- const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
+ const search = request.nextUrl.searchParams.get("search") || "";
+ const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
// Pull every imported classname once so we can both (a) sort non-imported
// items to the top *before* pagination and (b) skip the per-page lookup
// below. Single-column indexed query — fast even at 10k+ rows.
const allImported = await prisma.itemsBase.findMany({
select: { itemName: true },
- })
- const importedSet = new Set(allImported.map((e) => e.itemName).filter((n): n is string => !!n))
+ });
+ const importedSet = new Set(allImported.map((e) => e.itemName).filter((n): n is string => !!n));
const result = await listFurniture({
search: search || undefined,
page,
perPage: 50,
importedSet,
- })
+ });
const enriched = result.items.map((item) => {
- const imported = importedSet.has(item.classname)
- let nitroExists = false
+ const imported = importedSet.has(item.classname);
+ let nitroExists = false;
if (imported) {
- const starIdx = item.classname.indexOf('*')
- const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname
- const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
- nitroExists = existsSync(/*turbopackIgnore: true*/ p)
+ const starIdx = item.classname.indexOf("*");
+ const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname;
+ const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
+ nitroExists = existsSync(/*turbopackIgnore: true*/ p);
}
- return { ...item, alreadyImported: imported, nitroExists }
- })
+ return { ...item, alreadyImported: imported, nitroExists };
+ });
- return apiOk({ items: enriched, meta: result.meta })
-})
+ return apiOk({ items: enriched, meta: result.meta });
+});
// ── Import furni (single or batch) ──────────────────────────────────
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const body = await request.json()
+ const body = await request.json();
- await ensureDirectories()
+ await ensureDirectories();
// ── Batch import ─────────────────────────────────────────────────
if (Array.isArray(body.items)) {
const results: Array<{
- classname: string
- ok: boolean
- itemId?: number
- catalogItemId?: number | null
- dimensions?: { x: number; y: number; z: number }
- spriteCount?: number
- warnings: string[]
- error?: string
- }> = []
+ classname: string;
+ ok: boolean;
+ itemId?: number;
+ catalogItemId?: number | null;
+ dimensions?: { x: number; y: number; z: number };
+ spriteCount?: number;
+ warnings: string[];
+ error?: string;
+ }> = [];
for (const item of body.items) {
if (!item.classname || !item.name) {
results.push({
- classname: item.classname || 'unknown',
+ classname: item.classname || "unknown",
ok: false,
warnings: [],
- error: 'Missing classname or name',
- })
- continue
+ error: "Missing classname or name",
+ });
+ continue;
}
try {
@@ -238,30 +236,30 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
id: item.id ?? 0,
classname: item.classname,
name: item.name,
- description: item.description ?? '',
- type: item.type ?? 'flooritem',
+ description: item.description ?? "",
+ type: item.type ?? "flooritem",
revision: item.revision ?? 0,
- category: item.category ?? 'unknown',
- })
+ category: item.category ?? "unknown",
+ });
- results.push({ classname: item.classname, ...result })
+ results.push({ classname: item.classname, ...result });
if (result.ok) {
logAudit({
userId: ctx.session.user.id,
- action: 'furni_import',
- target: 'ItemsBase',
+ action: "furni_import",
+ target: "ItemsBase",
targetId: result.itemId!,
after: {
classname: item.classname,
name: item.name,
- type: item.type === 'wallitem' ? 'i' : 's',
+ type: item.type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
batch: true,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
},
- })
+ });
}
} catch (err) {
results.push({
@@ -269,66 +267,66 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
ok: false,
warnings: [],
error: (err as Error).message,
- })
+ });
}
}
// Refresh caches once after batch
try {
- await rcon.updateCatalog()
- await rcon.updateItems()
+ await rcon.updateCatalog();
+ await rcon.updateItems();
} catch (err) {
- console.warn('[import-furni] RCON update failed after batch:', (err as Error).message)
+ console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
}
- const succeeded = results.filter((r) => r.ok).length
- const failed = results.filter((r) => !r.ok).length
+ const succeeded = results.filter((r) => r.ok).length;
+ const failed = results.filter((r) => !r.ok).length;
- return apiOk({ batch: true, succeeded, failed, results })
+ return apiOk({ batch: true, succeeded, failed, results });
}
// ── Single import ────────────────────────────────────────────────
- const { id, classname, name, description, type, revision, category } = body
+ const { id, classname, name, description, type, revision, category } = body;
- if (!classname || !name) return apiError('Missing classname or name', 400)
+ if (!classname || !name) return apiError("Missing classname or name", 400);
const result = await importSingleFurni({
id: id ?? 0,
classname,
name,
- description: description ?? '',
- type: type ?? 'flooritem',
+ description: description ?? "",
+ type: type ?? "flooritem",
revision: revision ?? 0,
- category: category ?? 'unknown',
- })
+ category: category ?? "unknown",
+ });
- if (!result.ok) return apiError(result.error!, 409)
+ if (!result.ok) return apiError(result.error!, 409);
// Refresh game server caches
try {
- await rcon.updateCatalog()
- await rcon.updateItems()
+ await rcon.updateCatalog();
+ await rcon.updateItems();
} catch (err) {
- console.warn('[import-furni] RCON update failed:', (err as Error).message)
- result.warnings.push('RCON cache refresh failed')
+ console.warn("[import-furni] RCON update failed:", (err as Error).message);
+ result.warnings.push("RCON cache refresh failed");
}
logAudit({
userId: ctx.session.user.id,
- action: 'furni_import',
- target: 'ItemsBase',
+ action: "furni_import",
+ target: "ItemsBase",
targetId: result.itemId!,
after: {
classname,
name,
spriteId: id ?? 0,
- type: type === 'wallitem' ? 'i' : 's',
+ type: type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
},
- })
+ });
return apiOk({
id: result.itemId,
@@ -336,44 +334,42 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
- })
-})
+ });
+});
// ── Re-generate missing .nitro files (PATCH) ─────────────────────────
export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
- const body = await request.json()
- const items: Array<{ classname: string; revision: number }> = body.items || []
+ const body = await request.json();
+ const items: Array<{ classname: string; revision: number }> = body.items || [];
- if (items.length === 0) return apiError('No items provided', 400)
+ if (items.length === 0) return apiError("No items provided", 400);
- await ensureDirectories()
- const { swfDir, nitroDir } = await getFurniAssetDirs()
+ await ensureDirectories();
+ const { swfDir, nitroDir } = await getFurniAssetDirs();
- const results: Array<{ classname: string; ok: boolean; warning?: string }> = []
+ const results: Array<{ classname: string; ok: boolean; warning?: string }> = [];
for (const item of items) {
- const starIdx = item.classname.indexOf('*')
- const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname
- const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`)
+ const starIdx = item.classname.indexOf("*");
+ const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname;
+ const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`);
// Skip if .nitro already exists
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
- results.push({ classname: item.classname, ok: true, warning: 'Already exists' })
- continue
+ results.push({ classname: item.classname, ok: true, warning: "Already exists" });
+ continue;
}
// Resolve revision: try furnidata first, then spriteId as fallback
- let rev = item.revision || 0
+ let rev = item.revision || 0;
if (rev === 0) {
- const furnidata = await getHabboItFurnidata()
+ const furnidata = await getHabboItFurnidata();
const fd =
- furnidata.get(item.classname) ||
- furnidata.get(baseClassname) ||
- furnidata.get(`${baseClassname}*0`)
- if (fd) rev = fd.revision
+ furnidata.get(item.classname) || furnidata.get(baseClassname) || furnidata.get(`${baseClassname}*0`);
+ if (fd) rev = fd.revision;
}
- const swfPath = path.join(swfDir, `${baseClassname}.swf`)
+ const swfPath = path.join(swfDir, `${baseClassname}.swf`);
// Download SWF if not present — try multiple revision candidates
if (!existsSync(swfPath)) {
@@ -381,20 +377,20 @@ export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reque
const spriteId = await prisma.itemsBase.findFirst({
where: { itemName: item.classname },
select: { spriteId: true },
- })
- const revsToTry = [...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0))]
+ });
+ const revsToTry = [...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0))];
- let downloaded = false
+ let downloaded = false;
for (const tryRev of revsToTry) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
swfPath,
- { validate: 'swf' },
- )
+ { validate: "swf" },
+ );
if (dl.ok) {
- rev = tryRev
- downloaded = true
- break
+ rev = tryRev;
+ downloaded = true;
+ break;
}
}
@@ -402,231 +398,225 @@ export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reque
results.push({
classname: item.classname,
ok: false,
- warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(', ') || 'none'})`,
- })
- continue
+ warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(", ") || "none"})`,
+ });
+ continue;
}
}
// Convert SWF → Nitro + update FurnitureData.json
try {
- const swfBuffer = await fs.readFile(swfPath)
- const conversion = convertSwfToNitro(swfBuffer, baseClassname)
- await fs.writeFile(nitroPath, conversion.bundle)
+ const swfBuffer = await fs.readFile(swfPath);
+ const conversion = convertSwfToNitro(swfBuffer, baseClassname);
+ await fs.writeFile(nitroPath, conversion.bundle);
// Add to FurnitureData.json if not already present
try {
- const { buildFurniEntry, appendFurniEntry, readFurniData } = await import(
- '@/lib/services/furni-data'
- )
+ const { buildFurniEntry, appendFurniEntry, readFurniData } =
+ await import("@/lib/services/furni-data");
const furniData = (await readFurniData()) as {
- roomitemtypes?: { furnitype?: Array<{ classname?: string }> }
- wallitemtypes?: { furnitype?: Array<{ classname?: string }> }
- }
+ roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
+ wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
+ };
// Check if already in FurnitureData
const allEntries = [
...(furniData.roomitemtypes?.furnitype || []),
...(furniData.wallitemtypes?.furnitype || []),
- ]
+ ];
const alreadyInFD = allEntries.some(
(e) => e.classname === baseClassname || e.classname === item.classname,
- )
+ );
if (!alreadyInFD) {
const dbItem = await prisma.itemsBase.findFirst({
where: { itemName: item.classname },
select: { id: true, spriteId: true, publicName: true, type: true },
- })
- const furnidata = await getHabboItFurnidata()
- const fd = furnidata.get(item.classname) || furnidata.get(baseClassname)
- const itemType = dbItem?.type === 'i' ? 'i' : 's'
+ });
+ const furnidata = await getHabboItFurnidata();
+ const fd = furnidata.get(item.classname) || furnidata.get(baseClassname);
+ const itemType = dbItem?.type === "i" ? "i" : "s";
const entry = buildFurniEntry({
id: dbItem?.spriteId || 0,
classname: baseClassname,
revision: rev,
- category: fd?.category || '',
+ category: fd?.category || "",
name: dbItem?.publicName || baseClassname,
- description: fd?.description || '',
+ description: fd?.description || "",
spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null,
habboIt: fd || null,
- })
- await appendFurniEntry(entry, itemType)
+ });
+ await appendFurniEntry(entry, itemType);
}
} catch (fdErr) {
- console.warn(
- `[regen] FurnitureData update skipped for ${item.classname}:`,
- (fdErr as Error).message,
- )
+ console.warn(`[regen] FurnitureData update skipped for ${item.classname}:`, (fdErr as Error).message);
}
- results.push({ classname: item.classname, ok: true })
+ results.push({ classname: item.classname, ok: true });
} catch (err) {
- results.push({ classname: item.classname, ok: false, warning: (err as Error).message })
+ results.push({ classname: item.classname, ok: false, warning: (err as Error).message });
}
}
- const succeeded = results.filter((r) => r.ok).length
- const failed = results.filter((r) => !r.ok).length
+ const succeeded = results.filter((r) => r.ok).length;
+ const failed = results.filter((r) => !r.ok).length;
- return apiOk({ succeeded, failed, results })
-})
+ return apiOk({ succeeded, failed, results });
+});
// ── Re-classify ALL imported items across all sub-pages + update prices (PUT) ──
// Supports ?dryrun=1 to preview changes without applying them.
export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
- const isDryRun = request.nextUrl.searchParams.get('dryrun') === '1'
+ const isDryRun = request.nextUrl.searchParams.get("dryrun") === "1";
// 1. Find the root "imported_furni" page
const parentPage = await prisma.catalogPages.findFirst({
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE },
select: { id: true },
- })
+ });
if (!parentPage) {
return apiOk({
dryRun: isDryRun,
moved: 0,
repriced: 0,
- message: 'No imported_furni page found — nothing to reorganize',
- })
+ message: "No imported_furni page found — nothing to reorganize",
+ });
}
// 2. Collect ALL imported page IDs (parent + every imp_* sub-page)
const subPages = await prisma.catalogPages.findMany({
where: { parentId: parentPage.id },
select: { id: true, captionSave: true },
- })
- const allImportedPageIds = [String(parentPage.id), ...subPages.map((p) => String(p.id))]
+ });
+ const allImportedPageIds = [String(parentPage.id), ...subPages.map((p) => String(p.id))];
// 3. Find ALL catalog_items across all imported pages
// page_id is VARCHAR in DB so we use string placeholders
- const inPlaceholders = allImportedPageIds.map(() => '?').join(',')
+ const inPlaceholders = allImportedPageIds.map(() => "?").join(",");
const allItems = await prisma.$queryRawUnsafe<
Array<{
- id: number
- item_ids: string
- catalog_name: string
- page_id: string
- cost_credits: number
+ id: number;
+ item_ids: string;
+ catalog_name: string;
+ page_id: string;
+ cost_credits: number;
}>
>(
`SELECT id, item_ids, catalog_name, page_id, cost_credits FROM catalog_items WHERE page_id IN (${inPlaceholders})`,
...allImportedPageIds,
- )
+ );
if (allItems.length === 0) {
return apiOk({
dryRun: isDryRun,
moved: 0,
repriced: 0,
- message: 'No imported items found — nothing to reorganize',
- })
+ message: "No imported items found — nothing to reorganize",
+ });
}
// 4. Build classname → type map from items_base
- const uniqueItemIds = [...new Set(allItems.map((i) => Number(i.item_ids)))].filter(
- (n) => !Number.isNaN(n),
- )
+ const uniqueItemIds = [...new Set(allItems.map((i) => Number(i.item_ids)))].filter((n) => !Number.isNaN(n));
const dbItems =
uniqueItemIds.length > 0
? await prisma.itemsBase.findMany({
where: { id: { in: uniqueItemIds } },
select: { id: true, itemName: true, type: true },
})
- : []
- const typeMap = new Map()
+ : [];
+ const typeMap = new Map();
for (const di of dbItems) {
- if (di.itemName) typeMap.set(di.itemName, di.type || 's')
+ if (di.itemName) typeMap.set(di.itemName, di.type || "s");
}
// 5. Pre-load category page map (captionSave → pageId) to avoid N+1 queries
- const categoryPageMap = new Map()
+ const categoryPageMap = new Map();
for (const sp of subPages) {
- if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id)
+ if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id);
}
// Helper: resolve or create category page using the cached map
async function resolvePageId(catKey: string): Promise {
- const captionSave = `imp_${catKey}`
- const cached = categoryPageMap.get(captionSave)
- if (cached !== undefined) return String(cached)
+ const captionSave = `imp_${catKey}`;
+ const cached = categoryPageMap.get(captionSave);
+ if (cached !== undefined) return String(cached);
- if (isDryRun) return 'new'
+ if (isDryRun) return "new";
// Create the page (only happens once per new category)
- const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other
- const parentId = parentPage!.id
+ const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other;
+ const parentId = parentPage!.id;
await prisma.$executeRaw`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '')
- `
+ `;
const created = await prisma.catalogPages.findFirst({
where: { captionSave, parentId },
select: { id: true },
- })
- categoryPageMap.set(captionSave, created!.id)
- return String(created!.id)
+ });
+ categoryPageMap.set(captionSave, created!.id);
+ return String(created!.id);
}
// 6. Classify all items (pure computation + cached lookups, no per-item DB queries)
- let moved = 0
- let repriced = 0
- let skipped = 0
- const errors: string[] = []
- const categoryCounts: Record = {}
- const pagesWithItems = new Set()
+ let moved = 0;
+ let repriced = 0;
+ let skipped = 0;
+ const errors: string[] = [];
+ const categoryCounts: Record = {};
+ const pagesWithItems = new Set