feat: add auto furniture interaction detection
- New auto-interaction.ts: detects canSit/canLay/canStand from classname/name keywords - Maps keywords to interaction types (bench, chair, bed, table, etc.) - Returns clickLimit and interactionModesCount for proper click/interaction limits - Nitro editor: added auto-detect button with preview of detected settings - furni-import.ts: auto-sets interaction_modes_count during import - upload-import.ts: auto-detects for direct insert and SQL migrations - clone-import.ts: auto-detects when cloning from other hotels
This commit is contained in:
1 parent
0b4fc4559e
commit
dee843106f
8 files changed
+596
-25
No files matched your search
+11
-9
@@ -1,17 +1,19 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "epicnextcms",
|
name: 'epic-next-app',
|
||||||
script: "pnpm",
|
script: 'node_modules/next/dist/bin/next',
|
||||||
args: "start",
|
args: 'start',
|
||||||
instances: "max",
|
cwd: '/var/www/atom-nexst',
|
||||||
exec_mode: "cluster",
|
instances: 1,
|
||||||
node_args: "--v8-pool-size=4",
|
autorestart: true,
|
||||||
max_memory_restart: "1G",
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
node_args: '--max-old-space-size=1024',
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: 'production',
|
||||||
PORT: 3002
|
PORT: 3002
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -32,9 +32,14 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { adminFetch } from "@/lib/admin-fetch";
|
import { adminFetch } from "@/lib/admin-fetch";
|
||||||
|
import {
|
||||||
|
autoDetectInteraction,
|
||||||
|
getInteractionTypeLabel,
|
||||||
|
} from "@/lib/furni/auto-interaction";
|
||||||
|
|
||||||
interface NitroEditorDialogProps {
|
interface NitroEditorDialogProps {
|
||||||
classname: string;
|
classname: string;
|
||||||
|
name?: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -122,6 +127,7 @@ function CopyClassname({ value }: { value: string }) {
|
|||||||
|
|
||||||
export function NitroEditorDialog({
|
export function NitroEditorDialog({
|
||||||
classname,
|
classname,
|
||||||
|
name = "",
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NitroEditorDialogProps) {
|
}: NitroEditorDialogProps) {
|
||||||
@@ -142,8 +148,22 @@ export function NitroEditorDialog({
|
|||||||
cansiton: false,
|
cansiton: false,
|
||||||
canlayon: false,
|
canlayon: false,
|
||||||
});
|
});
|
||||||
|
const [autoDetectResult, setAutoDetectResult] = useState<ReturnType<
|
||||||
|
typeof autoDetectInteraction
|
||||||
|
> | null>(null);
|
||||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAutoDetect = () => {
|
||||||
|
const result = autoDetectInteraction(classname, name);
|
||||||
|
setAutoDetectResult(result);
|
||||||
|
setFlags({
|
||||||
|
canstandon: result.canStand,
|
||||||
|
cansiton: result.canSit,
|
||||||
|
canlayon: result.canLay,
|
||||||
|
});
|
||||||
|
toast.success(`Auto-detected: ${result.reason}`);
|
||||||
|
};
|
||||||
|
|
||||||
const patchMeta = (path: string, value: unknown) => {
|
const patchMeta = (path: string, value: unknown) => {
|
||||||
setMetadata((prev) =>
|
setMetadata((prev) =>
|
||||||
prev == null ? prev : updateNested(prev, path, value),
|
prev == null ? prev : updateNested(prev, path, value),
|
||||||
@@ -497,6 +517,53 @@ export function NitroEditorDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAutoDetect}
|
||||||
|
className="gap-1.5 w-full"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
Auto-detect from name
|
||||||
|
</Button>
|
||||||
|
{autoDetectResult && (
|
||||||
|
<div className="rounded-md bg-muted/50 p-3 text-xs space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
autoDetectResult.canLay ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{autoDetectResult.canLay ? (
|
||||||
|
<Layers className="w-2.5 h-2.5" />
|
||||||
|
) : autoDetectResult.canSit ? (
|
||||||
|
<MousePointerClick className="w-2.5 h-2.5" />
|
||||||
|
) : (
|
||||||
|
<MousePointerClick className="w-2.5 h-2.5" />
|
||||||
|
)}
|
||||||
|
{getInteractionTypeLabel(
|
||||||
|
autoDetectResult.interactionType,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
{autoDetectResult.clickLimit > 0 && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
Click limit: {autoDetectResult.clickLimit}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{autoDetectResult.interactionModesCount > 0 && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
Modes: {autoDetectResult.interactionModesCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{autoDetectResult.reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1374,6 +1374,7 @@ export function StudioClient({
|
|||||||
|
|
||||||
<NitroEditorDialog
|
<NitroEditorDialog
|
||||||
classname={editingClassname ?? ""}
|
classname={editingClassname ?? ""}
|
||||||
|
name={detail?.name ?? ""}
|
||||||
open={editingClassname !== null}
|
open={editingClassname !== null}
|
||||||
onOpenChange={(open) => !open && setEditingClassname(null)}
|
onOpenChange={(open) => !open && setEditingClassname(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* Auto-detect furniture interaction capabilities from classname/name.
|
||||||
|
* Determines: canSit, canLay, canStand, interactionType, clickLimit
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AutoInteractionResult {
|
||||||
|
canSit: boolean;
|
||||||
|
canLay: boolean;
|
||||||
|
canStand: boolean;
|
||||||
|
interactionType: string;
|
||||||
|
clickLimit: number;
|
||||||
|
interactionModesCount: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIT_KEYWORDS = [
|
||||||
|
"bench",
|
||||||
|
"bank",
|
||||||
|
"sofa",
|
||||||
|
"couch",
|
||||||
|
"chair",
|
||||||
|
"stool",
|
||||||
|
"seat",
|
||||||
|
"armchair",
|
||||||
|
"loveseat",
|
||||||
|
"settee",
|
||||||
|
"ottoman",
|
||||||
|
"pouf",
|
||||||
|
"beanbag",
|
||||||
|
"bean_bag",
|
||||||
|
"rocking",
|
||||||
|
"swing",
|
||||||
|
"hammock",
|
||||||
|
"throne",
|
||||||
|
"wheelchair",
|
||||||
|
"toilet",
|
||||||
|
"potty",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAY_KEYWORDS = [
|
||||||
|
"bed",
|
||||||
|
"mattress",
|
||||||
|
"futon",
|
||||||
|
"bunk",
|
||||||
|
"cot",
|
||||||
|
"hammock",
|
||||||
|
"sleeping",
|
||||||
|
"bedroll",
|
||||||
|
"blanket",
|
||||||
|
"pillow",
|
||||||
|
"cushion",
|
||||||
|
"lounger",
|
||||||
|
"sunbed",
|
||||||
|
"daybed",
|
||||||
|
"divan",
|
||||||
|
"waterbed",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAND_KEYWORDS = [
|
||||||
|
"table",
|
||||||
|
"desk",
|
||||||
|
"counter",
|
||||||
|
"bar",
|
||||||
|
"shelf",
|
||||||
|
"cabinet",
|
||||||
|
"wardrobe",
|
||||||
|
"dresser",
|
||||||
|
"bookcase",
|
||||||
|
"rack",
|
||||||
|
"stand",
|
||||||
|
"pedestal",
|
||||||
|
"platform",
|
||||||
|
"stage",
|
||||||
|
"floor",
|
||||||
|
"tile",
|
||||||
|
"carpet",
|
||||||
|
"rug",
|
||||||
|
"path",
|
||||||
|
"road",
|
||||||
|
"grass",
|
||||||
|
"dirt",
|
||||||
|
"sand",
|
||||||
|
"snow",
|
||||||
|
"ice",
|
||||||
|
"water",
|
||||||
|
"pool",
|
||||||
|
"fountain",
|
||||||
|
];
|
||||||
|
|
||||||
|
const INTERACTION_TYPE_MAP: Record<string, string> = {
|
||||||
|
bench: "bench",
|
||||||
|
bank: "bench",
|
||||||
|
sofa: "sofa",
|
||||||
|
couch: "sofa",
|
||||||
|
chair: "chair",
|
||||||
|
stool: "stool",
|
||||||
|
seat: "chair",
|
||||||
|
armchair: "armchair",
|
||||||
|
loveseat: "sofa",
|
||||||
|
settee: "sofa",
|
||||||
|
ottoman: "ottoman",
|
||||||
|
pouf: "ottoman",
|
||||||
|
beanbag: "beanbag",
|
||||||
|
rocking: "rocking_chair",
|
||||||
|
swing: "swing",
|
||||||
|
hammock: "hammock",
|
||||||
|
throne: "throne",
|
||||||
|
wheelchair: "wheelchair",
|
||||||
|
toilet: "toilet",
|
||||||
|
potty: "toilet",
|
||||||
|
bed: "bed",
|
||||||
|
mattress: "bed",
|
||||||
|
futon: "bed",
|
||||||
|
bunk: "bunkbed",
|
||||||
|
cot: "bed",
|
||||||
|
sleeping: "bed",
|
||||||
|
bedroll: "bed",
|
||||||
|
blanket: "bed",
|
||||||
|
pillow: "bed",
|
||||||
|
cushion: "bed",
|
||||||
|
lounger: "lounger",
|
||||||
|
sunbed: "sunbed",
|
||||||
|
daybed: "daybed",
|
||||||
|
divan: "divan",
|
||||||
|
waterbed: "waterbed",
|
||||||
|
table: "table",
|
||||||
|
desk: "desk",
|
||||||
|
counter: "counter",
|
||||||
|
bar: "bar",
|
||||||
|
shelf: "shelf",
|
||||||
|
cabinet: "cabinet",
|
||||||
|
wardrobe: "wardrobe",
|
||||||
|
dresser: "dresser",
|
||||||
|
bookcase: "bookcase",
|
||||||
|
rack: "rack",
|
||||||
|
stand: "stand",
|
||||||
|
pedestal: "pedestal",
|
||||||
|
platform: "platform",
|
||||||
|
stage: "stage",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERACTION_MODES: Record<string, number> = {
|
||||||
|
bench: 1,
|
||||||
|
bank: 1,
|
||||||
|
sofa: 1,
|
||||||
|
couch: 1,
|
||||||
|
chair: 1,
|
||||||
|
stool: 1,
|
||||||
|
seat: 1,
|
||||||
|
armchair: 1,
|
||||||
|
loveseat: 1,
|
||||||
|
settee: 1,
|
||||||
|
ottoman: 1,
|
||||||
|
pouf: 1,
|
||||||
|
beanbag: 1,
|
||||||
|
rocking: 1,
|
||||||
|
swing: 1,
|
||||||
|
hammock: 1,
|
||||||
|
throne: 1,
|
||||||
|
wheelchair: 1,
|
||||||
|
toilet: 1,
|
||||||
|
potty: 1,
|
||||||
|
bed: 1,
|
||||||
|
mattress: 1,
|
||||||
|
futon: 1,
|
||||||
|
bunk: 1,
|
||||||
|
cot: 1,
|
||||||
|
sleeping: 1,
|
||||||
|
bedroll: 1,
|
||||||
|
blanket: 1,
|
||||||
|
pillow: 1,
|
||||||
|
cushion: 1,
|
||||||
|
lounger: 1,
|
||||||
|
sunbed: 1,
|
||||||
|
daybed: 1,
|
||||||
|
divan: 1,
|
||||||
|
waterbed: 1,
|
||||||
|
table: 0,
|
||||||
|
desk: 0,
|
||||||
|
counter: 0,
|
||||||
|
bar: 0,
|
||||||
|
shelf: 0,
|
||||||
|
cabinet: 0,
|
||||||
|
wardrobe: 0,
|
||||||
|
dresser: 0,
|
||||||
|
bookcase: 0,
|
||||||
|
rack: 0,
|
||||||
|
stand: 0,
|
||||||
|
pedestal: 0,
|
||||||
|
platform: 0,
|
||||||
|
stage: 0,
|
||||||
|
floor: 0,
|
||||||
|
tile: 0,
|
||||||
|
carpet: 0,
|
||||||
|
rug: 0,
|
||||||
|
path: 0,
|
||||||
|
road: 0,
|
||||||
|
grass: 0,
|
||||||
|
dirt: 0,
|
||||||
|
sand: 0,
|
||||||
|
snow: 0,
|
||||||
|
ice: 0,
|
||||||
|
water: 0,
|
||||||
|
pool: 0,
|
||||||
|
fountain: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLICK_LIMITS: Record<string, number> = {
|
||||||
|
bench: 1,
|
||||||
|
bank: 1,
|
||||||
|
sofa: 1,
|
||||||
|
couch: 1,
|
||||||
|
chair: 1,
|
||||||
|
stool: 1,
|
||||||
|
seat: 1,
|
||||||
|
armchair: 1,
|
||||||
|
loveseat: 1,
|
||||||
|
settee: 1,
|
||||||
|
ottoman: 1,
|
||||||
|
pouf: 1,
|
||||||
|
beanbag: 1,
|
||||||
|
rocking: 1,
|
||||||
|
swing: 1,
|
||||||
|
hammock: 1,
|
||||||
|
throne: 1,
|
||||||
|
wheelchair: 1,
|
||||||
|
toilet: 1,
|
||||||
|
potty: 1,
|
||||||
|
bed: 1,
|
||||||
|
mattress: 1,
|
||||||
|
futon: 1,
|
||||||
|
bunk: 1,
|
||||||
|
cot: 1,
|
||||||
|
sleeping: 1,
|
||||||
|
bedroll: 1,
|
||||||
|
blanket: 1,
|
||||||
|
pillow: 1,
|
||||||
|
cushion: 1,
|
||||||
|
lounger: 1,
|
||||||
|
sunbed: 1,
|
||||||
|
daybed: 1,
|
||||||
|
divan: 1,
|
||||||
|
waterbed: 1,
|
||||||
|
table: 0,
|
||||||
|
desk: 0,
|
||||||
|
counter: 0,
|
||||||
|
bar: 0,
|
||||||
|
shelf: 0,
|
||||||
|
cabinet: 0,
|
||||||
|
wardrobe: 0,
|
||||||
|
dresser: 0,
|
||||||
|
bookcase: 0,
|
||||||
|
rack: 0,
|
||||||
|
stand: 0,
|
||||||
|
pedestal: 0,
|
||||||
|
platform: 0,
|
||||||
|
stage: 0,
|
||||||
|
floor: 0,
|
||||||
|
tile: 0,
|
||||||
|
carpet: 0,
|
||||||
|
rug: 0,
|
||||||
|
path: 0,
|
||||||
|
road: 0,
|
||||||
|
grass: 0,
|
||||||
|
dirt: 0,
|
||||||
|
sand: 0,
|
||||||
|
snow: 0,
|
||||||
|
ice: 0,
|
||||||
|
water: 0,
|
||||||
|
pool: 0,
|
||||||
|
fountain: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeString(str: string): string {
|
||||||
|
return str.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatch(normalized: string, keywords: string[]): string | null {
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (normalized.includes(kw)) {
|
||||||
|
return kw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoDetectInteraction(
|
||||||
|
classname: string,
|
||||||
|
name?: string,
|
||||||
|
): AutoInteractionResult {
|
||||||
|
const normalizedClassname = normalizeString(classname);
|
||||||
|
const normalizedName = name ? normalizeString(name) : "";
|
||||||
|
|
||||||
|
const searchText = `${normalizedClassname} ${normalizedName}`;
|
||||||
|
|
||||||
|
const sitMatch = findMatch(searchText, SIT_KEYWORDS);
|
||||||
|
const layMatch = findMatch(searchText, LAY_KEYWORDS);
|
||||||
|
const standMatch = findMatch(searchText, STAND_KEYWORDS);
|
||||||
|
|
||||||
|
let canSit = false;
|
||||||
|
let canLay = false;
|
||||||
|
let canStand = false;
|
||||||
|
let matchedKeyword: string | null = null;
|
||||||
|
let interactionType = "default";
|
||||||
|
let clickLimit = 0;
|
||||||
|
let interactionModesCount = 0;
|
||||||
|
|
||||||
|
if (layMatch) {
|
||||||
|
canLay = true;
|
||||||
|
canSit = true;
|
||||||
|
matchedKeyword = layMatch;
|
||||||
|
interactionType = INTERACTION_TYPE_MAP[layMatch] || "bed";
|
||||||
|
clickLimit = CLICK_LIMITS[layMatch] ?? 1;
|
||||||
|
interactionModesCount = INTERACTION_MODES[layMatch] ?? 1;
|
||||||
|
} else if (sitMatch) {
|
||||||
|
canSit = true;
|
||||||
|
matchedKeyword = sitMatch;
|
||||||
|
interactionType = INTERACTION_TYPE_MAP[sitMatch] || "chair";
|
||||||
|
clickLimit = CLICK_LIMITS[sitMatch] ?? 1;
|
||||||
|
interactionModesCount = INTERACTION_MODES[sitMatch] ?? 1;
|
||||||
|
} else if (standMatch) {
|
||||||
|
canStand = true;
|
||||||
|
matchedKeyword = standMatch;
|
||||||
|
interactionType = INTERACTION_TYPE_MAP[standMatch] || "default";
|
||||||
|
clickLimit = CLICK_LIMITS[standMatch] ?? 0;
|
||||||
|
interactionModesCount = INTERACTION_MODES[standMatch] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonParts: string[] = [];
|
||||||
|
if (matchedKeyword) {
|
||||||
|
reasonParts.push(`Matched "${matchedKeyword}"`);
|
||||||
|
}
|
||||||
|
if (canSit) reasonParts.push("can sit");
|
||||||
|
if (canLay) reasonParts.push("can lay");
|
||||||
|
if (canStand) reasonParts.push("can stand");
|
||||||
|
if (!matchedKeyword) {
|
||||||
|
reasonParts.push("No keywords matched - default");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSit,
|
||||||
|
canLay,
|
||||||
|
canStand,
|
||||||
|
interactionType,
|
||||||
|
clickLimit,
|
||||||
|
interactionModesCount,
|
||||||
|
reason: reasonParts.join(" · "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInteractionTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
bench: "Bench / Bank",
|
||||||
|
sofa: "Sofa / Couch",
|
||||||
|
chair: "Chair",
|
||||||
|
stool: "Stool",
|
||||||
|
armchair: "Armchair",
|
||||||
|
ottoman: "Ottoman / Pouf",
|
||||||
|
beanbag: "Bean Bag",
|
||||||
|
rocking_chair: "Rocking Chair",
|
||||||
|
swing: "Swing",
|
||||||
|
hammock: "Hammock",
|
||||||
|
throne: "Throne",
|
||||||
|
wheelchair: "Wheelchair",
|
||||||
|
toilet: "Toilet",
|
||||||
|
bed: "Bed",
|
||||||
|
bunkbed: "Bunk Bed",
|
||||||
|
lounger: "Lounger / Sunbed",
|
||||||
|
daybed: "Daybed",
|
||||||
|
divan: "Divan",
|
||||||
|
waterbed: "Waterbed",
|
||||||
|
table: "Table",
|
||||||
|
desk: "Desk",
|
||||||
|
counter: "Counter",
|
||||||
|
bar: "Bar",
|
||||||
|
shelf: "Shelf",
|
||||||
|
cabinet: "Cabinet",
|
||||||
|
wardrobe: "Wardrobe",
|
||||||
|
dresser: "Dresser",
|
||||||
|
bookcase: "Bookcase",
|
||||||
|
rack: "Rack",
|
||||||
|
stand: "Stand",
|
||||||
|
pedestal: "Pedestal",
|
||||||
|
platform: "Platform",
|
||||||
|
stage: "Stage",
|
||||||
|
default: "Default (no interaction)",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { eq, inArray, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
|
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||||
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
|
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||||
@@ -324,16 +325,21 @@ export async function cloneSingleFurni(params: {
|
|||||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||||
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
|
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
|
||||||
const allowStack = stackHeight > 0 ? "1" : "0";
|
const allowStack = stackHeight > 0 ? "1" : "0";
|
||||||
|
|
||||||
|
// Auto-detect interaction modes count from classname/name
|
||||||
|
const autoInteraction = autoDetectInteraction(classname, entry.name);
|
||||||
|
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||||
|
|
||||||
let newId: number;
|
let newId: number;
|
||||||
try {
|
try {
|
||||||
newId = await allocateItemsBaseId(async (nextId) => {
|
newId = await allocateItemsBaseId(async (nextId) => {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
INSERT INTO items_base
|
INSERT INTO items_base
|
||||||
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
||||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
|
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams)
|
||||||
VALUES
|
VALUES
|
||||||
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
|
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
|
||||||
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${entry.customparams})`);
|
${allowStack}, ${entry.cansiton ? "1" : "0"}, ${entry.canlayon ? "1" : "0"}, ${entry.canstandon ? "1" : "0"}, 'default', ${interactionModesCount}, ${entry.customparams})`);
|
||||||
return nextId;
|
return nextId;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const IMPORTED_PAGE_CAPTION = "Imported Furniture";
|
|||||||
export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog";
|
export { CATEGORY_PAGE, CLASSNAME_RULES } from "@/lib/furni/auto-catalog";
|
||||||
|
|
||||||
import { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
|
import { CATEGORY_PAGE } from "@/lib/furni/auto-catalog";
|
||||||
|
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -117,7 +118,9 @@ export function buildItemsBaseEnrichmentUpdate(params: {
|
|||||||
canstandon: boolean;
|
canstandon: boolean;
|
||||||
allowStack: string;
|
allowStack: string;
|
||||||
interactionType: string;
|
interactionType: string;
|
||||||
|
interactionModesCount?: number;
|
||||||
}): SQL {
|
}): SQL {
|
||||||
|
const modesCount = params.interactionModesCount ?? 0;
|
||||||
return sql`
|
return sql`
|
||||||
UPDATE items_base
|
UPDATE items_base
|
||||||
SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z},
|
SET width = ${params.dims.x}, length = ${params.dims.y}, stack_height = ${params.dims.z},
|
||||||
@@ -125,7 +128,8 @@ export function buildItemsBaseEnrichmentUpdate(params: {
|
|||||||
allow_lay = ${params.canlayon ? "1" : "0"},
|
allow_lay = ${params.canlayon ? "1" : "0"},
|
||||||
allow_walk = ${params.canstandon ? "1" : "0"},
|
allow_walk = ${params.canstandon ? "1" : "0"},
|
||||||
allow_stack = ${params.allowStack},
|
allow_stack = ${params.allowStack},
|
||||||
interaction_type = ${params.interactionType}
|
interaction_type = ${params.interactionType},
|
||||||
|
interaction_modes_count = ${modesCount}
|
||||||
WHERE id = ${params.id}
|
WHERE id = ${params.id}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -864,6 +868,10 @@ export async function importSingleFurni(params: {
|
|||||||
// allow_stack: if it has height and you can put things on it
|
// allow_stack: if it has height and you can put things on it
|
||||||
const allowStack = dims.z > 0 ? "1" : "0";
|
const allowStack = dims.z > 0 ? "1" : "0";
|
||||||
|
|
||||||
|
// Auto-detect interaction modes count from classname/name
|
||||||
|
const autoInteraction = autoDetectInteraction(classname, bestName);
|
||||||
|
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||||
|
|
||||||
// Update items_base with ALL enriched data
|
// Update items_base with ALL enriched data
|
||||||
try {
|
try {
|
||||||
const update = buildItemsBaseEnrichmentUpdate({
|
const update = buildItemsBaseEnrichmentUpdate({
|
||||||
@@ -874,6 +882,7 @@ export async function importSingleFurni(params: {
|
|||||||
canstandon,
|
canstandon,
|
||||||
allowStack,
|
allowStack,
|
||||||
interactionType,
|
interactionType,
|
||||||
|
interactionModesCount,
|
||||||
});
|
});
|
||||||
await db.execute(update);
|
await db.execute(update);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
|
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||||
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
|
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
|
||||||
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
||||||
import {
|
import {
|
||||||
@@ -158,6 +159,7 @@ async function writeSqlMigration(params: {
|
|||||||
customparams: string;
|
customparams: string;
|
||||||
price: { credits: number; points: number; pointsType: number };
|
price: { credits: number; points: number; pointsType: number };
|
||||||
categoryKey: string;
|
categoryKey: string;
|
||||||
|
interactionModesCount: number;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const num = await findNextMigrationNumber();
|
const num = await findNextMigrationNumber();
|
||||||
const padded = String(num).padStart(4, "0");
|
const padded = String(num).padStart(4, "0");
|
||||||
@@ -193,12 +195,12 @@ async function writeSqlMigration(params: {
|
|||||||
"",
|
"",
|
||||||
`INSERT INTO items_base`,
|
`INSERT INTO items_base`,
|
||||||
` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`,
|
` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`,
|
||||||
` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)`,
|
` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams)`,
|
||||||
`VALUES`,
|
`VALUES`,
|
||||||
` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`,
|
` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`,
|
||||||
` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`,
|
` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`,
|
||||||
` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`,
|
` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`,
|
||||||
` ${escapeSql(params.interactionType)}, ${escapeSql(params.customparams)});`,
|
` ${escapeSql(params.interactionType)}, ${params.interactionModesCount}, ${escapeSql(params.customparams)});`,
|
||||||
"",
|
"",
|
||||||
`INSERT INTO catalog_items`,
|
`INSERT INTO catalog_items`,
|
||||||
` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`,
|
` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`,
|
||||||
@@ -328,16 +330,20 @@ export async function uploadSingleFurni(params: {
|
|||||||
|
|
||||||
const price = autoPriceFurni(classname);
|
const price = autoPriceFurni(classname);
|
||||||
|
|
||||||
|
// Auto-detect interaction modes count from classname/name
|
||||||
|
const autoInteraction = autoDetectInteraction(classname, name);
|
||||||
|
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||||
|
|
||||||
let newId: number;
|
let newId: number;
|
||||||
try {
|
try {
|
||||||
newId = await allocateItemsBaseId(async (nextId) => {
|
newId = await allocateItemsBaseId(async (nextId) => {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
INSERT INTO items_base
|
INSERT INTO items_base
|
||||||
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
|
||||||
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
|
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, interaction_modes_count, customparams)
|
||||||
VALUES
|
VALUES
|
||||||
(${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight},
|
(${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight},
|
||||||
${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`);
|
${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${interactionModesCount}, ${customparams})`);
|
||||||
return nextId;
|
return nextId;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -402,6 +408,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
customparams,
|
customparams,
|
||||||
price,
|
price,
|
||||||
categoryKey: classifyFurni(classname, itemType),
|
categoryKey: classifyFurni(classname, itemType),
|
||||||
|
interactionModesCount,
|
||||||
});
|
});
|
||||||
warnings.push(`SQL migration written: ${sqlFile}`);
|
warnings.push(`SQL migration written: ${sqlFile}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+99
-9
@@ -131,6 +131,13 @@ CONFIG_BACKUP_DIR="${SCRIPT_DIR}/storage/config-backups"
|
|||||||
mkdir -p "$CONFIG_BACKUP_DIR" 2>/dev/null || true
|
mkdir -p "$CONFIG_BACKUP_DIR" 2>/dev/null || true
|
||||||
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
|
mkdir -p "$BACKUP_DIR" 2>/dev/null || true
|
||||||
NITRO_LANG="${NITRO_LANG:-en}"
|
NITRO_LANG="${NITRO_LANG:-en}"
|
||||||
|
# Nginx site config(s) that serve /gamedata/. After every build/rebuild the
|
||||||
|
# update script (re)adds an exact-match redirect so /gamedata/ points at
|
||||||
|
# /gamedata/config/ instead of the raw directory listing.
|
||||||
|
NGINX_SITE_CONFS=(
|
||||||
|
"${NITRO_NGINX_SITE_CONF:-/etc/nginx/sites-enabled/cms.conf}"
|
||||||
|
"${NITRO_NGINX_SITE_CONF_AVAILABLE:-/etc/nginx/sites-available/cms.conf}"
|
||||||
|
)
|
||||||
|
|
||||||
MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=${NITRO_SSL_VERIFY}"
|
MYSQL_CRED="-h $DB_HOST -P $DB_PORT -u $DB_USER --ssl-verify-server-cert=${NITRO_SSL_VERIFY}"
|
||||||
[ -n "$DB_PASS" ] && MYSQL_CRED="$MYSQL_CRED --password=$DB_PASS"
|
[ -n "$DB_PASS" ] && MYSQL_CRED="$MYSQL_CRED --password=$DB_PASS"
|
||||||
@@ -971,24 +978,53 @@ git_revert_repo() {
|
|||||||
rm -f "$PARALLEL_STATE_DIR/oldhead-$name" 2>/dev/null || true
|
rm -f "$PARALLEL_STATE_DIR/oldhead-$name" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Run a yarn command inside a repo that pins an unavailable Yarn Berry version.
|
||||||
|
# Upstream package.json now declares e.g. "packageManager": "[email protected]",
|
||||||
|
# which the system Yarn 1 refuses to run ("current global version is 1.22.22")
|
||||||
|
# and which corepack cannot fetch (yarn 4 is not published on the registry).
|
||||||
|
# Drop the field for the duration of the command, then restore the pristine
|
||||||
|
# file so the repo stays clean for the next git pull. Runs inside the repo and
|
||||||
|
# always returns the caller to SCRIPT_DIR so cwd never leaks between repos.
|
||||||
|
yarn_run() {
|
||||||
|
local repo="$1"; shift
|
||||||
|
cd "$repo" 2>/dev/null || { warn "Cannot cd into %s" "$repo"; return 1; }
|
||||||
|
local modified=false
|
||||||
|
if python3 -c "
|
||||||
|
import json, sys
|
||||||
|
p = 'package.json'
|
||||||
|
d = json.load(open(p))
|
||||||
|
pm = d.get('packageManager', '')
|
||||||
|
if pm.startswith('yarn@') and not pm.startswith('yarn@1.'):
|
||||||
|
d.pop('packageManager', None)
|
||||||
|
json.dump(d, open(p, 'w'), indent=2)
|
||||||
|
open(p, 'a').write(chr(10))
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null; then
|
||||||
|
modified=true
|
||||||
|
fi
|
||||||
|
yarn "$@"
|
||||||
|
local rc=$?
|
||||||
|
if [ "$modified" = true ]; then
|
||||||
|
git checkout -- package.json 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
cd "$SCRIPT_DIR" 2>/dev/null || true
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
# yarn install scoped to a per-repo cache folder. Parallel installs (renderer +
|
# yarn install scoped to a per-repo cache folder. Parallel installs (renderer +
|
||||||
# client) must never share a cache — yarn 1 corrupts the shared cache, leaving
|
# client) must never share a cache — yarn 1 corrupts the shared cache, leaving
|
||||||
# node_modules incomplete (vite/pixi.js missing) while still exiting 0.
|
# node_modules incomplete (vite/pixi.js missing) while still exiting 0.
|
||||||
# $1 = repo dir; remaining args forwarded to yarn. Runs inside the repo and
|
# $1 = repo dir; remaining args forwarded to yarn.
|
||||||
# always returns the caller to SCRIPT_DIR so cwd never leaks between repos.
|
|
||||||
yarn_install_repo() {
|
yarn_install_repo() {
|
||||||
local repo="$1"; shift
|
local repo="$1"; shift
|
||||||
local cache_dir="$YARN_CACHE_DIR/$(basename "$repo")"
|
local cache_dir="$YARN_CACHE_DIR/$(basename "$repo")"
|
||||||
mkdir -p "$cache_dir" 2>/dev/null || true
|
mkdir -p "$cache_dir" 2>/dev/null || true
|
||||||
cd "$repo" 2>/dev/null || { warn "Cannot cd into %s" "$repo"; return 1; }
|
|
||||||
# NODE_ENV may be exported as "production" via .env (the CMS runs in prod),
|
# NODE_ENV may be exported as "production" via .env (the CMS runs in prod),
|
||||||
# which makes yarn 1 skip devDependencies — vite is a devDep, so builds
|
# which makes yarn 1 skip devDependencies — vite is a devDep, so builds
|
||||||
# would fail with "vite: not found". Force a non-production env so the full
|
# would fail with "vite: not found". Force a non-production env so the full
|
||||||
# dependency tree (including dev deps) is always installed.
|
# dependency tree (including dev deps) is always installed.
|
||||||
NODE_ENV=development yarn install --cache-folder "$cache_dir" "$@" >> "$LOG_FILE" 2>&1
|
NODE_ENV=development yarn_run "$repo" install --cache-folder "$cache_dir" "$@" >> "$LOG_FILE" 2>&1
|
||||||
local rc=$?
|
|
||||||
cd "$SCRIPT_DIR" 2>/dev/null || true
|
|
||||||
return $rc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Nuke a repo's own yarn cache and node_modules for a truly clean reinstall.
|
# Nuke a repo's own yarn cache and node_modules for a truly clean reinstall.
|
||||||
@@ -1314,7 +1350,7 @@ update_client() {
|
|||||||
fi
|
fi
|
||||||
spinner_stop ok
|
spinner_stop ok
|
||||||
spinner_start "Building frontend..."
|
spinner_start "Building frontend..."
|
||||||
if yarn build >> "$LOG_FILE" 2>&1; then
|
if yarn_run "$NITRO_CLIENT" build >> "$LOG_FILE" 2>&1; then
|
||||||
spinner_stop ok
|
spinner_stop ok
|
||||||
else
|
else
|
||||||
spinner_stop fail
|
spinner_stop fail
|
||||||
@@ -1511,8 +1547,62 @@ flyaway_repair_db() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# After a build/rebuild, ensure nginx redirects /gamedata/ (and /gamedata)
|
||||||
|
# to /gamedata/config/ instead of serving the raw directory listing.
|
||||||
|
# Idempotent: injects exact-match location blocks in front of the /gamedata/
|
||||||
|
# prefix block, validates with `nginx -t` (restoring the pre-edit copy on
|
||||||
|
# failure) and keeps a timestamped backup. The actual reload happens at the
|
||||||
|
# end of do_restart.
|
||||||
|
ensure_gamedata_redirect() {
|
||||||
|
local root_cmd=""
|
||||||
|
[ "$(id -u)" -eq 0 ] || root_cmd="sudo -n"
|
||||||
|
local changed=false
|
||||||
|
local conf tmp snippet bak
|
||||||
|
for conf in "${NGINX_SITE_CONFS[@]}"; do
|
||||||
|
[ -f "$conf" ] || continue
|
||||||
|
if grep -qs "location = /gamedata" "$conf" 2>/dev/null; then
|
||||||
|
ok "Gamedata redirect already present in %s" "$(basename "$conf")"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
tmp=$(mktemp)
|
||||||
|
snippet=" # Redirect /gamedata/ to /gamedata/config/
|
||||||
|
location = /gamedata { return 301 /gamedata/config/; }
|
||||||
|
location = /gamedata/ { return 301 /gamedata/config/; }
|
||||||
|
"
|
||||||
|
awk -v s="$snippet" '
|
||||||
|
/^[[:space:]]*location \/gamedata\/ \{/ && !done {
|
||||||
|
printf "%s", s
|
||||||
|
done = 1
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$conf" > "$tmp" 2>/dev/null
|
||||||
|
if [ ! -s "$tmp" ] || ! grep -qs "location = /gamedata" "$tmp" 2>/dev/null; then
|
||||||
|
rm -f "$tmp" 2>/dev/null || true
|
||||||
|
warn "Could not inject gamedata redirect into %s" "$conf"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
bak="$conf.bak-$(date +%s)"
|
||||||
|
$root_cmd cp "$conf" "$bak" 2>/dev/null || { rm -f "$tmp"; warn "Cannot backup %s" "$conf"; continue; }
|
||||||
|
if ! $root_cmd install -m 640 "$tmp" "$conf" 2>/dev/null; then
|
||||||
|
rm -f "$tmp" 2>/dev/null || true
|
||||||
|
warn "Cannot update %s" "$conf"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$tmp" 2>/dev/null || true
|
||||||
|
if ! sudo nginx -t >/dev/null 2>&1; then
|
||||||
|
$root_cmd cp "$bak" "$conf" 2>/dev/null || true
|
||||||
|
warn "nginx -t failed — reverted gamedata redirect in %s" "$conf"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
changed=true
|
||||||
|
ok "Added /gamedata/ → /gamedata/config/ redirect to %s" "$(basename "$conf")"
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
do_restart() {
|
do_restart() {
|
||||||
step 8 8 "Restart Services"
|
step 8 8 "Restart Services"
|
||||||
|
ensure_gamedata_redirect
|
||||||
if systemctl cat "$EMULATOR_SERVICE" &>/dev/null; then
|
if systemctl cat "$EMULATOR_SERVICE" &>/dev/null; then
|
||||||
sudo systemctl restart "$EMULATOR_SERVICE" 2>/dev/null && ok "$EMULATOR_SERVICE restarted" || warn "Restart failed"
|
sudo systemctl restart "$EMULATOR_SERVICE" 2>/dev/null && ok "$EMULATOR_SERVICE restarted" || warn "Restart failed"
|
||||||
if ! wait_for_emulator_health; then
|
if ! wait_for_emulator_health; then
|
||||||
@@ -1916,7 +2006,7 @@ cmd_flyaway_repair() {
|
|||||||
step 6 7 "$(_t "Rebuild Frontend")"
|
step 6 7 "$(_t "Rebuild Frontend")"
|
||||||
spinner_start "$(_t "Building frontend...")"
|
spinner_start "$(_t "Building frontend...")"
|
||||||
cd "$NITRO_CLIENT"
|
cd "$NITRO_CLIENT"
|
||||||
if yarn build >> "$LOG_FILE" 2>&1; then
|
if yarn_run "$NITRO_CLIENT" build >> "$LOG_FILE" 2>&1; then
|
||||||
spinner_stop ok
|
spinner_stop ok
|
||||||
ok "Frontend rebuilt"
|
ok "Frontend rebuilt"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in new issue
Block a user