fix: restore complete admin feature dependencies

This commit is contained in:
Simo committed 2026-07-11 21:15:54 +02:00
1 parent 5b4228261a
commit 0cd753c735
115 files changed
+18565 -4930

No files matched your search

+17
View File
@@ -20,24 +20,40 @@
"jobs:worker": "tsx scripts/jobs-worker.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-virtual": "^3.14.5",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"croner": "^10.0.1",
"hash-wasm": "^4.12.0",
"ioredis": "^5.11.1",
"jpeg-js": "^0.4.4",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"lucide-react": "^1.23.0",
"lzma": "^2.3.2",
"music-metadata": "^11.13.0",
"mysql2": "^3.22.6",
"next": "^16.2.10",
"next-auth": "5.0.0-beta.31",
"next-intl": "^4.13.1",
"nodemailer": "^9.0.3",
"otplib": "^12.0.1",
"radix-ui": "^1.6.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.81.0",
"resend": "^6.17.1",
"sanitize-html": "^2.17.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -46,6 +62,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/typography": "^0.5.20",
"@types/jpeg-js": "^0.3.7",
"@types/node": "^22.10.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^19.2.0",
+4528 -4740
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+5 -4
View File
@@ -5,8 +5,9 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function bulkUnban({ userIds }: { userIds: number[] }) {
export async function bulkUnban({ userIds }: { userIds: number[] }): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requireStaff();
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
await logStaffActivity({
@@ -18,7 +19,7 @@ export async function bulkUnban({ userIds }: { userIds: number[] }) {
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
}
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }) {
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }): Promise<ActionResult<{ banned: number }>> {
const staff = await requireStaff();
const now = Math.floor(Date.now() / 1000);
let banned = 0;
@@ -52,7 +53,7 @@ export async function bulkBan({ userIds, reason, duration }: { userIds: number[]
return { ok: true as const, data: { banned } };
}
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }) {
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
@@ -92,7 +93,7 @@ export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: num
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
}
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }) {
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }): Promise<ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>> {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
+5
View File
@@ -34,6 +34,7 @@ export async function createCatalogItem(data: {
targetId: created.id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } };
}
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
@@ -47,6 +48,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
targetType: "catalog_item",
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; targetPageId: number }) {
@@ -57,6 +59,7 @@ export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; t
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function reorderCatalogItems({ orders }: { orders: Array<{ id: number; orderNumber: number }> }) {
@@ -66,6 +69,7 @@ export async function reorderCatalogItems({ orders }: { orders: Array<{ id: numb
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: number; catalogFields: Record<string, unknown>; baseItem?: { id: number; fields: Record<string, unknown> } }) {
@@ -83,6 +87,7 @@ export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: n
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function translateCatalogItems({ items }: { items: Array<{ id: number; publicName: string; description: string }> }) {
+65 -11
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
'use server'
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
import { logAudit } from '@/lib/services/audit'
import { rcon } from '@/lib/services/rcon'
const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
})
export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => {
const entries = Object.entries(ctx.data.settings)
for (const [key, value] of entries) {
await prisma.emulatorSettings.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
})
}
await rcon.updateConfig()
logAudit({
userId: ctx.session.user.id,
action: 'emulator_settings_update',
target: 'EmulatorSettings',
after: ctx.data.settings,
})
return actionOk()
},
)
+8 -3
View File
@@ -2,10 +2,15 @@
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import type { ActionResult } from "@/lib/safe-action-shared";
export async function cleanSwfFiles() {
await requireStaff();
return { ok: true as const, data: { deleted: 0, remaining: 0 } };
export async function cleanSwfFiles(): Promise<ActionResult<{ deleted: number; remaining: number }>> {
try {
await requireStaff();
return { ok: true, data: { deleted: 0, remaining: 0 } };
} catch {
return { ok: false, error: "Failed to clean SWF files" };
}
}
export async function deleteImportedFurni({ classname }: { classname: string }) {
+48
View File
@@ -0,0 +1,48 @@
'use server'
import { z } from 'zod'
import { PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
const templateSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
category: z.string().max(50).optional().default('general'),
sortOrder: z.coerce.number().int().min(0).default(0),
})
export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => {
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data })
return actionOk({ id: tpl.id })
},
)
const updateTemplateInput = templateSchema
.partial()
.extend({ id: z.coerce.number().int().positive() })
export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => {
const { id, ...data } = ctx.data
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } })
if (!existing) throw new ActionError('Template not found')
await prisma.websiteTicketTemplate.update({ where: { id }, data })
return actionOk({ id })
},
)
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() })
export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => {
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } })
return actionOk()
},
)
File diff suppressed because it is too large. Load diff
+77
View File
@@ -0,0 +1,77 @@
'use server'
import fs from 'node:fs/promises'
import path from 'node:path'
import JSON5 from 'json5'
import { z } from 'zod'
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from '@/lib/client-translation-files'
import { patchJson5 } from '@/lib/json5-patch'
import { adminAction } from '@/lib/safe-action'
import { ActionError, actionOk } from '@/lib/safe-action-shared'
const saveTranslationsSchema = z.object({
locale: z.enum(['en', 'it']),
data: z.record(z.string(), z.unknown()),
})
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
const filePath = path.join(process.cwd(), 'messages', `${ctx.data.locale}.json`)
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), 'utf-8')
return actionOk()
})
const saveClientTranslationsSchema = z.object({
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
data: z.record(z.string(), z.string()),
})
export const saveClientTranslations = adminAction(
{ schema: saveClientTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError('Forbidden')
const file = getClientTranslationFile(ctx.data.fileId)
if (!file) throw new ActionError('Unknown file')
if (file.readOnly) throw new ActionError('File is read-only')
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list.
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath)
const raw = await fs.readFile(absPath, 'utf-8')
if (file.format === 'json') {
// Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), 'utf-8')
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] })
}
// JSON5: surgical line-level patch keeps headers and section comments
// intact. Falls back to a full re-serialization (which DOES drop comments)
// only when an edited key cannot be located via the patch contract.
const original: Record<string, string> = {}
const parsed = JSON5.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
original[k] = v == null ? '' : String(v)
}
}
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data)
if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, 'utf-8')
return actionOk({ commentsLost: false, unpatchedKeys })
}
// At least one key could not be patched surgically (e.g. unusual
// formatting or a brand-new key). Fall back to a full re-serialization
// and warn the caller that comments were lost.
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), 'utf-8')
return actionOk({ commentsLost: true, unpatchedKeys })
},
)
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
'use server'
import { revalidateTag } from 'next/cache'
import { z } from 'zod'
import { PERMS } from '@/lib/permission-slugs'
import { prisma } from '@/lib/prisma'
import { adminAction } from '@/lib/safe-action'
import { actionOk } from '@/lib/safe-action-shared'
const toggleWatchSchema = z.object({
targetUserId: z.coerce.number().int().positive(),
reason: z.string().max(255).optional(),
})
/**
* Toggle a watch entry for the calling staff on the given target user.
* If a row already exists it's removed; otherwise it's created with the
* provided reason (defaulting to empty). Returns the resulting state so
* the UI can flip the badge without re-fetching.
*/
export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => {
const staffId = ctx.session.user.id
const { targetUserId, reason } = ctx.data
const existing = await prisma.userWatch.findUnique({
where: { staffId_targetUserId: { staffId, targetUserId } },
})
if (existing) {
await prisma.userWatch.delete({ where: { id: existing.id } })
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
return actionOk({ watching: false })
}
await prisma.userWatch.create({
data: { staffId, targetUserId, reason: reason ?? '' },
})
revalidateTag(`user-watch:${staffId}`, { expire: 0 })
return actionOk({ watching: true })
},
)
@@ -264,7 +264,7 @@ export function CatalogTranslateTab({
<Database className="h-3 w-3" /> items_base (name)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ShoppingCart className="h-3 w-3" /> catalog_name (auto)
<ShoppingCart className="h-3 w-3" /> catalogName (auto)
</span>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<FileJson className="h-3 w-3" /> FurnitureData.json (name + desc)
+2 -2
View File
@@ -22,14 +22,14 @@ export default async function CatalogEditPage({
const pageId = parseInt(id, 10)
if (Number.isNaN(pageId)) notFound()
const catalogPage = await prisma.catalogPage.findUnique({
const catalogPage = await prisma.catalogPages.findUnique({
where: { id: pageId },
})
if (!catalogPage) notFound()
const [children, itemsData, ancestors] = await Promise.all([
prisma.catalogPage.findMany({
prisma.catalogPages.findMany({
where: { parentId: pageId },
orderBy: { orderNum: 'asc' },
select: { id: true, caption: true, enabled: true },
@@ -19,40 +19,40 @@ export default async function BcPageDetailPage({
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
const bcPage = await prisma.catalog_pages_bc.findUnique({ where: { id } })
const bcPage = await prisma.catalogPagesBc.findUnique({ where: { id } })
if (!bcPage) notFound()
const bcItems = await prisma.catalog_items_bc.findMany({
where: { page_id: id },
orderBy: { order_number: 'asc' },
const bcItems = await prisma.catalogItemsBc.findMany({
where: { pageId: id },
orderBy: { orderNumber: 'asc' },
})
return (
<BcPageDetail
page={{
id: bcPage.id,
parentId: bcPage.parent_id,
parentId: bcPage.parentId,
caption: bcPage.caption,
pageLayout: bcPage.page_layout,
iconColor: bcPage.icon_color,
iconImage: bcPage.icon_image,
orderNum: bcPage.order_num,
pageLayout: bcPage.pageLayout,
iconColor: bcPage.iconColor,
iconImage: bcPage.iconImage,
orderNum: bcPage.orderNum,
visible: bcPage.visible,
enabled: bcPage.enabled,
pageHeadline: bcPage.page_headline,
pageTeaser: bcPage.page_teaser,
pageSpecial: bcPage.page_special ?? '',
pageText1: bcPage.page_text1 ?? '',
pageText2: bcPage.page_text2 ?? '',
pageTextDetails: bcPage.page_text_details ?? '',
pageTextTeaser: bcPage.page_text_teaser ?? '',
pageHeadline: bcPage.pageHeadline,
pageTeaser: bcPage.pageTeaser,
pageSpecial: bcPage.pageSpecial ?? '',
pageText1: bcPage.pageText1 ?? '',
pageText2: bcPage.pageText2 ?? '',
pageTextDetails: bcPage.pageTextDetails ?? '',
pageTextTeaser: bcPage.pageTextTeaser ?? '',
}}
items={bcItems.map((i) => ({
id: i.id,
pageId: i.page_id,
itemIds: i.item_ids,
catalogName: i.catalog_name,
orderNumber: i.order_number,
pageId: i.pageId,
itemIds: i.itemIds,
catalogName: i.catalogName,
orderNumber: i.orderNumber,
extradata: i.extradata,
}))}
canEdit={canEdit}
File diff suppressed because it is too large. Load diff
@@ -379,7 +379,7 @@ function FurniGrid({ source }: FurniGridProps) {
setError(data.error || 'Failed to load')
return
}
setItems(data.items || [])
setItems(data.item || [])
setMeta(data.meta || null)
setStats(data.stats || null)
setSelected(new Set())
@@ -150,7 +150,7 @@ function LibsView() {
setError(data.error || 'Failed to load')
return
}
setItems(data.items || [])
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
} catch {
@@ -498,7 +498,7 @@ function SetsView() {
setError(data.error || 'Failed to load')
return
}
setItems(data.items || [])
setItems(data.item || [])
setTotal(data.meta?.total || 0)
setSelected(new Set())
} catch {
@@ -310,7 +310,7 @@ export function ImportFurniClient() {
setError(data.error || 'Failed to load')
return
}
const loadedItems = data.items || []
const loadedItems = data.item || []
setItems(loadedItems)
setMeta(data.meta || null)
setSelected(new Set())
@@ -167,7 +167,10 @@ export function RankEditClient({
}
function saveRank() {
run(() => saveRankAction({ id: initialRank.id, fields: { ...general, ...perms } }), {
const fields = Object.fromEntries(
Object.entries({ ...general, ...perms }).map(([key, value]) => [key, String(value)]),
)
run(() => saveRankAction({ id: initialRank.id, fields }), {
successMessage: 'Rank saved',
})
}
+5 -5
View File
@@ -17,13 +17,13 @@ export default async function RoomFurniPage({
const room = await loadRoom(id)
// Load items in this room. items.item_id → items_base.id
const items = await prisma.item.findMany({
const items = await prisma.items.findMany({
where: { roomId: room.id },
orderBy: { id: 'desc' },
})
// Resolve item bases
const baseIds = Array.from(new Set(items.map((i) => i.itemId)))
const baseIds = Array.from(new Set(items.map((i) => i.itemId).filter((id): id is number => id !== null)))
const bases = baseIds.length
? await prisma.itemsBase.findMany({
where: { id: { in: baseIds } },
@@ -43,10 +43,10 @@ export default async function RoomFurniPage({
const userMap = new Map(users.map((u) => [u.id, u.username]))
const rows = items.map((i) => {
const base = baseMap.get(i.itemId)
const base = i.itemId === null ? undefined : baseMap.get(i.itemId)
return {
id: i.id,
baseId: i.itemId,
baseId: i.itemId ?? 0,
name: base?.publicName || base?.itemName || `#${i.itemId}`,
itemName: base?.itemName ?? '',
type: base?.type ?? '?',
@@ -74,7 +74,7 @@ export default async function RoomFurniPage({
room={{
id: room.id,
name: room.name,
ownerUsername: room.owner?.username ?? room.owner_name,
ownerUsername: room.owner?.username ?? room.ownerName,
}}
items={rows}
canEdit={session.user.rank >= 4}
+8 -3
View File
@@ -5,11 +5,16 @@ export async function loadRoom(id: string) {
const roomId = parseInt(id, 10)
if (Number.isNaN(roomId)) notFound()
const room = await prisma.room.findUnique({
const room = await prisma.rooms.findUnique({
where: { id: roomId },
include: { owner: { select: { id: true, username: true, look: true } } },
})
if (!room) notFound()
return room
const owner = room.ownerId > 0
? await prisma.user.findUnique({
where: { id: room.ownerId },
select: { id: true, username: true, look: true },
})
: null
return { ...room, owner }
}
+2 -2
View File
@@ -42,7 +42,7 @@ export default async function RoomEditPage({
</Badge>
</h1>
<p className="text-sm text-muted-foreground">
Owner: {room.owner.username} &middot; {room.users}/{room.usersMax} users
Owner: {room.owner?.username ?? room.ownerName} &middot; {room.users}/{room.usersMax} users
</p>
</div>
</div>
@@ -61,7 +61,7 @@ export default async function RoomEditPage({
state: room.state,
usersMax: room.usersMax,
users: room.users,
ownerName: room.owner.username,
ownerName: room.owner?.username ?? room.ownerName,
}}
/>
</div>
+7 -6
View File
@@ -49,12 +49,13 @@ export default async function RoomShowPage({
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{room.users}</span> / {room.usersMax} users
</span>
<Link
href={`/admin/users/show/${room.owner.id}`}
className="text-primary hover:underline"
>
Owner: {room.owner.username}
</Link>
{room.owner ? (
<Link href={`/admin/users/show/${room.owner.id}`} className="text-primary hover:underline">
Owner: {room.owner.username}
</Link>
) : (
<span>Owner: {room.ownerName}</span>
)}
</div>
</div>
<div className="flex gap-2">
+3 -2
View File
@@ -26,7 +26,7 @@ export default async function SoundsPage() {
track: true,
},
}),
prisma.catalogItem.groupBy({
prisma.catalogItems.groupBy({
by: ['songId'],
where: { songId: { gt: 0 } },
_count: { _all: true },
@@ -55,7 +55,8 @@ export default async function SoundsPage() {
})
// Normalize: ensure trailing slash so ${base}${code}.mp3 yields a valid URL.
const baseUrl = baseUrlSetting.endsWith('/') ? baseUrlSetting : `${baseUrlSetting}/`
const configuredBaseUrl = baseUrlSetting ?? '/sounds/'
const baseUrl = configuredBaseUrl.endsWith('/') ? configuredBaseUrl : `${configuredBaseUrl}/`
return (
<div className="space-y-6">
+1 -1
View File
@@ -67,7 +67,7 @@ export default async function AdminTicketDetailPage({
username: ticket.creator.username,
look: ticket.creator.look,
rank: ticket.creator.rank,
mail: ticket.creator.mail,
mail: ticket.creator.mail ?? '',
ipRegister: ticket.creator.ipRegister,
accountCreated: ticket.creator.accountCreated,
},
+8 -8
View File
@@ -1,6 +1,6 @@
import { ExternalLink, Server } from 'lucide-react'
import Link from 'next/link'
import { EmulatorSettingsForm } from '@/app/[locale]/admin/settings/emulator/emulator-settings-form'
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'
@@ -31,32 +31,32 @@ export default async function EmulatorTranslationsPage({
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.EmulatorSettingWhereInput[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
const patternFilters: Prisma.EmulatorSettingsWhereInput[] = TRANSLATION_KEY_PATTERNS.map((p) => ({
key: { contains: p.replace(/%/g, '') },
}))
const baseFilter: Prisma.EmulatorSettingWhereInput = { OR: patternFilters }
const baseFilter: Prisma.EmulatorSettingsWhereInput = { OR: patternFilters }
const conditions: Prisma.EmulatorSettingWhereInput[] = [baseFilter]
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.EmulatorSettingWhereInput = { AND: conditions }
const where: Prisma.EmulatorSettingsWhereInput = { AND: conditions }
const [settings, total, allKeys] = await Promise.all([
prisma.emulatorSetting.findMany({
prisma.emulatorSettings.findMany({
where,
orderBy: { key: 'asc' },
skip: (page - 1) * perPage,
take: perPage,
}),
prisma.emulatorSetting.count({ where }),
prisma.emulatorSettings.count({ where }),
// Group chips reflect the WHOLE translation-pattern subset (so they don't
// disappear when you click one); we ignore the group/search filters here.
prisma.emulatorSetting.findMany({
prisma.emulatorSettings.findMany({
where: baseFilter,
select: { key: true },
}),
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
import { ChevronRight, Home } from 'lucide-react'
import Link from 'next/link'
import { Fragment } from 'react'
export type { BreadcrumbItem } from '@/types/common'
import type { BreadcrumbItem } from '@/types/common'
interface AdminBreadcrumbProps {
items: BreadcrumbItem[]
}
export function AdminBreadcrumb({ items }: AdminBreadcrumbProps) {
return (
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-2">
<Link href="/admin" className="hover:text-foreground transition-colors">
<Home className="h-3.5 w-3.5" />
</Link>
{items.map((item) => (
<Fragment key={item.href ?? item.label}>
<ChevronRight className="h-3 w-3 flex-shrink-0" />
{item.href ? (
<Link href={item.href} className="hover:text-foreground transition-colors">
{item.label}
</Link>
) : (
<span className="text-foreground font-medium">{item.label}</span>
)}
</Fragment>
))}
</nav>
)
}
@@ -0,0 +1,61 @@
'use client'
import { ChevronRight, Home } from 'lucide-react'
import { Fragment, useEffect } from 'react'
import { useCatalogManager } from './use-catalog-store'
export function BreadcrumbBar() {
const { state, dispatch } = useCatalogManager()
const { selectedPageId, ancestors } = state
useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) {
dispatch({ type: 'SET_ANCESTORS', ancestors: [] })
return
}
const ac = new AbortController()
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, { signal: ac.signal })
.then((r) => r.json())
.then((d) => {
if (!ac.signal.aborted) {
dispatch({ type: 'SET_ANCESTORS', ancestors: d.ancestors ?? [] })
}
})
.catch(() => {})
return () => ac.abort()
}, [selectedPageId, dispatch])
if (!selectedPageId || ancestors.length === 0) return null
return (
<div className="flex items-center gap-1 px-4 py-1 border-b bg-muted/20 text-xs text-muted-foreground overflow-x-auto shrink-0">
<Home className="h-3 w-3 shrink-0" />
{ancestors.map((a) => (
<Fragment key={a.id}>
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: 'SELECT_PAGE', id: a.id })}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
</button>
</Fragment>
))}
{/* Current page */}
{(() => {
const node = state.nodes.get(selectedPageId)
if (!node) return null
return (
<>
<ChevronRight className="h-3 w-3 shrink-0" />
<span className="shrink-0 text-foreground font-medium truncate max-w-40">
{node.caption}
</span>
</>
)
})()}
</div>
)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
'use client'
import { ImageOff, Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
interface ImagePreviewProps {
value: string
basePath?: string
height?: number
className?: string
}
export function ImagePreview({
value,
basePath = '/swf/c_images/catalogue',
height = 60,
className,
}: ImagePreviewProps) {
const [src, setSrc] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
const [fallback, setFallback] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!value.trim()) {
setSrc('')
setStatus('idle')
setFallback(false)
return
}
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setFallback(false)
const url = `${basePath}/${value.trim()}.png`
setSrc(url)
setStatus('loading')
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [value, basePath])
function handleError() {
if (!fallback) {
// Try .gif as fallback
setFallback(true)
setSrc(`${basePath}/${value.trim()}.gif`)
setStatus('loading')
} else {
setStatus('error')
}
}
if (status === 'idle' || !src) {
return (
<div
className={`flex items-center justify-center rounded border border-dashed bg-muted/30 text-muted-foreground ${className || ''}`}
style={{ height }}
>
<ImageOff className="h-4 w-4" />
</div>
)
}
return (
<div
className={`relative flex items-center justify-center rounded border bg-muted/30 overflow-hidden ${className || ''}`}
style={{ height }}
>
{status === 'loading' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground absolute" />
)}
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
style={{ imageRendering: 'pixelated' }}
onLoad={() => setStatus('loaded')}
onError={handleError}
/>
{status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/60">
<ImageOff className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,56 @@
'use client'
import { FolderTree, Keyboard, Package, ToggleRight } from 'lucide-react'
import { useMemo } from 'react'
import { useCatalogManager } from './use-catalog-store'
export function StatsBar() {
const { state } = useCatalogManager()
const stats = useMemo(() => {
const nodes = Array.from(state.nodes.values())
const enabled = nodes.filter((n) => n.enabled === '1').length
const totalItems = nodes.reduce((sum, n) => sum + n.itemCount, 0)
return {
rootTabs: state.rootPages.length,
totalPages: nodes.length,
enabled,
totalItems,
}
}, [state.nodes, state.rootPages])
return (
<div className="flex items-center justify-between gap-4 px-4 py-1.5 border-t bg-muted/30 text-[11px] text-muted-foreground shrink-0">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<FolderTree className="h-3 w-3" />
{stats.rootTabs} root
</span>
<span>{stats.totalPages} pages</span>
<span className="flex items-center gap-1">
<ToggleRight className="h-3 w-3" />
{stats.enabled} enabled
</span>
<span className="flex items-center gap-1">
<Package className="h-3 w-3" />
{stats.totalItems} items
</span>
{state.multiSelectedIds.size > 0 && (
<span className="text-primary font-medium">{state.multiSelectedIds.size} selected</span>
)}
</div>
<div className="flex items-center gap-1">
<Keyboard className="h-3 w-3" />
<span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+S</kbd> save
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">/</kbd> search
<span className="mx-1">·</span>
<kbd className="rounded border bg-background px-1 font-mono text-[9px]">Ctrl+K</kbd> quick
add
</span>
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
'use client'
import { Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { toggleUserWatch } from '@/actions/watch'
import { Button } from '@/components/ui/button'
interface UserWatchToggleProps {
targetUserId: number
username: string
initialWatching: boolean
}
export function UserWatchToggle({ targetUserId, username, initialWatching }: UserWatchToggleProps) {
const [watching, setWatching] = useState(initialWatching)
const [busy, setBusy] = useState(false)
async function handleToggle() {
setBusy(true)
try {
const result = await toggleUserWatch({ targetUserId })
if (result.ok) {
const data = result.data as { watching: boolean }
setWatching(data.watching)
toast.success(data.watching ? `Now watching ${username}` : `Stopped watching ${username}`)
} else {
toast.error(result.error)
}
} finally {
setBusy(false)
}
}
return (
<Button
variant={watching ? 'default' : 'outline'}
size="sm"
disabled={busy}
onClick={handleToggle}
title={watching ? 'Stop watching this user' : 'Watch this user'}
>
{watching ? (
<>
<Eye className="mr-1.5 h-3.5 w-3.5" /> Watching
</>
) : (
<>
<EyeOff className="mr-1.5 h-3.5 w-3.5" /> Watch
</>
)}
</Button>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
link: 'text-primary underline-offset-4 [a&]:hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span'
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+63
View File
@@ -0,0 +1,63 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : 'button'
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
+30
View File
@@ -0,0 +1,30 @@
'use client'
import { CheckIcon } from 'lucide-react'
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
'use client'
import { Label as LabelPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
)
}
export { Label }
+75
View File
@@ -0,0 +1,75 @@
'use client'
import { Popover as PopoverPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="popover-header"
className={cn('flex flex-col gap-1 text-sm', className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />
}
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="popover-description"
className={cn('text-muted-foreground', className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
}
export { Skeleton }
+36
View File
@@ -0,0 +1,36 @@
'use client'
import { Switch as SwitchPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Switch({
className,
size = 'default',
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: 'sm' | 'default'
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+93
View File
@@ -0,0 +1,93 @@
'use client'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)
}
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }
+82
View File
@@ -0,0 +1,82 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Tabs({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn('group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', className)}
{...props}
/>
)
}
const tabsListVariants = cva(
'rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent',
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground',
'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100',
className,
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
+19
View File
@@ -0,0 +1,19 @@
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
)
}
export { Textarea }
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+2
View File
@@ -56,6 +56,8 @@ const schema = z.object({
OPENAI_API_KEY: z.string().optional(),
// Optional alerting (jobs worker / alert service).
DISCORD_WEBHOOK_URL: z.string().url().optional(),
TELEGRAM_BOT_TOKEN: z.string().optional(),
TELEGRAM_CHAT_ID: z.string().optional(),
ALERT_EMAIL: z.string().optional(),
// Optional PayPal top-up.
PAYPAL_CLIENT_ID: z.string().optional(),
+15
View File
@@ -0,0 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
+71
View File
@@ -0,0 +1,71 @@
'use client'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { toast } from 'sonner'
import type { ActionResult } from '@/lib/safe-action-shared'
interface UseServerActionOptions {
/** Where to redirect after a successful action (optional) */
redirectOnSuccess?: string
}
interface RunOptions {
successMessage?: string
errorMessage?: string
/** Override redirect path for this specific call */
redirectTo?: string
/** Called on success with the result data. Overrides default router.refresh(). */
onSuccess?: (data: Record<string, unknown>) => void
}
/**
* Hook that wraps a server action with loading state, toast notifications,
* and optional redirect/refresh. Drop-in replacement for useAdminForm.
*
* @example
* const { run, isPending } = useServerAction()
*
* // Submit
* run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' })
*
* // Delete with redirect
* run(() => deleteNews(id), { redirectTo: '/admin/news' })
*/
export function useServerAction(options: UseServerActionOptions = {}) {
const [isPending, startTransition] = useTransition()
const router = useRouter()
function run(action: () => Promise<ActionResult | void>, opts: RunOptions = {}) {
const {
successMessage = 'Saved successfully.',
errorMessage = 'An error occurred.',
redirectTo,
onSuccess,
} = opts
startTransition(async () => {
try {
const result = await action()
if (!result || result.ok) {
toast.success(successMessage)
if (onSuccess) {
onSuccess((result?.data ?? {}) as Record<string, unknown>)
} else {
const target = redirectTo || options.redirectOnSuccess
if (target) router.push(target)
else router.refresh()
}
} else {
toast.error(result.error || errorMessage)
}
} catch (err) {
console.error('[useServerAction] Unexpected error:', err)
toast.error(errorMessage)
}
})
}
return { run, isPending }
}
+55
View File
@@ -0,0 +1,55 @@
import type { ListParams } from '@/types'
export const PER_PAGE_OPTIONS = [10, 20, 50] as const
export const DEFAULT_PER_PAGE = 20
export function parseListParams(searchParams: URLSearchParams): ListParams {
const search = searchParams.get('search') || ''
const perPage = Math.min(
Math.max(parseInt(searchParams.get('perPage') || String(DEFAULT_PER_PAGE), 10), 1),
100,
)
const page = Math.max(parseInt(searchParams.get('page') || '1', 10), 1)
const sort = searchParams.get('sort') || undefined
const order = (searchParams.get('order') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc'
return { search, perPage, page, sort, order }
}
export function calcPagination(total: number, page: number, perPage: number) {
const lastPage = Math.max(Math.ceil(total / perPage), 1)
return {
total,
page: Math.min(page, lastPage),
perPage,
lastPage,
offset: (Math.min(page, lastPage) - 1) * perPage,
}
}
/** Generate CSV content from rows */
export function generateCsv(
rows: Record<string, unknown>[],
columns: { key: string; label: string }[],
): string {
const BOM = '\uFEFF'
const header = columns.map((c) => escapeCsv(c.label)).join(',')
const body = rows
.map((row) => columns.map((c) => escapeCsv(String(row[c.key] ?? ''))).join(','))
.join('\n')
return `${BOM + header}\n${body}`
}
function escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
export function formatTimestamp(ts: number): string {
if (!ts) return 'N/A'
return new Date(ts * 1000).toLocaleString()
}
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
/** All known catalog page layouts for Habbo/Arcturus. */
export const CATALOG_LAYOUTS = [
'default_3x3',
'frontpage',
'spaces_new',
'recycler',
'trophies',
'pets',
'pets2',
'pets3',
'soundmachine',
'guilds',
'info_loyalty',
'info_duckets',
'loyalty_vip_buy',
'single_bundle',
'club_buy',
'marketplace',
'badge_display',
'room_bundle',
'sold_ltd_items',
] as const
export type CatalogLayout = (typeof CATALOG_LAYOUTS)[number]
/** Short human-readable description for each layout. Shown in the layout selector. */
export const LAYOUT_DESCRIPTIONS: Record<CatalogLayout, string> = {
default_3x3: 'Standard 3-column grid of items',
frontpage: 'Featured landing page with large teasers',
spaces_new: 'Wide 4-column grid used for themed spaces',
recycler: 'Ecotron recycler exchange page',
trophies: 'Trophy crafting form',
pets: 'Legacy pet purchase page',
pets2: 'Pet purchase page (v2)',
pets3: 'Pet purchase page (v3)',
soundmachine: 'Soundmachine track picker',
guilds: 'Guild/group creation page',
info_loyalty: 'Informational loyalty page (no items)',
info_duckets: 'Informational duckets page (no items)',
loyalty_vip_buy: 'HC/VIP loyalty purchase',
single_bundle: 'Single prominent bundle tile',
club_buy: 'HC subscription purchase page',
marketplace: 'Marketplace entry page',
badge_display: 'Badge showcase page',
room_bundle: 'Room bundle purchase page',
sold_ltd_items: 'Sold limited editions archive',
}
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
/**
* Module-level cache for the song picker options, shared between the
* catalog-items table (where the picker lives) and the Sounds admin page
* (which invalidates after an upload/edit/delete).
*
* Living in `src/lib/` (outside any `[dynamic]` route folder) avoids
* cross-route client imports that confused Turbopack's HMR boundaries
* and triggered `Router action dispatched before initialization` on
* the Sounds page in dev.
*/
export interface SoundtrackOption {
id: number
code: string
name: string
author: string
length: number
}
let cachedOptions: SoundtrackOption[] | null = null
let inflight: Promise<SoundtrackOption[]> | null = null
export async function loadSongPickerOptions(): Promise<SoundtrackOption[]> {
if (cachedOptions) return cachedOptions
if (inflight) return inflight
inflight = (async () => {
const res = await fetch('/api/admin/sounds', { cache: 'no-store' })
if (!res.ok) throw new Error('Failed to load soundtracks')
const json = (await res.json()) as { ok: boolean; items?: SoundtrackOption[] }
cachedOptions = json.items ?? []
inflight = null
return cachedOptions
})()
return inflight
}
export function getCachedSongPickerOptions(): SoundtrackOption[] | null {
return cachedOptions
}
/** Clear the cache so new uploads show up on next open. */
export function invalidateSongPickerCache(): void {
cachedOptions = null
}
+98
View File
@@ -0,0 +1,98 @@
// Whitelist of Nitro client localization files exposed in
// /admin/translations/client. The registry doubles as a path-traversal guard
// for the saveClientTranslations server action: only `id`s defined here can be
// written to.
export type ClientTranslationFormat = 'json5' | 'json'
export interface ClientTranslationFile {
id: string
/** Path relative to the project root. */
relPath: string
format: ClientTranslationFormat
language: 'it' | 'en' | 'shared'
readOnly: boolean
/**
* True when the on-disk file commonly contains comments that the json5
* serializer cannot preserve. Used to surface a warning in the UI.
*/
hasComments: boolean
/** Free-form note shown in the UI (e.g. why a file is read-only). */
note?: string
}
export const CLIENT_TRANSLATION_FILES: ClientTranslationFile[] = [
{
id: 'ui-texts-it',
relPath: 'public/nitro-assets/config/UITexts.json5',
format: 'json5',
language: 'it',
readOnly: false,
hasComments: true,
note: 'Override del client (sovrascrive ExternalTexts).',
},
{
id: 'ui-texts-en',
relPath: 'public/nitro-assets/config/UITexts_en.json5',
format: 'json5',
language: 'en',
readOnly: true,
hasComments: true,
note: 'Riferimento EN — non viene caricato dal client.',
},
{
id: 'external-texts',
relPath: 'public/nitro-assets/config/ExternalTexts.json5',
format: 'json5',
language: 'shared',
readOnly: false,
hasComments: false,
},
{
id: 'external-texts-badges',
relPath: 'public/nitro-assets/config/ExternalTexts_Badges.json5',
format: 'json5',
language: 'shared',
readOnly: false,
hasComments: false,
note: 'File grande (~30k voci): usa la ricerca.',
},
{
id: 'catalog-texts',
relPath: 'public/nitro-assets/config/CatalogTexts.json5',
format: 'json5',
language: 'shared',
readOnly: false,
hasComments: false,
},
{
id: 'gamedata-external-texts',
relPath: 'public/nitro-assets/gamedata/ExternalTexts.json',
format: 'json',
language: 'shared',
readOnly: true,
hasComments: false,
note: 'Copia "live" servita al client. Generata dal build, modifiche manuali sovrascritte.',
},
{
id: 'badge-texts-en',
relPath: 'public/nitro3/localization/badge-texts-en.json',
format: 'json',
language: 'en',
readOnly: false,
hasComments: false,
},
{
id: 'badge-texts-it',
relPath: 'public/nitro3/localization/badge-texts-it.json',
format: 'json',
language: 'it',
readOnly: false,
hasComments: false,
},
]
export function getClientTranslationFile(id: string): ClientTranslationFile | undefined {
return CLIENT_TRANSLATION_FILES.find((f) => f.id === id)
}
+43
View File
@@ -0,0 +1,43 @@
const CLASSNAME_CHAR_RE = /^[a-z0-9_*\-.]+$/
export function getBaseClassname(classname: string): string {
const star = classname.indexOf('*')
return star >= 0 ? classname.substring(0, star) : classname
}
export function getSafeClassnameStem(classname: string): string {
return classname.replace(/\*/g, '_')
}
export function getIconFileName(classname: string): string {
return `${getSafeClassnameStem(classname)}_icon.png`
}
export function getSwfFileName(classname: string): string {
return `${getBaseClassname(classname)}.swf`
}
export function getNitroFileName(classname: string): string {
return `${getBaseClassname(classname)}.nitro`
}
export function normalizeClassname(raw: string): string {
return raw.trim().toLowerCase()
}
export function isValidClassname(classname: string): boolean {
if (!classname) return false
return CLASSNAME_CHAR_RE.test(classname)
}
export const LOCAL_ICON_URL_PREFIX = '/swf/dcr/hof_furni/icons/'
export function getLocalIconUrl(classname: string): string {
return `${LOCAL_ICON_URL_PREFIX}${encodeURIComponent(getIconFileName(classname))}`
}
export function getHabboCdnIconUrl(classname: string, revision: number): string {
const base = getBaseClassname(classname)
return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Centralized Habbo avatar imager utility.
* Points to the self-hosted imager API (`/api/imaging/avatar`) which handles
* disk + memory caching and (Phase 2) local rendering from .nitro bundles.
* Override with NEXT_PUBLIC_IMAGER_URL when needed (e.g. CDN in front).
*/
const IMAGER_URL = process.env.NEXT_PUBLIC_IMAGER_URL || '/api/imaging/avatar'
export type { AvatarOptions } from '@/types/admin'
import type { AvatarOptions } from '@/types/admin'
/**
* Build an avatar image URL served by the self-hosted imager.
*
* @example
* getAvatarUrl('hr-893-45.hd-180-1.ch-210-66.lg-270-82')
* getAvatarUrl(look, { size: 'l', headOnly: true })
* getAvatarUrl(look, { size: 'm', gesture: 'sml', direction: 2 })
*/
export function getAvatarUrl(figure: string, options: AvatarOptions = {}): string {
const {
size = 'm',
direction = 2,
headDirection = 3,
headOnly = false,
gesture,
action,
} = options
const params = new URLSearchParams({
figure,
direction: String(direction),
head_direction: String(headDirection),
size,
})
if (headOnly) params.set('headonly', '1')
if (gesture) params.set('gesture', gesture)
if (action) params.set('action', action)
return `${IMAGER_URL}?${params.toString()}`
}
+79
View File
@@ -0,0 +1,79 @@
// Surgical line-level update for JSON5 files that preserves comments and
// formatting. Used by saveClientTranslations so that the Nitro localization
// files keep their headers and section markers when an admin edits a value.
//
// Assumptions (true for every Nitro `.json5` file in the repo):
// * one entry per line, formatted as `<key>: <value>,`
// * keys are simple strings (single- or double-quoted, or bare)
// * values are strings (single- or double-quoted)
// If a changed key cannot be located via this contract, we report it back so
// the caller can decide whether to fall back to a full re-serialization.
export interface PatchResult {
/** The new file content, with surgical edits applied. */
content: string
/** Keys that we could not patch surgically (missing or unusual format). */
unpatchedKeys: string[]
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function escapeForQuote(value: string, quote: '"' | "'"): string {
// Escape backslashes first, then the chosen quote. Newlines/tabs are
// preserved as escape sequences so we don't break the JSON5 parser.
let out = value.replace(/\\/g, '\\\\')
out = out.replace(new RegExp(quote, 'g'), `\\${quote}`)
out = out.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
return out
}
/**
* Apply per-key value updates to a JSON5 source string while preserving
* comments and formatting. Only keys present in `original` (and with a
* different value in `updated`) are touched.
*/
export function patchJson5(
raw: string,
original: Record<string, string>,
updated: Record<string, string>,
): PatchResult {
let result = raw
const unpatchedKeys: string[] = []
for (const [key, newVal] of Object.entries(updated)) {
if (!(key in original)) {
// Key did not exist on disk — append/merge logic is the caller's job.
unpatchedKeys.push(key)
continue
}
if (original[key] === newVal) continue
const keyPattern = `(['"]?)${escapeRegExp(key)}\\1\\s*:\\s*(["'])((?:\\\\.|(?!\\2).)*)\\2`
const re = new RegExp(keyPattern)
const idx = result.search(re)
if (idx < 0) {
unpatchedKeys.push(key)
continue
}
const match = re.exec(result)
if (!match) {
unpatchedKeys.push(key)
continue
}
const valueQuote = match[2] as '"' | "'"
const oldValueLen = match[3].length
const fullLen = match[0].length
// prefix keeps everything up to and including the opening value quote
const prefix = match[0].slice(0, fullLen - oldValueLen - 1)
const escaped = escapeForQuote(newVal, valueQuote)
const replacement = prefix + escaped + valueQuote
result = result.slice(0, idx) + replacement + result.slice(idx + fullLen)
}
return { content: result, unpatchedKeys }
}
+3 -2
View File
@@ -7,7 +7,7 @@ export interface MissingLocalImport {
}
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
const importPattern = /(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
async function collectSourceFiles(directory: string): Promise<string[]> {
@@ -16,6 +16,7 @@ async function collectSourceFiles(directory: string): Promise<string[]> {
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return collectSourceFiles(path);
if (/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name)) return [];
return sourceExtensions.has(extname(entry.name)) ? [path] : [];
}),
);
@@ -32,7 +33,7 @@ async function exists(path: string): Promise<boolean> {
}
async function resolvesToLocalFile(base: string): Promise<boolean> {
if (sourceExtensions.has(extname(base)) && (await exists(base))) return true;
if (extname(base) && (await exists(base))) return true;
for (const suffix of resolutionSuffixes) {
if (await exists(`${base}${suffix}`)) return true;
}
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
// ── Permission Slug Constants ───────────────────────────────────────
// Extracted to its own file so it can be imported by client components
// (modules.ts → sidebar.tsx) without pulling in prisma/server-only deps.
export const PERMS = {
ADMIN_DASHBOARD: 'admin.dashboard',
USERS_VIEW: 'admin.users.view',
USERS_EDIT: 'admin.users.edit',
USERS_BAN: 'admin.users.ban',
USERS_RESET_PASSWORD: 'admin.users.reset_password',
ROOMS_VIEW: 'admin.room.view',
ROOMS_EDIT: 'admin.room.edit',
ROOMS_DELETE: 'admin.room.delete',
BANS_VIEW: 'admin.bans.view',
NEWS_VIEW: 'admin.news.view',
NEWS_EDIT: 'admin.news.edit',
LOGS_VIEW: 'admin.logs.view',
SETTINGS_VIEW: 'admin.settings.view',
SETTINGS_EDIT: 'admin.settings.edit',
PERMISSIONS_MANAGE: 'admin.permissions.manage',
SHOP_VIEW: 'admin.shop.view',
SHOP_EDIT: 'admin.shop.edit',
WORDFILTER_VIEW: 'admin.wordfilter.view',
WORDFILTER_EDIT: 'admin.wordfilter.edit',
CATALOG_VIEW: 'admin.catalog.view',
CATALOG_EDIT: 'admin.catalog.edit',
RCON_EXECUTE: 'admin.rcon.execute',
EXPORT: 'admin.export',
PREFIXES_VIEW: 'admin.prefixes.view',
PREFIXES_EDIT: 'admin.prefixes.edit',
TICKETS_VIEW: 'admin.tickets.view',
TICKETS_EDIT: 'admin.tickets.edit',
MODERATION_VIEW: 'admin.moderation.view',
MODERATION_EDIT: 'admin.moderation.edit',
// ── Events Module ──
EVENTS_VIEW: 'admin.events.view',
EVENTS_EDIT: 'admin.events.edit',
// ── Analytics Module ──
ANALYTICS_VIEW: 'admin.analytics.view',
ANALYTICS_EXPORT: 'admin.analytics.export',
// ── DevOps Module ──
DEVOPS_VIEW: 'admin.devops.view',
DEVOPS_EDIT: 'admin.devops.edit',
// ── Polls Module ──
POLLS_VIEW: 'admin.polls.view',
POLLS_EDIT: 'admin.polls.edit',
// ── Notifications Module ──
NOTIFICATIONS_VIEW: 'admin.notifications.view',
NOTIFICATIONS_EDIT: 'admin.notifications.edit',
// ── CMS Pages Module ──
PAGES_VIEW: 'admin.pages.view',
PAGES_EDIT: 'admin.pages.edit',
// ── Banners Module ──
BANNERS_VIEW: 'admin.banners.view',
BANNERS_EDIT: 'admin.banners.edit',
// ── Assets Module ──
ASSETS_IMPORT: 'admin.assets.import',
// ── Mod Panel Permissions ──
MOD_DASHBOARD: 'mod.dashboard',
MOD_CFH_VIEW: 'mod.cfh.view',
MOD_CFH_EDIT: 'mod.cfh.edit',
MOD_ACTIONS: 'mod.actions',
MOD_TEAM_VIEW: 'mod.team.view',
MOD_TICKETS_VIEW: 'mod.tickets.view',
MOD_TICKETS_EDIT: 'mod.tickets.edit',
MOD_USERS_VIEW: 'mod.users.view',
MOD_BANS_VIEW: 'mod.bans.view',
} as const
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
import { ZodError } from 'zod'
// ── Action Result Type ───────────────────────────────────────────────
export type ActionResult<T = Record<string, unknown>> =
| { ok: true; data?: T }
| { ok: false; error: string; fieldErrors?: Record<string, string[]> }
// ── Helper to build results ──────────────────────────────────────────
export function actionOk<T = Record<string, unknown>>(data?: T): ActionResult<T> {
return { ok: true, data: data ?? ({} as T) }
}
export function actionError(message: string): ActionResult<never> {
return { ok: false, error: message }
}
// ── Throwable error ──────────────────────────────────────────────────
export class ActionError extends Error {
constructor(message: string) {
super(message)
this.name = 'ActionError'
}
}
// ── Error handler ────────────────────────────────────────────────────
export function handleActionError(error: unknown): ActionResult<never> {
if (error instanceof ZodError) {
return {
ok: false,
error: 'Validation failed',
fieldErrors: error.flatten().fieldErrors as Record<string, string[]>,
}
}
if (error instanceof Error && error.name === 'ActionError') {
return { ok: false, error: error.message }
}
// Prisma P2025
if (
error instanceof Error &&
error.constructor.name === 'PrismaClientKnownRequestError' &&
(error as Error & { code?: string }).code === 'P2025'
) {
return { ok: false, error: 'Not found' }
}
console.error('[Action error]', error)
return { ok: false, error: 'Internal server error' }
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13 -1
View File
@@ -92,7 +92,7 @@ export class RconClient {
sendGift(userId: number, itemId: number, message = "Here is a gift.") {
return this.send("sendgift", { user_id: userId, itemid: itemId, message });
}
disconnectUser(userId: number, username: string) {
disconnectUser(userId: number, username = "") {
return this.send("disconnect", { user_id: userId, username });
}
alertUser(userId: number, message: string) {
@@ -110,6 +110,18 @@ export class RconClient {
updateCatalog() {
return this.send("updatecatalog");
}
updateConfig() {
return this.send("updateconfig");
}
removeBadge(userId: number, badge: string) {
return this.send("removebadge", { user_id: userId, badge });
}
muteUser(userId: number, duration: number) {
return this.send("muteuser", { user_id: userId, duration });
}
unmuteUser(userId: number) {
return this.send("unmuteuser", { user_id: userId });
}
}
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
/**
* Re-export barrel for backward compatibility.
* All implementation has been moved to the ./swf/ module directory.
*/
export * from './swf'
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more