fix: harden update pipeline and restore a clean production build
- update-Nitrov3.sh: build CMS into .next-staging and swap atomically so a failed build never takes the live site down; auto-merge new variables from .env.example; validate env for duplicates/broken lines; restart the emulator/CMS only when rebuilt or unhealthy; fix step renumbering - next.config.ts: support NEXT_DIST_DIR for staged production builds - fix all TS errors (unused imports, missing tryDownloadCandidates helper) so tsc and the production build pass clean - add Dockerfile/.dockerignore and switch docker-compose to a CMS container - include prevailing UI/refactor changes (SurfaceCard, ticketing, tsconfig)
This commit is contained in:
1 parent
750fcb2e5c
commit
944e8ff1d8
26 files changed
+427
-876
No files matched your search
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
storage
|
||||||
|
prod.log
|
||||||
|
update.log
|
||||||
|
.pm2
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
|
# ~3GB client assets; mounted as a volume at runtime (see docker-compose.yml)
|
||||||
|
public/nitro-assets
|
||||||
|
public/swf
|
||||||
@@ -3,6 +3,7 @@ node_modules.prev/
|
|||||||
.turbo/
|
.turbo/
|
||||||
.next/
|
.next/
|
||||||
.next.prev/
|
.next.prev/
|
||||||
|
.next-staging/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# EpicNext-CMS — Docker image (Node 26.8.1, pnpm 11.24.0, Next.js standalone)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# --- Builder stage ---
|
||||||
|
FROM node:26.8.1-bookworm-slim AS builder
|
||||||
|
|
||||||
|
# pnpm is required and pinned in package.json (packageManager: [email protected]).
|
||||||
|
# Node 26 does not bundle corepack anymore, so install pnpm via npm.
|
||||||
|
RUN npm install -g [email protected]
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# First copy only the manifests so dependency layers are cached.
|
||||||
|
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# Copy the rest of the source.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the production bundle.
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Copy runtime dependencies (node_modules) needed by standalone output.
|
||||||
|
RUN pnpm prune --prod
|
||||||
|
|
||||||
|
# --- Runtime stage ---
|
||||||
|
FROM node:26.8.1-bookworm-slim AS runner
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3002
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# Occupied base port on the host; keep PM2-style default.
|
||||||
|
EXPOSE 3002
|
||||||
|
|
||||||
|
# Non-root user for security.
|
||||||
|
RUN groupadd --system --gid 1001 nodejs \
|
||||||
|
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Storage directory for runtime uploaded media (persistent volume).
|
||||||
|
# nitro/swf client assets (~3GB) are mounted here as volumes at runtime.
|
||||||
|
RUN mkdir -p /app/storage \
|
||||||
|
/app/public/nitro-assets \
|
||||||
|
/app/public/swf \
|
||||||
|
&& chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
# Copy standalone Next.js output (includes a minimal node_modules).
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
# Copy static assets (public files served directly).
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
# Copy the server-side static build output.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Client assets + runtime uploads live outside the image (mounted volumes).
|
||||||
|
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+15
-6
@@ -1,11 +1,20 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
flaresolverr:
|
cms:
|
||||||
image: flaresolverr/flaresolverr:latest
|
build:
|
||||||
container_name: flaresolverr
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: epicnext-cms
|
||||||
ports:
|
ports:
|
||||||
- "8191:8191"
|
# Host port -> container port (container always listens on 3002)
|
||||||
|
- "3002:3002"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
env_file:
|
||||||
- LOG_LEVEL=info
|
- .env
|
||||||
|
volumes:
|
||||||
|
# ~3GB client assets live on the host; mount them read-only into the container
|
||||||
|
- ./public/nitro-assets:/app/public/nitro-assets:ro
|
||||||
|
- ./public/swf:/app/public/swf:ro
|
||||||
|
# Runtime uploaded media (persistent on the host)
|
||||||
|
- ./storage:/app/storage
|
||||||
@@ -26,7 +26,9 @@ const securityHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
||||||
|
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
compress: true,
|
compress: true,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
|||||||
@@ -3,21 +3,8 @@
|
|||||||
import { asc } from "drizzle-orm";
|
import { asc } from "drizzle-orm";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
|
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
|
||||||
import { redirectSafe } from "@/lib/foundation/security";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
/**
|
|
||||||
* Housekeeping table writes are retired. Runtime access uses ACL only.
|
|
||||||
* Redirect editors to live Permissions.
|
|
||||||
*/
|
|
||||||
async function rejectLegacyHousekeepingWrite(): Promise<never> {
|
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
|
||||||
return redirectSafe(
|
|
||||||
"/admin/permissions?from=housekeeping",
|
|
||||||
"/admin/permissions",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportPermissions(): Promise<string> {
|
export async function exportPermissions(): Promise<string> {
|
||||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||||
|
|
||||||
@@ -37,7 +24,3 @@ export async function exportPermissions(): Promise<string> {
|
|||||||
|
|
||||||
return JSON.stringify(perms, null, 2);
|
return JSON.stringify(perms, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyPreset(_formData: FormData): Promise<void> {
|
|
||||||
return rejectLegacyHousekeepingWrite();
|
|
||||||
}
|
|
||||||
@@ -5,20 +5,9 @@ import { db, WebsiteSetting } from "@/lib/db";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from "@/lib/safe-action-shared";
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
import {
|
import { deleteImportedItem } from "@/lib/services/furni-import";
|
||||||
cleanupConvertedSwfs,
|
|
||||||
deleteImportedItem,
|
|
||||||
} from "@/lib/services/furni-import";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export const cleanSwfFiles = adminAction(
|
|
||||||
{ permission: PERMS.ASSETS_IMPORT },
|
|
||||||
async () => {
|
|
||||||
const result = await cleanupConvertedSwfs();
|
|
||||||
return actionOk(result as unknown as Record<string, unknown>);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
|
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
|
||||||
|
|
||||||
export const deleteImportedFurni = adminAction(
|
export const deleteImportedFurni = adminAction(
|
||||||
|
|||||||
+1
-39
@@ -3,13 +3,11 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { notify } from "@/lib/services/webhook";
|
|
||||||
import {
|
import {
|
||||||
assignTicketSchema,
|
assignTicketSchema,
|
||||||
createTicketSchema,
|
|
||||||
replyTicketSchema,
|
replyTicketSchema,
|
||||||
updateTicketPrioritySchema,
|
updateTicketPrioritySchema,
|
||||||
updateTicketStatusSchema,
|
updateTicketStatusSchema,
|
||||||
@@ -17,42 +15,6 @@ import {
|
|||||||
|
|
||||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||||
|
|
||||||
export const createTicket = authAction(
|
|
||||||
{
|
|
||||||
schema: createTicketSchema,
|
|
||||||
rateLimitKey: "ticket-create",
|
|
||||||
rateLimitMax: 5,
|
|
||||||
rateLimitWindowMs: 60_000,
|
|
||||||
},
|
|
||||||
async (ctx) => {
|
|
||||||
const now = new Date();
|
|
||||||
const [result] = await db.insert(WebsiteTicket).values({
|
|
||||||
subject: ctx.data.subject,
|
|
||||||
category: ctx.data.category,
|
|
||||||
creatorId: ctx.session.user.id,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
const ticketId = Number(result.insertId);
|
|
||||||
|
|
||||||
// Create the first message
|
|
||||||
await db.insert(WebsiteTicketMessage).values({
|
|
||||||
ticketId,
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
message: ctx.data.message,
|
|
||||||
isStaff: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
notify({
|
|
||||||
action: "ticket_create",
|
|
||||||
actor: ctx.session.user.username,
|
|
||||||
target: `#${ticketId} - ${ctx.data.subject}`,
|
|
||||||
details: `Category: ${ctx.data.category}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk({ id: ticketId });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const adminReplyTicket = adminAction(
|
export const adminReplyTicket = adminAction(
|
||||||
{
|
{
|
||||||
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
||||||
|
|||||||
@@ -48,19 +48,6 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -71,22 +58,4 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
|
||||||
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,
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
|
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
|
||||||
import type * as React from "react";
|
|
||||||
|
|
||||||
import { resolveAsChild } from "@/lib/as-child";
|
import { resolveAsChild } from "@/lib/as-child";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -120,28 +119,11 @@ function ContextMenuLabel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContextMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="context-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuLabel,
|
ContextMenuLabel,
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuShortcut,
|
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
};
|
};
|
||||||
@@ -33,24 +33,6 @@ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
|||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
asChild = false,
|
|
||||||
children,
|
|
||||||
render,
|
|
||||||
...props
|
|
||||||
}: DialogPrimitive.Close.Props & { asChild?: boolean }) {
|
|
||||||
const resolved = resolveAsChild(asChild, children, render);
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
render={resolved.render}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{resolved.children}
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -166,7 +148,6 @@ function DialogDescription({
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
||||||
import type * as React from "react";
|
|
||||||
|
|
||||||
import { resolveAsChild } from "@/lib/as-child";
|
import { resolveAsChild } from "@/lib/as-child";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -63,49 +62,5 @@ function PopoverContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Radix compat stub — Base UI positions via Positioner. */
|
/** Radix compat stub — Base UI positions via Positioner. */
|
||||||
function PopoverAnchor({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return <div data-slot="popover-anchor" className={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
export { Popover, PopoverContent, PopoverTrigger };
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="popover-header"
|
|
||||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
|
||||||
return (
|
|
||||||
<PopoverPrimitive.Title
|
|
||||||
data-slot="popover-title"
|
|
||||||
className={cn("font-medium", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PopoverPrimitive.Description.Props) {
|
|
||||||
return (
|
|
||||||
<PopoverPrimitive.Description
|
|
||||||
data-slot="popover-description"
|
|
||||||
className={cn("text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Popover,
|
|
||||||
PopoverAnchor,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverDescription,
|
|
||||||
PopoverHeader,
|
|
||||||
PopoverTitle,
|
|
||||||
PopoverTrigger,
|
|
||||||
};
|
|
||||||
@@ -23,16 +23,6 @@ function Select({ onValueChange, ...props }: SelectProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Group
|
|
||||||
data-slot="select-group"
|
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Value
|
<SelectPrimitive.Value
|
||||||
@@ -116,19 +106,6 @@ function SelectContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.GroupLabel.Props) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.GroupLabel
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -156,19 +133,6 @@ function SelectItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.Separator.Props) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -208,12 +172,9 @@ function SelectScrollDownButton({
|
|||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
};
|
};
|
||||||
@@ -39,19 +39,6 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -91,26 +78,4 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({
|
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
|
||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { drizzle } from "drizzle-orm/mysql2";
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import * as relations from "@/db/relations";
|
import * as relations from "@/db/relations";
|
||||||
import * as schema from "@/db/schema";
|
import * as schema from "@/db/schema";
|
||||||
@@ -63,35 +62,4 @@ if (env.NODE_ENV !== "production") globalForDb.db = db;
|
|||||||
|
|
||||||
export type Db = ReturnType<typeof createDb>;
|
export type Db = ReturnType<typeof createDb>;
|
||||||
|
|
||||||
type PreparedValue = string | number | bigint | boolean | Date | null | Buffer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side prepared statements (mysql2 pool.execute()).
|
|
||||||
*
|
|
||||||
* Drizzle's mysql2 driver runs `.select()` through the text protocol
|
|
||||||
* (pool.query()), so it never uses server-side prepared statements. For
|
|
||||||
* hot-path queries the SQL is parsed once on the server per connection
|
|
||||||
* instead of every execution. Column aliases let you pick exactly the
|
|
||||||
* columns you need (no SELECT *).
|
|
||||||
*/
|
|
||||||
export async function queryPrepared<T extends Record<string, unknown>>(
|
|
||||||
sql: string,
|
|
||||||
params?: PreparedValue[],
|
|
||||||
): Promise<T[]> {
|
|
||||||
const client = db.$client as MySqlPool;
|
|
||||||
const [rows] = (await client.execute(sql, params)) as unknown as [
|
|
||||||
T[],
|
|
||||||
unknown,
|
|
||||||
];
|
|
||||||
return rows ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryPreparedOne<T extends Record<string, unknown>>(
|
|
||||||
sql: string,
|
|
||||||
params?: PreparedValue[],
|
|
||||||
): Promise<T | null> {
|
|
||||||
const rows = await queryPrepared<T>(sql, params);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export * from "@/db/schema";
|
export * from "@/db/schema";
|
||||||
@@ -40,7 +40,7 @@ function fail(
|
|||||||
return { ok: false, error, fieldErrors };
|
return { ok: false, error, fieldErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { fail as actionError, ok as actionOk };
|
export { ok as actionOk };
|
||||||
|
|
||||||
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
||||||
/** Single slug, or any-of list (e.g. admin.moderation.edit OR mod.actions). */
|
/** Single slug, or any-of list (e.g. admin.moderation.edit OR mod.actions). */
|
||||||
|
|||||||
@@ -25,10 +25,7 @@ interface FigureDataFile {
|
|||||||
setTypes: FdSetType[];
|
setTypes: FdSetType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let pathOverride: string | null = null;
|
const pathOverride: string | null = null;
|
||||||
export function __setFigureDataPathForTests(p: string | null): void {
|
|
||||||
pathOverride = p;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFigureDataPath(): Promise<string> {
|
export async function getFigureDataPath(): Promise<string> {
|
||||||
if (pathOverride) return pathOverride;
|
if (pathOverride) return pathOverride;
|
||||||
|
|||||||
@@ -271,7 +271,6 @@ import type {
|
|||||||
import {
|
import {
|
||||||
finalizeLangFile,
|
finalizeLangFile,
|
||||||
finalizePatch,
|
finalizePatch,
|
||||||
lookupTranslation,
|
|
||||||
prepareLangFile,
|
prepareLangFile,
|
||||||
preparePatch,
|
preparePatch,
|
||||||
} from "./furni-data-i18n-core";
|
} from "./furni-data-i18n-core";
|
||||||
@@ -283,10 +282,7 @@ export type {
|
|||||||
PreparedPatch,
|
PreparedPatch,
|
||||||
Translation,
|
Translation,
|
||||||
} from "./furni-data-i18n-core";
|
} from "./furni-data-i18n-core";
|
||||||
export {
|
export { lookupTranslation } from "./furni-data-i18n-core";
|
||||||
lookupTranslation,
|
|
||||||
officialIsRealTranslation,
|
|
||||||
} from "./furni-data-i18n-core";
|
|
||||||
|
|
||||||
export async function fetchTranslationsForHotel(
|
export async function fetchTranslationsForHotel(
|
||||||
hotel: HabboGamedataHotel,
|
hotel: HabboGamedataHotel,
|
||||||
@@ -643,31 +639,3 @@ export async function patchLocalizedFurniDataEntries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Visible for tests — apply translations to a furnidata clone in place. */
|
/** Visible for tests — apply translations to a furnidata clone in place. */
|
||||||
export function applyTranslationsForTest(
|
|
||||||
data: Record<string, { furnitype: Array<Record<string, unknown>> }>,
|
|
||||||
translations: Map<string, Translation>,
|
|
||||||
): { translatedRoom: number; translatedWall: number } {
|
|
||||||
let translatedRoom = 0;
|
|
||||||
let translatedWall = 0;
|
|
||||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
||||||
for (const entry of data[section]?.furnitype ?? []) {
|
|
||||||
const cn = typeof entry.classname === "string" ? entry.classname : "";
|
|
||||||
const t = lookupTranslation(translations, cn);
|
|
||||||
if (!t) continue;
|
|
||||||
let did = false;
|
|
||||||
if (t.name) {
|
|
||||||
entry.name = t.name;
|
|
||||||
did = true;
|
|
||||||
}
|
|
||||||
if (t.description) {
|
|
||||||
entry.description = t.description;
|
|
||||||
did = true;
|
|
||||||
}
|
|
||||||
if (did) {
|
|
||||||
if (section === "roomitemtypes") translatedRoom++;
|
|
||||||
else translatedWall++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { translatedRoom, translatedWall };
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -96,13 +96,6 @@ export async function listFurniture(
|
|||||||
/**
|
/**
|
||||||
* Search furniture by query string.
|
* Search furniture by query string.
|
||||||
*/
|
*/
|
||||||
export async function searchFurniture(
|
|
||||||
query: string,
|
|
||||||
perPage = 50,
|
|
||||||
): Promise<HabboFurniItem[]> {
|
|
||||||
const result = await listFurniture({ search: query, perPage });
|
|
||||||
return result.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Direct lookup by classname ─────────────────────────────────────
|
// ── Direct lookup by classname ─────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ export function validatePngBytes(buffer: Buffer): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function tryDownloadCandidates(
|
||||||
|
urls: string[],
|
||||||
|
destPath: string,
|
||||||
|
kind: "swf" | "png",
|
||||||
|
): Promise<boolean> {
|
||||||
|
for (const url of urls) {
|
||||||
|
const res = await downloadFile(url, destPath, { validate: kind });
|
||||||
|
if (res.ok) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadFile(
|
export async function downloadFile(
|
||||||
url: string,
|
url: string,
|
||||||
destPath: string,
|
destPath: string,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -139,9 +139,4 @@ export type { TraxChannel, TraxEvent } from "@/lib/trax-format";
|
|||||||
// Re-export the Trax format parser for server-side use. The actual
|
// Re-export the Trax format parser for server-side use. The actual
|
||||||
// implementation lives in `@/lib/trax-format` so it can be imported by
|
// implementation lives in `@/lib/trax-format` so it can be imported by
|
||||||
// client components without pulling in Node-only deps from this file.
|
// client components without pulling in Node-only deps from this file.
|
||||||
export {
|
export { parseTrackSamples } from "@/lib/trax-format";
|
||||||
detectSoundtrackKind,
|
|
||||||
parseTrackSamples,
|
|
||||||
parseTraxChannels,
|
|
||||||
TRAX_TICK_SECONDS,
|
|
||||||
} from "@/lib/trax-format";
|
|
||||||
+3
-1
@@ -30,7 +30,9 @@
|
|||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts",
|
||||||
|
".next-staging/types/**/*.ts",
|
||||||
|
".next-staging/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "e2e", "playwright.config.ts", "src/generated"]
|
"exclude": ["node_modules", "e2e", "playwright.config.ts", "src/generated"]
|
||||||
}
|
}
|
||||||
+304
-21
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user