fix: restore complete admin feature dependencies
This commit is contained in:
1 parent
5b4228261a
commit
0cd753c735
115 files changed
+18565
-4930
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+4528
-4740
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,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 }> = [];
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default async function RoomEditPage({
|
||||
</Badge>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Owner: {room.owner.username} · {room.users}/{room.usersMax} users
|
||||
Owner: {room.owner?.username ?? room.ownerName} · {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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user