Merge pull request #32 from duckietm/main

Sync with main
This commit is contained in:
DuckieTM
2026-03-21 08:45:16 +01:00
committed by GitHub
32 changed files with 8173 additions and 81 deletions
+1
View File
@@ -27,3 +27,4 @@ Thumbs.db
/build
*.zip
.env
.claude/
+1 -1
View File
@@ -16,7 +16,7 @@
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<base href="/client/" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+587
View File
@@ -0,0 +1,587 @@
/**
* Room Builder Plugin - Menu Costruzioni
*
* Plugin esterno per Nitro Client.
* Richiede il RoomBuilderPlugin.jar lato server (Arcturus).
*
* Se rimuovi questo file, il bottone scompare automaticamente dalla UI.
*
* Colori e stili uniformati al tema Nitro Client.
*/
(function ()
{
'use strict';
// ─── Nitro Theme Colors ───
var THEME = {
// Card / Window
headerBg: '#1E7295',
headerText: '#FFFFFF',
tabsBg: '#185D79',
cardBorder: '#283F5D',
contentBg: '#DFDFDF',
// Buttons
btnPrimary: '#3c6d82',
btnPrimaryBrd: '#1a617f',
btnPrimaryHov: '#4a8199',
btnActive: '#185D79',
btnActiveBrd: '#0f4a63',
btnActiveHov: '#1E7295',
btnDanger: '#a81a12',
btnDangerBrd: '#b9322a',
btnDangerHov: '#c43a32',
btnSuccess: '#00800b',
btnSuccessBrd: '#006d09',
btnWarning: '#ffc107',
btnWarningBrd: '#f3c12a',
// Dark panel (infostand style)
darkBg: '#212131',
darkBorder: '#383853',
darkShadow: 'inset 0 5px rgba(38,38,57,0.6), inset 0 -4px rgba(25,25,37,0.6)',
// Grid items
gridBg: '#CDD3D9',
gridBorder: '#B6BEC5',
gridActiveBg: '#ECECEC',
gridActiveBrd: '#FFFFFF',
// Text
textLight: '#FFFFFF',
textDark: '#212529',
textMuted: '#B6BEC5',
// Typography
fontFamily: 'Ubuntu, sans-serif',
fontSm: '0.7875rem',
fontBase: '0.9rem',
// Misc
borderRadius: '0.5rem',
borderRadiusSm: '0.25rem',
scrollThumb: 'rgba(30, 114, 149, 0.4)',
scrollThumbHov: 'rgba(30, 114, 149, 0.8)'
};
function waitForApi(callback, maxRetries)
{
if (maxRetries === undefined) maxRetries = 50;
if (window.NitroPlugins)
{
callback(window.NitroPlugins);
return;
}
if (maxRetries <= 0)
{
console.warn('[RoomBuilder] NitroPlugins API not found after retries');
return;
}
setTimeout(function () { waitForApi(callback, maxRetries - 1); }, 200);
}
// ─── Constants ───
var FLOOR = 10;
var WALL = 20;
// ─── Send chat command to server via proper API ───
function sendCommand(api, command)
{
try
{
api.sendChat(':' + command);
}
catch (e)
{
console.warn('[RoomBuilder] sendCommand error:', e);
}
}
// ─── Section Label Helper ───
function createSectionLabel(container, text)
{
var label = document.createElement('div');
label.textContent = text;
label.style.cssText = 'font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:bold;color:' + THEME.textDark + ';margin:10px 0 6px 0;padding-bottom:4px;border-bottom:1px solid ' + THEME.gridBorder + ';text-transform:uppercase;letter-spacing:0.5px';
container.appendChild(label);
}
// ─── Slider Helper ───
function createSlider(container, label, min, max, value, step, onChange)
{
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;padding:4px 6px;background:' + THEME.gridBg + ';border:1px solid ' + THEME.gridBorder + ';border-radius:' + THEME.borderRadiusSm;
var lbl = document.createElement('span');
lbl.textContent = label;
lbl.style.cssText = 'width:70px;color:' + THEME.textDark + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:bold;flex-shrink:0';
var slider = document.createElement('input');
slider.type = 'range';
slider.min = min;
slider.max = max;
slider.step = step || 1;
slider.value = value;
slider.style.cssText = 'flex:1;height:6px;cursor:pointer;accent-color:' + THEME.headerBg;
var valDisplay = document.createElement('span');
valDisplay.textContent = value;
valDisplay.style.cssText = 'width:28px;color:' + THEME.textDark + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';text-align:center;font-weight:bold';
var saveBtn = document.createElement('button');
saveBtn.innerHTML = '&#x1F4BE;';
saveBtn.title = 'Salva';
saveBtn.style.cssText = 'width:28px;height:28px;min-height:28px;display:flex;align-items:center;justify-content:center;background:' + THEME.btnSuccess + ';border:2px solid ' + THEME.btnSuccessBrd + ';border-radius:' + THEME.borderRadius + ';cursor:pointer;font-size:11px;color:' + THEME.textLight;
var resetBtn = document.createElement('button');
resetBtn.innerHTML = '&#x21A9;';
resetBtn.title = 'Ripristina';
resetBtn.style.cssText = 'width:28px;height:28px;min-height:28px;display:flex;align-items:center;justify-content:center;background:' + THEME.btnDanger + ';border:2px solid ' + THEME.btnDangerBrd + ';border-radius:' + THEME.borderRadius + ';color:' + THEME.textLight + ';cursor:pointer;font-size:13px;font-weight:bold';
slider.addEventListener('input', function ()
{
valDisplay.textContent = slider.value;
});
saveBtn.addEventListener('click', function ()
{
if (onChange) onChange(Number(slider.value));
});
resetBtn.addEventListener('click', function ()
{
slider.value = value;
valDisplay.textContent = value;
if (onChange) onChange(Number(value));
});
row.appendChild(lbl);
row.appendChild(slider);
row.appendChild(valDisplay);
row.appendChild(saveBtn);
row.appendChild(resetBtn);
container.appendChild(row);
return { slider: slider, valDisplay: valDisplay };
}
// ─── Button Helper ───
function createButton(container, label, onClick, opts)
{
opts = opts || {};
var isActive = opts.active || false;
var isDanger = opts.danger || false;
var bgColor = isDanger ? THEME.btnDanger : (isActive ? THEME.btnActive : THEME.btnPrimary);
var borderColor = isDanger ? THEME.btnDangerBrd : (isActive ? THEME.btnActiveBrd : THEME.btnPrimaryBrd);
var hoverColor = isDanger ? THEME.btnDangerHov : (isActive ? THEME.btnActiveHov : THEME.btnPrimaryHov);
var btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = 'padding:0.25rem 0.5rem;border-radius:' + THEME.borderRadius + ';color:' + THEME.textLight + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + ';font-weight:500;cursor:pointer;border:2px solid ' + borderColor + ';transition:background .15s;background:' + bgColor + ';min-height:28px;box-shadow:none;' + (opts.fullWidth ? 'width:100%;' : '') + (opts.extraStyle || '');
btn.addEventListener('mouseenter', function () { btn.style.background = hoverColor; });
btn.addEventListener('mouseleave', function () { btn.style.background = bgColor; });
btn.addEventListener('click', function ()
{
if (onClick) onClick(btn);
});
if (container) container.appendChild(btn);
return btn;
}
// ─── Grid Row Helper ───
function createButtonRow(container, cols, buttons)
{
var row = document.createElement('div');
row.style.cssText = 'display:grid;grid-template-columns:repeat(' + cols + ',1fr);gap:6px;margin-bottom:6px';
buttons.forEach(function (b) { createButton(row, b.label, b.onClick, b); });
container.appendChild(row);
}
// ─── Utility: iterate room floor objects ───
function forEachFloorObject(api, callback)
{
var session = api.getRoomSession();
var engine = api.getRoomEngine();
if (!session || !engine) return;
var objects = engine.getRoomObjects(session.roomId, FLOOR);
for (var i = 0; i < objects.length; i++)
{
var obj = engine.getRoomObject(session.roomId, objects[i].id, FLOOR);
if (obj) callback(obj, objects[i].id, session.roomId, engine);
}
}
// ─── State ───
var state = {
hidePyramids: false,
hideCarpets: false,
hideWalls: false,
hideWired: false,
frozen: false,
teleporting: false
};
// ─── Stack clipboard (client-side memory) ───
var stackClipboard = null;
// ─── Plugin Init ───
waitForApi(function (api)
{
api.register({
name: 'room-builder',
label: 'Menu costruzioni',
icon: 'icon-cog',
onOpen: function ()
{
var content = api.createWindow('room-builder', 'Menu costruzioni', 440);
if (!content) return;
// Apply Nitro content area style to the content container
content.style.cssText = 'padding:10px;font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontBase;
// ─── Warning banner (Nitro tabs style) ───
var banner = document.createElement('div');
banner.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:6px;background:' + THEME.tabsBg + ';border:1px solid ' + THEME.cardBorder + ';border-radius:' + THEME.borderRadiusSm + ';padding:8px 12px;margin-bottom:12px';
banner.innerHTML = '<span style="font-size:16px">&#x26A0;</span><span style="color:' + THEME.textLight + ';font-family:' + THEME.fontFamily + ';font-size:' + THEME.fontSm + '">Assicurati di non spammare per non essere <b>mutato</b></span><span style="font-size:16px">&#x26A0;</span>';
content.appendChild(banner);
// ─── Sliders Section ───
createSectionLabel(content, 'Controlli');
var slidersDiv = document.createElement('div');
slidersDiv.style.marginBottom = '8px';
createSlider(slidersDiv, 'Altezza', -10, 40, 0, 1, function (val)
{
try
{
sendCommand(api, 'autotile ' + val);
}
catch (e) { console.warn('[RoomBuilder] Height:', e); }
});
createSlider(slidersDiv, 'Velocita', 0, 10, 4, 1, function (val)
{
sendCommand(api, 'rb_speed ' + val);
});
content.appendChild(slidersDiv);
// ─── Screenshot ───
var ssDiv = document.createElement('div');
ssDiv.style.marginBottom = '8px';
createButton(ssDiv, 'Fai lo screenshot della stanza', function ()
{
api.takeScreenshot();
}, { fullWidth: true, extraStyle: 'padding:6px 12px;' });
content.appendChild(ssDiv);
// ─── Avatar Section ───
createSectionLabel(content, 'Avatar');
createButtonRow(content, 2, [
{
label: state.frozen ? '\u2713 Avatar bloccato' : 'Blocca avatar',
active: state.frozen,
onClick: function (btn)
{
state.frozen = !state.frozen;
btn.textContent = state.frozen ? '\u2713 Avatar bloccato' : 'Blocca avatar';
btn.style.background = state.frozen ? THEME.btnActive : THEME.btnPrimary;
btn.style.borderColor = state.frozen ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
sendCommand(api, 'blocca');
}
},
{
label: state.teleporting ? '\u2713 Teletrasporto ON' : 'Teletrasporto',
active: state.teleporting,
onClick: function (btn)
{
state.teleporting = !state.teleporting;
btn.textContent = state.teleporting ? '\u2713 Teletrasporto ON' : 'Teletrasporto';
btn.style.background = state.teleporting ? THEME.btnActive : THEME.btnPrimary;
btn.style.borderColor = state.teleporting ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
sendCommand(api, 'rb_teleport');
}
}
]);
// ─── Visibility Section ───
createSectionLabel(content, 'Visibilita');
createButtonRow(content, 3, [
{
label: state.hidePyramids ? 'Mostra piramidi' : 'Nascondi piramidi',
active: state.hidePyramids,
onClick: function (btn)
{
state.hidePyramids = !state.hidePyramids;
btn.style.background = state.hidePyramids ? THEME.btnActive : THEME.btnPrimary;
btn.style.borderColor = state.hidePyramids ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
btn.textContent = state.hidePyramids ? 'Mostra piramidi' : 'Nascondi piramidi';
try
{
forEachFloorObject(api, function (obj, objId, roomId, engine)
{
if (obj.type && obj.type.toLowerCase().indexOf('pyramid') >= 0)
{
engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hidePyramids ? 0 : 1);
}
});
}
catch (e) { console.warn('[RoomBuilder]', e); }
}
},
{
label: state.hideCarpets ? 'Mostra tappeti' : 'Nascondi tappeti',
active: state.hideCarpets,
onClick: function (btn)
{
state.hideCarpets = !state.hideCarpets;
btn.style.background = state.hideCarpets ? THEME.btnActive : THEME.btnPrimary;
btn.style.borderColor = state.hideCarpets ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
btn.textContent = state.hideCarpets ? 'Mostra tappeti' : 'Nascondi tappeti';
try
{
forEachFloorObject(api, function (obj, objId, roomId, engine)
{
if (obj.model)
{
var sizeZ = obj.model.getValue('furniture_size_z');
if (sizeZ !== undefined && sizeZ <= 0.01)
{
engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hideCarpets ? 0 : 1);
}
}
});
}
catch (e) { console.warn('[RoomBuilder]', e); }
}
},
{
label: state.hideWalls ? 'Mostra mura' : 'Nascondi mura',
active: state.hideWalls,
onClick: function (btn)
{
state.hideWalls = !state.hideWalls;
btn.style.background = state.hideWalls ? THEME.btnActive : THEME.btnPrimary;
btn.style.borderColor = state.hideWalls ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
btn.textContent = state.hideWalls ? 'Mostra mura' : 'Nascondi mura';
try
{
var session = api.getRoomSession();
var engine = api.getRoomEngine();
if (!session || !engine) return;
var objects = engine.getRoomObjects(session.roomId, WALL);
for (var i = 0; i < objects.length; i++)
{
engine.changeObjectModelData(session.roomId, objects[i].id, WALL, 'furniture_alpha_multiplier', state.hideWalls ? 0 : 1);
}
}
catch (e) { console.warn('[RoomBuilder]', e); }
}
}
]);
// ─── Stack Section ───
createSectionLabel(content, 'Pila (Stack)');
createButtonRow(content, 4, [
{
label: 'Annulla pila',
onClick: function ()
{
sendCommand(api, 'autotile');
}
},
{
label: 'Seleziona pila',
onClick: function ()
{
try
{
var session = api.getRoomSession();
var engine = api.getRoomEngine();
if (!session || !engine) return;
var objects = engine.getRoomObjects(session.roomId, FLOOR);
stackClipboard = [];
for (var i = 0; i < objects.length; i++)
{
var obj = engine.getRoomObject(session.roomId, objects[i].id, FLOOR);
if (obj && obj.location)
{
stackClipboard.push({
id: objects[i].id,
x: Math.floor(obj.location.x),
y: Math.floor(obj.location.y),
z: obj.location.z,
dir: obj.direction ? obj.direction.x : 0
});
}
}
console.log('[RoomBuilder] Pila selezionata: ' + stackClipboard.length + ' oggetti');
}
catch (e) { console.warn('[RoomBuilder]', e); }
}
},
{
label: 'Copia pila',
onClick: function ()
{
if (!stackClipboard || stackClipboard.length === 0)
{
console.log('[RoomBuilder] Nessuna pila selezionata');
return;
}
console.log('[RoomBuilder] Pila copiata: ' + stackClipboard.length + ' oggetti');
}
},
{
label: 'Posiziona pila',
onClick: function ()
{
if (!stackClipboard || stackClipboard.length === 0)
{
console.log('[RoomBuilder] Nessuna pila da posizionare');
return;
}
try
{
for (var i = 0; i < stackClipboard.length; i++)
{
var item = stackClipboard[i];
api.sendStackHeight(item.id, Math.round(item.z * 100));
}
console.log('[RoomBuilder] Pila posizionata: ' + stackClipboard.length + ' oggetti');
}
catch (e) { console.warn('[RoomBuilder]', e); }
}
}
]);
// ─── Room Management Section ───
createSectionLabel(content, 'Gestione stanza');
createButtonRow(content, 3, [
{
label: 'Impostazioni',
onClick: function () { api.createLinkEvent('navigator/toggle-room-info'); }
},
{
label: 'Reload stanza',
onClick: function ()
{
try
{
var session = api.getRoomSession();
if (session) api.createLinkEvent('navigator/goto/' + session.roomId);
}
catch (e) { }
}
},
{
label: 'Unload stanza',
onClick: function ()
{
api.visitDesktop();
}
}
]);
// ─── Floor Tools Section ───
createSectionLabel(content, 'Strumenti pavimento');
createButtonRow(content, 4, [
{
label: 'Max Tile',
onClick: function ()
{
sendCommand(api, 'maxtile');
}
},
{
label: 'Auto Tile',
onClick: function ()
{
sendCommand(api, 'autotile');
}
},
{
label: 'No Item Floor',
danger: true,
onClick: function ()
{
if (confirm('Sei sicuro? Tutti i furni verranno rimossi dal pavimento!'))
{
sendCommand(api, 'noitemfloor');
}
}
},
{
label: 'Edit Floorplan',
onClick: function () { api.createLinkEvent('floor-editor/toggle'); }
}
]);
// ─── Wired Section ───
createSectionLabel(content, 'Wired');
createButtonRow(content, 2, [
{
label: state.hideWired ? 'Mostra wired' : 'Nascondi wired',
active: state.hideWired,
onClick: function (btn)
{
state.hideWired = !state.hideWired;
btn.style.background = state.hideWired ? THEME.btnActive : THEME.btnPrimary;
btn.style.borderColor = state.hideWired ? THEME.btnActiveBrd : THEME.btnPrimaryBrd;
btn.textContent = state.hideWired ? 'Mostra wired' : 'Nascondi wired';
try
{
forEachFloorObject(api, function (obj, objId, roomId, engine)
{
if (obj.type && (obj.type.toLowerCase().indexOf('wf_') >= 0 || obj.type.toLowerCase().indexOf('wired') >= 0))
{
engine.changeObjectModelData(roomId, objId, FLOOR, 'furniture_alpha_multiplier', state.hideWired ? 0 : 1);
}
});
}
catch (e) { console.warn('[RoomBuilder]', e); }
}
},
{
label: 'Prendi tutti gli wired',
danger: true,
onClick: function ()
{
sendCommand(api, 'pickwired');
}
}
]);
// ─── Spacer ───
var spacer = document.createElement('div');
spacer.style.cssText = 'height:1px;background:' + THEME.gridBorder + ';margin:10px 0';
content.appendChild(spacer);
// ─── Back button (danger style like close button) ───
var backDiv = document.createElement('div');
createButton(backDiv, 'Torna indietro', function ()
{
api.destroyWindow('room-builder');
}, { fullWidth: true, danger: true, extraStyle: 'padding:6px 12px;' });
content.appendChild(backDiv);
},
onClose: function ()
{
if (window.NitroPlugins) window.NitroPlugins.destroyWindow('room-builder');
}
});
console.log('[NitroPlugins] Room Builder plugin loaded (Nitro theme)');
});
})();
+5 -5
View File
@@ -1,8 +1,8 @@
{
"socket.url": "ws://## YOUR HOST ##:2096",
"asset.url": "http://## YOUR HOST ##/gamedata",
"image.library.url": "http://## YOUR HOST ##/gamedata/c_images/",
"hof.furni.url": "http://## YOUR HOST ##",
"socket.url": "ws:localhost:2097",
"asset.url": "http://localhost:3000/public\nitro-assets\gamedata",
"image.library.url": "http://localhost:3000/swf/gamedata/c_images/",
"hof.furni.url": "http://localhost:3000/",
"images.url": "${asset.url}/images",
"gamedata.url": "${asset.url}",
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
@@ -585,4 +585,4 @@
"${images.url}/clear_icon.png",
"${images.url}/big_arrow.png"
]
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"socket.url": "ws://localhost:2097",
"asset.url": "http://localhost:3000/nitro-assets",
"image.library.url": "http://localhost:3000/swf/c_images/",
"hof.furni.url": "http://localhost:3000/swf/dcr/hof_furni",
"images.url": "${asset.url}/images",
"gamedata.url": "${asset.url}/gamedata",
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
"external.texts.url": [ "${gamedata.url}/ExternalTexts.json", "${gamedata.url}/UITexts.json" ],
"external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3",
"furnidata.url": "${gamedata.url}/FurnitureData.json",
"productdata.url": "${gamedata.url}/ProductData.json",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json",
"avatar.asset.url": "${asset.url}/bundled/figure/%libname%.nitro",
"avatar.asset.effect.url": "${asset.url}/bundled/effect/%libname%.nitro",
"furni.asset.url": "${asset.url}/bundled/furniture/%libname%.nitro",
"furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png",
"pet.asset.url": "${asset.url}/bundled/pet/%libname%.nitro",
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
"furni.rotation.bounce.steps": 20,
"furni.rotation.bounce.height": 0.0625,
"enable.avatar.arrow": false,
"system.log.debug": false,
"system.log.warn": false,
"system.log.error": false,
"system.log.events": false,
"system.log.packets": false,
"system.fps.animation": 24,
"system.fps.max": 60,
"system.pong.manually": true,
"system.pong.interval.ms": 20000,
"room.color.skip.transition": true,
"room.landscapes.enabled": true,
"avatar.mandatory.libraries": [
"bd:1",
"li:0"
],
"avatar.mandatory.effect.libraries": [
"dance.1",
"dance.2",
"dance.3",
"dance.4"
],
"avatar.default.figuredata": {"palettes":[{"id":1,"colors":[{"id":99999,"index":1001,"club":0,"selectable":false,"hexCode":"DDDDDD"},{"id":99998,"index":1001,"club":0,"selectable":false,"hexCode":"FAFAFA"}]},{"id":3,"colors":[{"id":10001,"index":1001,"club":0,"selectable":false,"hexCode":"EEEEEE"},{"id":10002,"index":1002,"club":0,"selectable":false,"hexCode":"FA3831"},{"id":10003,"index":1003,"club":0,"selectable":false,"hexCode":"FD92A0"},{"id":10004,"index":1004,"club":0,"selectable":false,"hexCode":"2AC7D2"},{"id":10005,"index":1005,"club":0,"selectable":false,"hexCode":"35332C"},{"id":10006,"index":1006,"club":0,"selectable":false,"hexCode":"EFFF92"},{"id":10007,"index":1007,"club":0,"selectable":false,"hexCode":"C6FF98"},{"id":10008,"index":1008,"club":0,"selectable":false,"hexCode":"FF925A"},{"id":10009,"index":1009,"club":0,"selectable":false,"hexCode":"9D597E"},{"id":10010,"index":1010,"club":0,"selectable":false,"hexCode":"B6F3FF"},{"id":10011,"index":1011,"club":0,"selectable":false,"hexCode":"6DFF33"},{"id":10012,"index":1012,"club":0,"selectable":false,"hexCode":"3378C9"},{"id":10013,"index":1013,"club":0,"selectable":false,"hexCode":"FFB631"},{"id":10014,"index":1014,"club":0,"selectable":false,"hexCode":"DFA1E9"},{"id":10015,"index":1015,"club":0,"selectable":false,"hexCode":"F9FB32"},{"id":10016,"index":1016,"club":0,"selectable":false,"hexCode":"CAAF8F"},{"id":10017,"index":1017,"club":0,"selectable":false,"hexCode":"C5C6C5"},{"id":10018,"index":1018,"club":0,"selectable":false,"hexCode":"47623D"},{"id":10019,"index":1019,"club":0,"selectable":false,"hexCode":"8A8361"},{"id":10020,"index":1020,"club":0,"selectable":false,"hexCode":"FF8C33"},{"id":10021,"index":1021,"club":0,"selectable":false,"hexCode":"54C627"},{"id":10022,"index":1022,"club":0,"selectable":false,"hexCode":"1E6C99"},{"id":10023,"index":1023,"club":0,"selectable":false,"hexCode":"984F88"},{"id":10024,"index":1024,"club":0,"selectable":false,"hexCode":"77C8FF"},{"id":10025,"index":1025,"club":0,"selectable":false,"hexCode":"FFC08E"},{"id":10026,"index":1026,"club":0,"selectable":false,"hexCode":"3C4B87"},{"id":10027,"index":1027,"club":0,"selectable":false,"hexCode":"7C2C47"},{"id":10028,"index":1028,"club":0,"selectable":false,"hexCode":"D7FFE3"},{"id":10029,"index":1029,"club":0,"selectable":false,"hexCode":"8F3F1C"},{"id":10030,"index":1030,"club":0,"selectable":false,"hexCode":"FF6393"},{"id":10031,"index":1031,"club":0,"selectable":false,"hexCode":"1F9B79"},{"id":10032,"index":1032,"club":0,"selectable":false,"hexCode":"FDFF33"}]}],"setTypes":[{"type":"hd","paletteId":1,"mandatory_f_0":true,"mandatory_f_1":true,"mandatory_m_0":true,"mandatory_m_1":true,"sets":[{"id":99999,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":1,"type":"bd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"hd","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"lh","colorable":true,"index":0,"colorindex":1},{"id":1,"type":"rh","colorable":true,"index":0,"colorindex":1}]}]},{"type":"bds","paletteId":1,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10001,"gender":"U","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"bds","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"lhs","colorable":true,"index":0,"colorindex":1},{"id":10001,"type":"rhs","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"bd"},{"partType":"rh"},{"partType":"lh"}]}]},{"type":"ss","paletteId":3,"mandatory_f_0":false,"mandatory_f_1":false,"mandatory_m_0":false,"mandatory_m_1":false,"sets":[{"id":10010,"gender":"F","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10001,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]},{"id":10011,"gender":"M","club":0,"colorable":true,"selectable":false,"preselectable":false,"sellable":false,"parts":[{"id":10002,"type":"ss","colorable":true,"index":0,"colorindex":1}],"hiddenLayers":[{"partType":"ch"},{"partType":"lg"},{"partType":"ca"},{"partType":"wa"},{"partType":"sh"},{"partType":"ls"},{"partType":"rs"},{"partType":"lc"},{"partType":"rc"},{"partType":"cc"},{"partType":"cp"}]}]}]},
"avatar.default.actions": {
"actions": [
{
"id": "Default",
"state": "std",
"precedence": 1000,
"main": true,
"isDefault": true,
"geometryType": "vertical",
"activePartSet": "figure",
"assetPartDefinition": "std"
}
]
},
"pet.types": [
"dog",
"cat",
"croco",
"terrier",
"bear",
"pig",
"lion",
"rhino",
"spider",
"turtle",
"chicken",
"frog",
"dragon",
"monster",
"monkey",
"horse",
"monsterplant",
"bunnyeaster",
"bunnyevil",
"bunnydepressed",
"bunnylove",
"pigeongood",
"pigeonevil",
"demonmonkey",
"bearbaby",
"terrierbaby",
"gnome",
"leprechaun",
"kittenbaby",
"puppybaby",
"pigletbaby",
"haloompa",
"fools",
"pterosaur",
"velociraptor",
"cow",
"dragondog"
],
"preload.assets.urls": [
"${asset.url}/bundled/generic/avatar_additions.nitro",
"${asset.url}/bundled/generic/group_badge.nitro",
"${asset.url}/bundled/generic/floor_editor.nitro",
"${images.url}/loading_icon.png",
"${images.url}/clear_icon.png",
"${images.url}/big_arrow.png"
]
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
"achievements.images.url": "${image.library.url}Quests/%image%.png",
"camera.url": "http://## YOUR HOST ##/camera/photo/",
"thumbnails.url": "http://## YOUR HOST ##/camera/photo/temp/thumb/%thumbnail%.png",
"camera.url": "http://localhost:3000/camera/photo/",
"thumbnails.url": "http://localhost:3000/camera/photo/temp/thumb/%thumbnail%.png",
"url.prefix": "",
"habbopages.url": "/gamedata/habbopages/",
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,4 +1,4 @@
import { GetRoomSessionManager } from '@nitrots/nitro-renderer';
import { GetRoomSessionManager, NitroLogger } from '@nitrots/nitro-renderer';
import { GetRoomSession } from './GetRoomSession';
import { GoToDesktop } from './GoToDesktop';
@@ -6,6 +6,8 @@ export const VisitDesktop = () =>
{
if(!GetRoomSession()) return;
NitroLogger.log('[VisitDesktop] Called (isReconnecting=' + GetRoomSessionManager().isReconnecting + ')');
GoToDesktop();
GetRoomSessionManager().removeSession(-1);
};
@@ -0,0 +1,5 @@
export interface CommandDefinition
{
key: string;
description: string;
}
+4
View File
@@ -9,6 +9,7 @@ import { CampaignView } from './campaign/CampaignView';
import { CatalogView } from './catalog/CatalogView';
import { ChatHistoryView } from './chat-history/ChatHistoryView';
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
import { FurniEditorView } from './furni-editor/FurniEditorView';
import { FriendsView } from './friends/FriendsView';
import { GameCenterView } from './game-center/GameCenterView';
import { GroupsView } from './groups/GroupsView';
@@ -21,6 +22,7 @@ import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView';
import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView';
import { NitropediaView } from './nitropedia/NitropediaView';
import { ExternalPluginLoader } from './plugins/ExternalPluginLoader';
import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView';
@@ -119,7 +121,9 @@ export const MainView: FC<{}> = props =>
<CampaignView />
<GameCenterView />
<FloorplanEditorView />
<FurniEditorView />
<YoutubeTvView />
<ExternalPluginLoader />
</>
);
};
@@ -0,0 +1,140 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useFurniEditor } from '../../hooks/furni-editor';
import { FurniEditorEditView } from './views/FurniEditorEditView';
import { FurniEditorSearchView } from './views/FurniEditorSearchView';
const TAB_SEARCH = 0;
const TAB_EDIT = 1;
export const FurniEditorView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState(TAB_SEARCH);
const {
items, total, page, loading, error, clearError,
selectedItem, catalogItems, furniDataEntry,
interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
} = useFurniEditor();
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prev => !prev);
return;
}
},
eventUrlPrefix: 'furni-editor/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(isVisible) loadInteractions();
}, [ isVisible ]);
useEffect(() =>
{
const handler = async (e: CustomEvent<{ spriteId: number }>) =>
{
const { spriteId } = e.detail;
const ok = await loadBySpriteId(spriteId);
if(ok) setActiveTab(TAB_EDIT);
};
window.addEventListener('furni-editor:open', handler as EventListener);
return () => window.removeEventListener('furni-editor:open', handler as EventListener);
}, [ loadBySpriteId ]);
const handleSelect = useCallback(async (id: number) =>
{
const ok = await loadDetail(id);
if(ok) setActiveTab(TAB_EDIT);
}, [ loadDetail ]);
const handleBack = useCallback(() =>
{
setActiveTab(TAB_SEARCH);
}, []);
const handleClose = useCallback(() =>
{
setIsVisible(false);
}, []);
if(!isVisible) return null;
return (
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
<NitroCardHeaderView headerText="Furni Editor" onCloseClick={ handleClose } />
<NitroCardTabsView>
<NitroCardTabsItemView isActive={ activeTab === TAB_SEARCH } onClick={ () => setActiveTab(TAB_SEARCH) }>
Search
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === TAB_EDIT } onClick={ () => selectedItem && setActiveTab(TAB_EDIT) }>
Edit
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView>
{ error &&
<div className="bg-[#f8d7da] border border-[#f5c6cb] rounded p-2 text-[#721c24] text-xs mb-1 flex justify-between items-center">
<span>{ error }</span>
<span className="cursor-pointer font-bold" onClick={ clearError }>x</span>
</div>
}
{ activeTab === TAB_SEARCH &&
<FurniEditorSearchView
items={ items }
total={ total }
page={ page }
loading={ loading }
onSearch={ searchItems }
onSelect={ handleSelect }
/>
}
{ activeTab === TAB_EDIT && selectedItem &&
<FurniEditorEditView
item={ selectedItem }
catalogItems={ catalogItems }
furniDataEntry={ furniDataEntry }
interactions={ interactions }
loading={ loading }
onUpdate={ updateItem }
onDelete={ deleteItem }
onBack={ handleBack }
onRefresh={ loadDetail }
/>
}
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,159 @@
import { FC, useCallback, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
interface FurniEditorCreateViewProps
{
interactions: string[];
loading: boolean;
onCreate: (fields: Record<string, unknown>) => Promise<number | null>;
onCreated: (id: number) => void;
}
export const FurniEditorCreateView: FC<FurniEditorCreateViewProps> = props =>
{
const { interactions, loading, onCreate, onCreated } = props;
const [ success, setSuccess ] = useState<number | null>(null);
const [ form, setForm ] = useState({
itemName: '',
publicName: '',
spriteId: 0,
type: 's' as 's' | 'i',
width: 1,
length: 1,
stackHeight: 0,
allowStack: true,
allowSit: false,
allowLay: false,
allowWalk: false,
allowGift: true,
allowTrade: true,
allowRecycle: true,
allowMarketplaceSell: true,
allowInventoryStack: true,
interactionType: '',
interactionModesCount: 1,
customparams: '',
});
const setField = useCallback((key: string, value: unknown) =>
{
setForm(prev => ({ ...prev, [key]: value }));
setSuccess(null);
}, []);
const handleCreate = useCallback(async () =>
{
if(!form.itemName || !form.publicName) return;
const id = await onCreate(form);
if(id)
{
setSuccess(id);
setTimeout(() => onCreated(id), 1000);
}
}, [ form, onCreate, onCreated ]);
const inputClass = 'form-control form-control-sm';
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
return (
<Column gap={ 1 } className="h-full overflow-auto">
{ success &&
<div className="bg-[#d4edda] border border-[#c3e6cb] rounded p-2 text-[#155724] text-xs">
Item created with ID #{ success }!
</div>
}
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={ labelClass }>Item Name *</label>
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } placeholder="my_custom_furni" />
</div>
<div>
<label className={ labelClass }>Public Name *</label>
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } placeholder="My Custom Furni" />
</div>
<div>
<label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Length</label>
<input type="number" className={ inputClass } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Stack Height</label>
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
</div>
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ key.replace('allow', '') }
</label>
)) }
</div>
</div>
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<option value="">none</option>
{ interactions.map(i => (
<option key={ i } value={ i }>{ i }</option>
)) }
</select>
</div>
<div>
<label className={ labelClass }>Modes</label>
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
</div>
</div>
<div className="mt-1">
<label className={ labelClass }>Custom Params</label>
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
</div>
</div>
<Flex className="mt-1">
<Button variant="success" disabled={ loading || !form.itemName || !form.publicName } onClick={ handleCreate }>
{ loading ? 'Creating...' : 'Create Item' }
</Button>
</Flex>
</Column>
);
};
@@ -0,0 +1,249 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor';
interface FurniEditorEditViewProps
{
item: FurniDetail;
catalogItems: CatalogRef[];
furniDataEntry: Record<string, unknown> | null;
interactions: string[];
loading: boolean;
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>;
onDelete: (id: number) => Promise<boolean>;
onBack: () => void;
onRefresh: (id: number) => void;
}
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{
const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props;
const [ form, setForm ] = useState({
itemName: '',
publicName: '',
spriteId: 0,
type: 's',
width: 1,
length: 1,
stackHeight: 0,
allowStack: true,
allowWalk: false,
allowSit: false,
allowLay: false,
allowGift: true,
allowTrade: true,
allowRecycle: true,
allowMarketplaceSell: true,
allowInventoryStack: true,
interactionType: '',
interactionModesCount: 0,
customparams: '',
});
const [ confirmDelete, setConfirmDelete ] = useState(false);
useEffect(() =>
{
if(!item) return;
setForm({
itemName: item.itemName || '',
publicName: item.publicName || '',
spriteId: item.spriteId || 0,
type: item.type || 's',
width: item.width || 1,
length: item.length || 1,
stackHeight: item.stackHeight || 0,
allowStack: !!item.allowStack,
allowWalk: !!item.allowWalk,
allowSit: !!item.allowSit,
allowLay: !!item.allowLay,
allowGift: !!item.allowGift,
allowTrade: !!item.allowTrade,
allowRecycle: !!item.allowRecycle,
allowMarketplaceSell: !!item.allowMarketplaceSell,
allowInventoryStack: !!item.allowInventoryStack,
interactionType: item.interactionType || '',
interactionModesCount: item.interactionModesCount || 0,
customparams: item.customparams || '',
});
setConfirmDelete(false);
}, [ item ]);
const setField = useCallback((key: string, value: unknown) =>
{
setForm(prev => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(async () =>
{
const ok = await onUpdate(item.id, form);
if(ok) onRefresh(item.id);
}, [ item, form, onUpdate, onRefresh ]);
const handleDelete = useCallback(async () =>
{
if(!confirmDelete) return setConfirmDelete(true);
const ok = await onDelete(item.id);
if(ok) onBack();
}, [ confirmDelete, item, onDelete, onBack ]);
const inputClass = 'form-control form-control-sm';
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
return (
<Column gap={ 1 } className="h-full overflow-auto">
<Flex gap={ 1 } alignItems="center" className="mb-1">
<Button variant="secondary" onClick={ onBack }>Back</Button>
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
<path fillRule="evenodd" d="M4.93 1.31a41.401 41.401 0 0 1 10.14 0C16.194 1.45 17 2.414 17 3.517V18.25a.75.75 0 0 1-1.075.676l-2.8-1.344-2.8 1.344a.75.75 0 0 1-.65 0l-2.8-1.344-2.8 1.344A.75.75 0 0 1 3 18.25V3.517c0-1.103.806-2.068 1.93-2.207Z" clipRule="evenodd" />
</svg>
<Text bold className="text-[12px]">{ item.id }</Text>
<span className="text-[#999] mx-0.5">|</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
<path d="M12.586 2.586a2 2 0 1 1 2.828 2.828l-3 3a2 2 0 0 1-2.828 0 1 1 0 0 0-1.414 1.414 4 4 0 0 0 5.656 0l3-3a4 4 0 0 0-5.656-5.656l-1.5 1.5a1 1 0 1 0 1.414 1.414l1.5-1.5ZM7.414 17.414a2 2 0 1 1-2.828-2.828l3-3a2 2 0 0 1 2.828 0 1 1 0 0 0 1.414-1.414 4 4 0 0 0-5.656 0l-3 3a4 4 0 0 0 5.656 5.656l1.5-1.5a1 1 0 1 0-1.414-1.414l-1.5 1.5Z" />
</svg>
<Text bold className="text-[12px]">{ item.spriteId }</Text>
</Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text>
</Flex>
{ /* Basic Info */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={ labelClass }>Item Name</label>
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Public Name</label>
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
</div>
<div>
<label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</div>
</div>
</div>
{ /* Dimensions */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
<div className="grid grid-cols-3 gap-2">
<div>
<label className={ labelClass }>Width</label>
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Length</label>
<input type="number" className={ inputClass } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
</div>
<div>
<label className={ labelClass }>Stack Height</label>
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
</div>
</div>
</div>
{ /* Permissions */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="form-check-input"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ key.replace('allow', '') }
</label>
)) }
</div>
</div>
{ /* Interaction */ }
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<label className={ labelClass }>Type</label>
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<option value="">none</option>
{ interactions.map(i => (
<option key={ i } value={ i }>{ i }</option>
)) }
</select>
</div>
<div>
<label className={ labelClass }>Modes</label>
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
</div>
</div>
<div className="mt-1">
<label className={ labelClass }>Custom Params</label>
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
</div>
</div>
{ /* Catalog References */ }
{ catalogItems.length > 0 &&
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Catalog ({ catalogItems.length })</Text>
<div className="text-[10px] space-y-0.5">
{ catalogItems.map(ci => (
<div key={ ci.id } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
<span>{ ci.catalogName } (page: { ci.pageName })</span>
<span>{ ci.costCredits }c + { ci.costPoints }p</span>
</div>
)) }
</div>
</div>
}
{ /* FurniData.json Entry */ }
{ furniDataEntry &&
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]">
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
<span className="font-bold text-[#555]">{ key }</span>
<span className="text-[#333] truncate ml-1 max-w-[120px] text-right">{ String(value ?? '') }</span>
</div>
)) }
</div>
</div>
}
{ /* Actions */ }
<Flex gap={ 1 } justifyContent="between" className="mt-1">
<Button variant="success" disabled={ loading } onClick={ handleSave }>
{ loading ? 'Saving...' : 'Save' }
</Button>
<Button
variant={ confirmDelete ? 'danger' : 'warning' }
disabled={ loading || item.usageCount > 0 }
onClick={ handleDelete }
>
{ confirmDelete ? 'Confirm Delete' : 'Delete' }
</Button>
</Flex>
</Column>
);
};
@@ -0,0 +1,134 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common';
import { FurniItem } from '../../../hooks/furni-editor';
interface FurniEditorSearchViewProps
{
items: FurniItem[];
total: number;
page: number;
loading: boolean;
onSearch: (query: string, type: string, page: number) => void;
onSelect: (id: number) => void;
}
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
{
const { items, total, page, loading, onSearch, onSelect } = props;
const [ query, setQuery ] = useState('');
const [ typeFilter, setTypeFilter ] = useState('');
useEffect(() =>
{
onSearch('', '', 1);
}, []);
const handleSearch = useCallback(() =>
{
onSearch(query, typeFilter, 1);
}, [ query, typeFilter, onSearch ]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) =>
{
if(e.key === 'Enter') handleSearch();
}, [ handleSearch ]);
const totalPages = Math.ceil(total / 20);
return (
<Column gap={ 1 } className="h-full">
<Flex gap={ 1 } alignItems="end">
<Column gap={ 0 } className="flex-1">
<Text small bold>Search</Text>
<input
type="text"
className="form-control form-control-sm"
placeholder="ID, name or sprite ID..."
value={ query }
onChange={ e => setQuery(e.target.value) }
onKeyDown={ handleKeyDown }
/>
</Column>
<Column gap={ 0 } className="w-[80px]">
<Text small bold>Type</Text>
<select
className="form-select form-select-sm"
value={ typeFilter }
onChange={ e => setTypeFilter(e.target.value) }
>
<option value="">All</option>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
</Column>
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
{ loading ? '...' : 'Search' }
</Button>
</Flex>
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
<table className="w-full text-xs">
<thead>
<tr className="bg-[#e8e8e8] sticky top-0">
<th className="px-2 py-1 text-left">ID</th>
<th className="px-2 py-1 text-left">Sprite</th>
<th className="px-2 py-1 text-left">Name</th>
<th className="px-2 py-1 text-left">Public Name</th>
<th className="px-2 py-1 text-center">Type</th>
<th className="px-2 py-1 text-left">Interaction</th>
</tr>
</thead>
<tbody>
{ items.map(item => (
<tr
key={ item.id }
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
onClick={ () => onSelect(item.id) }
>
<td className="px-2 py-1 font-mono">{ item.id }</td>
<td className="px-2 py-1 font-mono">{ item.spriteId }</td>
<td className="px-2 py-1 truncate max-w-[120px]">{ item.itemName }</td>
<td className="px-2 py-1 truncate max-w-[120px]">{ item.publicName }</td>
<td className="px-2 py-1 text-center">
<span className={ `px-1 rounded text-white text-[10px] ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
{ item.type === 's' ? 'Floor' : 'Wall' }
</span>
</td>
<td className="px-2 py-1 text-[10px]">{ item.interactionType || '-' }</td>
</tr>
)) }
{ items.length === 0 && !loading &&
<tr>
<td colSpan={ 6 } className="px-2 py-4 text-center text-[#999]">No items found</td>
</tr>
}
</tbody>
</table>
</Column>
{ totalPages > 1 &&
<Flex gap={ 1 } justifyContent="between" alignItems="center">
<Text small variant="gray">
{ total } items - Page { page }/{ totalPages }
</Text>
<Flex gap={ 1 }>
<Button
variant="secondary"
disabled={ page <= 1 }
onClick={ () => onSearch(query, typeFilter, page - 1) }
>
Prev
</Button>
<Button
variant="secondary"
disabled={ page >= totalPages }
onClick={ () => onSearch(query, typeFilter, page + 1) }
>
Next
</Button>
</Flex>
</Flex>
}
</Column>
);
};
@@ -0,0 +1,61 @@
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue } from '../../api';
import { subscribePlugins } from './NitroPluginApi';
// Force the global API to be initialized
import './NitroPluginApi';
export const ExternalPluginLoader: FC<{}> = () =>
{
const [, forceUpdate] = useState(0);
useEffect(() =>
{
return subscribePlugins(() => forceUpdate(n => n + 1));
}, []);
// MainView only renders after isReady=true in App.tsx,
// so the configuration is guaranteed to be loaded at this point.
useEffect(() =>
{
const scripts: HTMLScriptElement[] = [];
let pluginUrls: string[] = [];
try
{
pluginUrls = GetConfigurationValue<string[]>('external.plugins', []);
}
catch (e)
{
console.warn('[NitroPlugins] Could not read external.plugins config:', e);
return;
}
if (!pluginUrls || pluginUrls.length === 0)
{
console.log('[NitroPlugins] No external plugins configured');
return;
}
console.log('[NitroPlugins] Loading external plugins:', pluginUrls);
for (const url of pluginUrls)
{
const script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = () => console.log(`[NitroPlugins] Loaded: ${url}`);
script.onerror = () => console.warn(`[NitroPlugins] Failed to load: ${url}`);
document.head.appendChild(script);
scripts.push(script);
}
return () =>
{
scripts.forEach(s => s.remove());
};
}, []);
return null;
};
+245
View File
@@ -0,0 +1,245 @@
import { FurnitureStackHeightComposer, GetRoomEngine, TextureUtils } from '@nitrots/nitro-renderer';
import { CreateLinkEvent, GetRoomSession, SendMessageComposer, VisitDesktop } from '../../api';
/**
* Plugin descriptor registered by external plugin scripts.
*/
export interface INitroPlugin
{
/** Unique plugin name */
name: string;
/** Label shown on the button in room tools */
label: string;
/** CSS class for the icon (nitro-icon class) */
icon?: string;
/** Called when the plugin button is clicked */
onOpen: () => void;
/** Called to close/destroy the plugin UI */
onClose?: () => void;
/** Called when the plugin is first loaded, receives the Nitro API */
onInit?: (api: INitroPluginApi) => void;
}
/**
* API exposed to external plugins via window.NitroPlugins
*/
export interface INitroPluginApi
{
/** Register a plugin */
register: (plugin: INitroPlugin) => void;
/** Unregister a plugin by name */
unregister: (name: string) => void;
/** Get all registered plugins */
getPlugins: () => INitroPlugin[];
/** Fire a Nitro link event (e.g., 'navigator/toggle-room-info') */
createLinkEvent: (link: string) => void;
/** Get the room engine instance */
getRoomEngine: () => ReturnType<typeof GetRoomEngine>;
/** Get the current room session */
getRoomSession: () => ReturnType<typeof GetRoomSession>;
/** Send a message composer to the server */
sendMessage: typeof SendMessageComposer;
/** Send a chat message to the server (processed as command if starts with ':') */
sendChat: (text: string, styleId?: number) => void;
/** Send stack height update for a furniture item (objectId, heightInCentimeters) */
sendStackHeight: (objectId: number, height: number) => void;
/** Take a screenshot of the room and download it as PNG */
takeScreenshot: () => Promise<void>;
/** Leave the room and go to hotel view */
visitDesktop: () => void;
/** Create a draggable floating window and return its container element */
createWindow: (id: string, title: string, width: number) => HTMLDivElement | null;
/** Destroy a floating window by id */
destroyWindow: (id: string) => void;
}
// Internal plugin storage
const _plugins: INitroPlugin[] = [];
const _listeners: Array<() => void> = [];
function notifyListeners()
{
_listeners.forEach(fn => fn());
}
const pluginApi: INitroPluginApi = {
register(plugin: INitroPlugin)
{
if (_plugins.some(p => p.name === plugin.name)) return;
_plugins.push(plugin);
plugin.onInit?.(pluginApi);
notifyListeners();
},
unregister(name: string)
{
const index = _plugins.findIndex(p => p.name === name);
if (index >= 0)
{
_plugins[index].onClose?.();
_plugins.splice(index, 1);
notifyListeners();
}
},
getPlugins()
{
return [..._plugins];
},
createLinkEvent(link: string)
{
CreateLinkEvent(link);
},
getRoomEngine()
{
return GetRoomEngine();
},
getRoomSession()
{
return GetRoomSession();
},
sendMessage: SendMessageComposer,
sendChat(text: string, styleId: number = 0)
{
const session = GetRoomSession();
if (!session) return;
session.sendChatMessage(text, styleId, '');
},
sendStackHeight(objectId: number, height: number)
{
SendMessageComposer(new FurnitureStackHeightComposer(objectId, height));
},
async takeScreenshot()
{
try
{
const session = GetRoomSession();
if (!session) return;
const texture = GetRoomEngine().createTextureFromRoom(session.roomId, 1);
if (!texture) return;
const imageUrl = await TextureUtils.generateImageUrl(texture);
if (!imageUrl) return;
// Download the image
const link = document.createElement('a');
link.href = imageUrl;
link.download = `room_${session.roomId}_${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
catch (e)
{
console.warn('[NitroPlugins] Screenshot failed:', e);
}
},
visitDesktop()
{
VisitDesktop();
},
createWindow(id: string, title: string, width: number): HTMLDivElement | null
{
// Remove existing window with same id
pluginApi.destroyWindow(id);
// Create overlay container
const overlay = document.createElement('div');
overlay.id = `nitro-plugin-window-${id}`;
overlay.style.cssText = `position:fixed;z-index:500;top:50%;left:50%;transform:translate(-50%,-50%)`;
// Card wrapper
const card = document.createElement('div');
card.style.cssText = `width:${width}px;background:#2c3e50;border:1px solid #283F5D;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.5);overflow:hidden;font-family:Ubuntu,sans-serif`;
// Header (draggable)
const header = document.createElement('div');
header.style.cssText = `display:flex;align-items:center;justify-content:center;position:relative;min-height:33px;background:linear-gradient(180deg,#3c6a8e 0%,#2a4f6e 100%);cursor:move;user-select:none`;
const titleEl = document.createElement('span');
titleEl.textContent = title;
titleEl.style.cssText = `color:#fff;font-size:16px;text-shadow:0 1px 2px rgba(0,0,0,0.5)`;
const closeBtn = document.createElement('div');
closeBtn.style.cssText = `position:absolute;right:8px;width:20px;height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;border-radius:50%;background:rgba(255,255,255,0.1)`;
closeBtn.innerHTML = '✕';
closeBtn.addEventListener('click', () => pluginApi.destroyWindow(id));
header.appendChild(titleEl);
header.appendChild(closeBtn);
// Make draggable
let isDragging = false;
let offsetX = 0, offsetY = 0;
header.addEventListener('mousedown', (e: MouseEvent) =>
{
isDragging = true;
const rect = overlay.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
overlay.style.transform = 'none';
overlay.style.left = rect.left + 'px';
overlay.style.top = rect.top + 'px';
});
document.addEventListener('mousemove', (e: MouseEvent) =>
{
if (!isDragging) return;
overlay.style.left = (e.clientX - offsetX) + 'px';
overlay.style.top = (e.clientY - offsetY) + 'px';
});
document.addEventListener('mouseup', () => { isDragging = false; });
// Content area
const content = document.createElement('div');
content.style.cssText = `padding:16px`;
card.appendChild(header);
card.appendChild(content);
overlay.appendChild(card);
document.body.appendChild(overlay);
return content;
},
destroyWindow(id: string)
{
const existing = document.getElementById(`nitro-plugin-window-${id}`);
if (existing) existing.remove();
}
};
/**
* Subscribe to plugin list changes. Returns unsubscribe function.
*/
export function subscribePlugins(listener: () => void): () => void
{
_listeners.push(listener);
return () =>
{
const idx = _listeners.indexOf(listener);
if (idx >= 0) _listeners.splice(idx, 1);
};
}
export function getRegisteredPlugins(): INitroPlugin[]
{
return [..._plugins];
}
// Expose globally so external scripts can use it
(window as any).NitroPlugins = pluginApi;
export { pluginApi };
+13 -2
View File
@@ -20,6 +20,16 @@ export const ReconnectView: FC<{}> = props =>
const onReconnected = useCallback(() =>
{
// Socket is open but not yet re-authenticated.
// Update attempt display but keep the overlay visible until
// re-authentication completes (SOCKET_REAUTHENTICATED).
setHasFailed(false);
}, []);
const onReauthenticated = useCallback(() =>
{
// Fully re-authenticated — dismiss the overlay so the room view
// (which stayed alive behind the overlay) is visible again.
setIsReconnecting(false);
setHasFailed(false);
setAttempt(0);
@@ -33,6 +43,7 @@ export const ReconnectView: FC<{}> = props =>
useNitroEvent<ReconnectEvent>(NitroEventType.SOCKET_RECONNECTING, onReconnecting);
useNitroEvent(NitroEventType.SOCKET_RECONNECTED, onReconnected);
useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, onReauthenticated);
useNitroEvent(NitroEventType.SOCKET_RECONNECT_FAILED, onReconnectFailed);
const handleReload = useCallback(() =>
@@ -42,8 +53,8 @@ export const ReconnectView: FC<{}> = props =>
const handleGoHome = useCallback(() =>
{
sessionStorage.removeItem('nitro_last_room');
sessionStorage.removeItem('nitro_last_room_password');
sessionStorage.removeItem('nitro.session.lastRoomId');
sessionStorage.removeItem('nitro.session.lastRoomPassword');
window.location.reload();
}, []);
@@ -552,7 +552,21 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
{ godMode &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
{ canSeeFurniId && <Text small wrap variant="white">ID: { avatarInfo.id }</Text> }
{ canSeeFurniId &&
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#7ec8e3]">
<path fillRule="evenodd" d="M4.93 1.31a41.401 41.401 0 0 1 10.14 0C16.194 1.45 17 2.414 17 3.517V18.25a.75.75 0 0 1-1.075.676l-2.8-1.344-2.8 1.344a.75.75 0 0 1-.65 0l-2.8-1.344-2.8 1.344A.75.75 0 0 1 3 18.25V3.517c0-1.103.806-2.068 1.93-2.207Z" clipRule="evenodd" />
</svg>
<Text small wrap variant="white">ID: { avatarInfo.id }</Text>
</div>
<div className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#7ec8e3]">
<path d="M5.127 3.502 5.25 3.5h9.5c.041 0 .082 0 .123.002A2.251 2.251 0 0 0 12.75 2h-5.5a2.25 2.25 0 0 0-2.123 1.502ZM1 10.25A2.25 2.25 0 0 1 3.25 8h13.5A2.25 2.25 0 0 1 19 10.25v5.5A2.25 2.25 0 0 1 16.75 18H3.25A2.25 2.25 0 0 1 1 15.75v-5.5ZM3.25 6.5c-.04 0-.082 0-.123.002A2.25 2.25 0 0 1 5.25 5h9.5c.98 0 1.814.627 2.123 1.502a3.819 3.819 0 0 0-.123-.002H3.25Z" />
</svg>
<Text small wrap variant="white">Sprite: { (() => { const ro = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR); return ro?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID) ?? '?'; })() }</Text>
</div>
</div> }
{ (!avatarInfo.isWallItem && canMove) &&
<>
<button
@@ -560,6 +574,19 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
onClick={ () => setDropdownOpen(!dropdownOpen) }>
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
</button>
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
CreateLinkEvent('furni-editor/show');
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button>
{ dropdownOpen &&
<div className="flex gap-[4px] w-full">
{ /* Left panel: position + rotation */ }
@@ -0,0 +1,41 @@
import { FC, useEffect, useRef } from 'react';
import { CommandDefinition } from '../../../../api';
interface ChatInputCommandSelectorViewProps
{
commands: CommandDefinition[];
selectedIndex: number;
onSelect: (command: CommandDefinition) => void;
onHover: (index: number) => void;
}
export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps> = props =>
{
const { commands = [], selectedIndex = 0, onSelect = null, onHover = null } = props;
const listRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
if(!listRef.current) return;
const selected = listRef.current.children[selectedIndex] as HTMLElement;
if(selected) selected.scrollIntoView({ block: 'nearest' });
}, [ selectedIndex ]);
return (
<div ref={ listRef } className="absolute bottom-full left-0 w-full bg-[#e8e8e8] border-2 border-black border-b-0 rounded-t-lg max-h-[240px] overflow-y-auto z-[1070]">
{ commands.map((cmd, index) => (
<div
key={ cmd.key }
className={ `px-3 py-1.5 cursor-pointer text-sm flex items-center gap-2 ${ index === selectedIndex ? 'bg-[#283F5D] text-white' : 'hover:bg-gray-300' }` }
onClick={ () => onSelect(cmd) }
onMouseEnter={ () => onHover(index) }
>
<span className="font-bold">:{ cmd.key }</span>
<span className={ `text-xs ${ index === selectedIndex ? 'text-gray-300' : 'text-gray-500' }` }>{ cmd.description }</span>
</div>
)) }
</div>
);
};
@@ -3,7 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
import { Text } from '../../../../common';
import { useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
@@ -14,6 +15,7 @@ export const ChatInputView: FC<{}> = props =>
const { selectedUsername = '', floodBlocked = false, floodBlockedSeconds = 0, setIsTyping = null, setIsIdle = null, sendChat = null } = useChatInputWidget();
const { roomSession = null } = useRoom();
const inputRef = useRef<HTMLInputElement>();
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
@@ -133,6 +135,40 @@ export const ChatInputView: FC<{}> = props =>
if(document.activeElement !== inputRef.current) setInputFocus();
if(commandSelectorVisible)
{
switch(event.key)
{
case 'ArrowUp':
event.preventDefault();
moveUp();
return;
case 'ArrowDown':
event.preventDefault();
moveDown();
return;
case 'Tab':
event.preventDefault();
// fall through
case 'NumpadEnter':
case 'Enter': {
const selected = selectCurrent();
if(selected)
{
event.preventDefault();
setChatValue(':' + selected.key + ' ');
return;
}
break;
}
case 'Escape':
event.preventDefault();
closeCommandSelector();
return;
}
}
const value = (event.target as HTMLInputElement).value;
switch(event.key)
@@ -158,7 +194,7 @@ export const ChatInputView: FC<{}> = props =>
return;
}
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue ]);
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector ]);
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
{
@@ -243,7 +279,14 @@ export const ChatInputView: FC<{}> = props =>
return (
createPortal(
<div className="nitro-chat-input-container flex justify-between items-center relative h-10 border-2 border-black bg-gray-200 pr-2.5 w-full overflow-hidden rounded-lg">
<div className="nitro-chat-input-container flex justify-between items-center relative h-10 border-2 border-black bg-gray-200 pr-2.5 w-full overflow-visible rounded-lg">
{ commandSelectorVisible &&
<ChatInputCommandSelectorView
commands={ filteredCommands }
selectedIndex={ selectedIndex }
onSelect={ (cmd) => { setChatValue(':' + cmd.key + ' '); inputRef.current?.focus(); } }
onHover={ setSelectedIndex }
/> }
<div className="flex-1 items-center input-sizer">
{ !floodBlocked &&
<input ref={ inputRef } className="[font-size:inherit] placeholder-[#6c757d] bg-transparent border-none focus:border-current focus:shadow-none focus:ring-0 " maxLength={ maxChatLength } placeholder={ LocalizeText('widgets.chatinput.default') } type="text" value={ chatValue } onChange={ event => updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> }
@@ -5,6 +5,7 @@ import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
import { Text } from '../../../../common';
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
export const RoomToolsWidgetView: FC<{}> = props => {
const [areBubblesMuted, setAreBubblesMuted] = useState(false);
@@ -15,12 +16,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
const { navigatorData = null } = useNavigator();
const { roomSession = null } = useRoom();
// Subscribe to external plugin changes
useEffect(() =>
{
setPlugins(getRegisteredPlugins());
return subscribePlugins(() => setPlugins(getRegisteredPlugins()));
}, []);
const handleToolClick = (action: string, value?: string) => {
if (!roomSession) return;
switch (action) {
case 'settings':
CreateLinkEvent('navigator/toggle-room-info');
@@ -114,12 +123,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
<div className={classNames('cursor-pointer', 'nitro-icon', (!isZoomedIn && 'icon-zoom-less'), (isZoomedIn && 'icon-zoom-more'))} title={LocalizeText('room.zoom.button.text')} onClick={() => handleToolClick('zoom')} />
<div className="cursor-pointer nitro-icon icon-chat-history" title={LocalizeText('room.chathistory.button.text')} onClick={() => handleToolClick('chat_history')} />
<div className={classNames('cursor-pointer', 'nitro-icon', (areBubblesMuted ? 'icon-chat-disablebubble' : 'icon-chat-enablebubble'))} title={areBubblesMuted ? LocalizeText('room.unmute.button.text') : LocalizeText('room.mute.button.text')} onClick={() => handleToolClick('hiddenbubbles')} />
{navigatorData.canRate && (
<div className="cursor-pointer nitro-icon icon-like-room" title={LocalizeText('room.like.button.text')} onClick={() => handleToolClick('like_room')} />
)}
<div className="cursor-pointer nitro-icon icon-room-link" title={LocalizeText('navigator.embed.caption')} onClick={() => handleToolClick('toggle_room_link')} />
<div className="cursor-pointer nitro-icon icon-room-history-enabled" title={LocalizeText('room.history.button.tooltip')} onClick={() => handleToolClick('room_history')} />
{plugins.map(plugin => (
<div
key={plugin.name}
className={`cursor-pointer nitro-icon ${plugin.icon || 'icon-cog'}`}
title={plugin.label}
onClick={() => plugin.onOpen()}
/>
))}
</div>
<div className="flex flex-col justify-center">
<AnimatePresence>
@@ -159,4 +176,4 @@ export const RoomToolsWidgetView: FC<{}> = props => {
</div>
</div>
);
};
};
+30 -30
View File
@@ -69,38 +69,38 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<ToolbarMeView setMeExpanded={ setMeExpanded } unseenAchievementCount={ getTotalUnseen } useGuideTool={ useGuideTool } />
</motion.div> )}
</AnimatePresence>
<Flex alignItems="center" className="absolute bottom-0 left-0 w-full h-[55px] bg-[rgba(28,28,32,.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] py-1 px-3" gap={ 2 } justifyContent="between">
<Flex alignItems="center" gap={ 2 }>
<Flex alignItems="center" gap={ 2 }>
<Flex center pointer className={ 'relative w-[50px] h-[45px] overflow-hidden ' + (isMeExpanded ? 'active ' : '') } onClick={ event =>
{
setMeExpanded(!isMeExpanded);
event.stopPropagation();
} }>
<LayoutAvatarImageView className="-ml-[5px] mt-[25px]" direction={ 2 } figure={ userFigure } position="absolute" />
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } /> }
</Flex>
{ isInRoom &&
<ToolbarItemView icon="habbo" onClick={ event => VisitDesktop() } /> }
{ !isInRoom &&
<ToolbarItemView icon="house" onClick={ event => CreateLinkEvent('navigator/goto/home') } /> }
<ToolbarItemView icon="rooms" onClick={ event => CreateLinkEvent('navigator/toggle') } />
{ GetConfigurationValue('game.center.enabled') &&
<ToolbarItemView icon="game" onClick={ event => CreateLinkEvent('games/toggle') } /> }
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('catalog/toggle') } />
<ToolbarItemView icon="inventory" onClick={ event => CreateLinkEvent('inventory/toggle') }>
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } /> }
</ToolbarItemView>
{ isInRoom &&
<ToolbarItemView icon="camera" onClick={ event => CreateLinkEvent('camera/toggle') } /> }
{ isMod &&
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
<Flex alignItems="center" className="absolute bottom-0 left-0 w-full h-[55px] bg-[rgba(28,28,32,.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] py-1 px-3" gap={ 2 }>
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
<Flex center pointer className={ 'relative w-[50px] h-[45px] overflow-hidden ' + (isMeExpanded ? 'active ' : '') } onClick={ event =>
{
setMeExpanded(!isMeExpanded);
event.stopPropagation();
} }>
<LayoutAvatarImageView className="-ml-[5px] mt-[25px]" direction={ 2 } figure={ userFigure } position="absolute" />
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } /> }
</Flex>
<Flex alignItems="center" id="toolbar-chat-input-container" />
{ isInRoom &&
<ToolbarItemView icon="habbo" onClick={ event => VisitDesktop() } /> }
{ !isInRoom &&
<ToolbarItemView icon="house" onClick={ event => CreateLinkEvent('navigator/goto/home') } /> }
<ToolbarItemView icon="rooms" onClick={ event => CreateLinkEvent('navigator/toggle') } />
{ GetConfigurationValue('game.center.enabled') &&
<ToolbarItemView icon="game" onClick={ event => CreateLinkEvent('games/toggle') } /> }
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('catalog/toggle') } />
<ToolbarItemView icon="inventory" onClick={ event => CreateLinkEvent('inventory/toggle') }>
{ (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } /> }
</ToolbarItemView>
{ isInRoom &&
<ToolbarItemView icon="camera" onClick={ event => CreateLinkEvent('camera/toggle') } /> }
{ isMod &&
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
{ isMod &&
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> }
</Flex>
<Flex alignItems="center" gap={ 2 }>
<Flex alignItems="center" justifyContent="center" className="flex-1 min-w-0 max-w-[600px] mx-auto" id="toolbar-chat-input-container" />
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
<Flex gap={ 2 }>
<ToolbarItemView icon="friendall" onClick={ event => CreateLinkEvent('friends/toggle') }>
{ (requests.length > 0) &&
@@ -0,0 +1,31 @@
import { FC, useState } from 'react';
import { LocalizeBadgeDescription, LocalizeBadgeName } from '../../api';
import { Flex, LayoutBadgeImageView } from '../../common';
interface BadgeInfoViewProps
{
badgeCode: string;
}
export const BadgeInfoView: FC<BadgeInfoViewProps> = props =>
{
const { badgeCode } = props;
const [ isHovered, setIsHovered ] = useState(false);
return (
<Flex center
className="w-[45px] h-[45px] rounded bg-white/50 relative cursor-pointer"
onMouseEnter={ () => setIsHovered(true) }
onMouseLeave={ () => setIsHovered(false) }
>
<LayoutBadgeImageView badgeCode={ badgeCode } />
{ isHovered && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 z-50 bg-white text-black rounded shadow-lg py-1 px-2 text-xs w-[180px] pointer-events-none">
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-white rotate-45" />
<div className="font-bold mb-0.5">{ LocalizeBadgeName(badgeCode) }</div>
<div className="text-gray-600">{ LocalizeBadgeDescription(badgeCode) }</div>
</div>
) }
</Flex>
);
};
@@ -37,7 +37,7 @@ export const UserContainerView: FC<{
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0">
<p className="leading-tight">{ userProfile.username }</p>
<p className="leading-tight font-bold">{ userProfile.username }</p>
<p className="text-sm italic leading-tight">{ userProfile.motto }</p>
</div>
<div className="flex flex-col gap-1">
+116 -23
View File
@@ -1,24 +1,31 @@
import { CreateLinkEvent, ExtendedProfileChangedMessageEvent, GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
import { ExtendedProfileChangedMessageEvent, GetSessionDataManager, NavigatorSearchComposer, NavigatorSearchEvent, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomDataParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
import { Flex, Grid, LayoutBadgeImageView, Text } from '../../common';
import { CreateRoomSession, GetRoomSession, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
import { Flex, Text } from '../../common';
import { BadgeInfoView } from './BadgeInfoView';
import { useMessageEvent, useNitroEvent } from '../../hooks';
import { NitroCard } from '../../layout';
import { FriendsContainerView } from './FriendsContainerView';
import { GroupsContainerView } from './GroupsContainerView';
import { UserContainerView } from './UserContainerView';
type ProfileTab = 'badge' | 'amici' | 'stanze' | 'gruppi';
export const UserProfileView: FC<{}> = props =>
{
const [ userProfile, setUserProfile ] = useState<UserProfileParser>(null);
const [ userBadges, setUserBadges ] = useState<string[]>([]);
const [ userRelationships, setUserRelationships ] = useState<RelationshipStatusInfoMessageParser>(null);
const [ activeTab, setActiveTab ] = useState<ProfileTab>('badge');
const [ userRooms, setUserRooms ] = useState<RoomDataParser[]>(null);
const onClose = () =>
{
setUserProfile(null);
setUserBadges([]);
setUserRelationships(null);
setActiveTab('badge');
setUserRooms(null);
};
const onLeaveGroup = () =>
@@ -28,6 +35,16 @@ export const UserProfileView: FC<{}> = props =>
GetUserProfile(userProfile.id);
};
const onTabClick = (tab: ProfileTab) =>
{
setActiveTab(tab);
if(tab === 'stanze' && !userRooms && userProfile)
{
SendMessageComposer(new NavigatorSearchComposer('hotel_view', `owner:${ userProfile.username }`));
}
};
useMessageEvent<UserCurrentBadgesEvent>(UserCurrentBadgesEvent, event =>
{
const parser = event.getParser();
@@ -63,6 +80,8 @@ export const UserProfileView: FC<{}> = props =>
{
setUserBadges([]);
setUserRelationships(null);
setActiveTab('badge');
setUserRooms(null);
}
SendMessageComposer(new UserCurrentBadgesComposer(parser.id));
@@ -78,6 +97,28 @@ export const UserProfileView: FC<{}> = props =>
GetUserProfile(parser.userId);
});
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
{
if(!userProfile || activeTab !== 'stanze') return;
const parser = event.getParser();
const result = parser.result;
if(!result) return;
const rooms: RoomDataParser[] = [];
for(const resultList of result.results)
{
if(resultList.rooms && resultList.rooms.length)
{
for(const room of resultList.rooms) rooms.push(room);
}
}
setUserRooms(rooms);
});
useNitroEvent<RoomEngineObjectEvent>(RoomEngineObjectEvent.SELECTED, event =>
{
if(!userProfile) return;
@@ -98,27 +139,79 @@ export const UserProfileView: FC<{}> = props =>
<NitroCard.Header
headerText={ LocalizeText('extendedprofile.caption') }
onCloseClick={ onClose } />
<NitroCard.Content
className="overflow-hidden">
<Grid fullHeight={ false } gap={ 2 }>
<div className="flex flex-col col-span-7 gap-1 border-r border-r-gray pe-2">
<UserContainerView userProfile={ userProfile } />
<div className="flex items-center justify-center w-full gap-3 p-2 rounded bg-muted">
{ userBadges && (userBadges.length > 0) && userBadges.map((badge, index) => <LayoutBadgeImageView key={ badge } badgeCode={ badge } />) }
<NitroCard.Content className="overflow-hidden !p-0 flex flex-col">
<div className="p-2">
<UserContainerView userProfile={ userProfile } />
</div>
<NitroCard.Tabs>
<NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }>
Badge
</NitroCard.TabItem>
<NitroCard.TabItem isActive={ activeTab === 'amici' } count={ userProfile.friendsCount } onClick={ () => onTabClick('amici') }>
Amici
</NitroCard.TabItem>
<NitroCard.TabItem isActive={ activeTab === 'stanze' } onClick={ () => onTabClick('stanze') }>
Stanze
</NitroCard.TabItem>
<NitroCard.TabItem isActive={ activeTab === 'gruppi' } count={ userProfile.groups?.length } onClick={ () => onTabClick('gruppi') }>
Gruppi
</NitroCard.TabItem>
</NitroCard.Tabs>
<div className="flex-1 overflow-auto p-2">
{ activeTab === 'badge' && (
<div className="flex flex-wrap content-start gap-2 p-2 rounded bg-muted h-full">
{ userBadges && (userBadges.length > 0)
? userBadges.map((badge, index) => (
<BadgeInfoView key={ badge + index } badgeCode={ badge } />
))
: (
<Flex center fullWidth className="h-full">
<Text small variant="muted">Nessun badge da mostrare</Text>
</Flex>
)
}
</div>
</div>
<div className="flex flex-col col-span-5">
{ userRelationships &&
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } /> }
</div>
</Grid>
<Flex alignItems="center" className="px-2 py-1 border-t border-b border-t-gray border-b-gray">
<Flex alignItems="center" gap={ 1 } onClick={ event => CreateLinkEvent(`navigator/search/hotel_view/owner:${ userProfile.username }`) }>
<i className="nitro-icon icon-rooms" />
<Text bold pointer underline>{ LocalizeText('extendedprofile.rooms') }</Text>
</Flex>
</Flex>
<GroupsContainerView fullWidth groups={ userProfile.groups } itsMe={ userProfile.id === GetSessionDataManager().userId } onLeaveGroup={ onLeaveGroup } />
) }
{ activeTab === 'amici' && (
<div className="flex flex-col gap-2 h-full">
{ userRelationships ? (
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } />
) : (
<Flex center className="h-full">
<Text small variant="muted">Caricamento...</Text>
</Flex>
) }
</div>
) }
{ activeTab === 'stanze' && (
<div className="flex flex-col gap-1 h-full">
{ !userRooms && (
<Flex center className="h-full">
<Text small variant="muted">Caricamento stanze...</Text>
</Flex>
) }
{ userRooms && userRooms.length === 0 && (
<Flex center className="h-full">
<Text small variant="muted">Nessuna stanza trovata</Text>
</Flex>
) }
{ userRooms && userRooms.length > 0 && userRooms.map(room => (
<Flex key={ room.roomId } alignItems="center" gap={ 2 } className="px-2 py-1.5 rounded bg-white/50 cursor-pointer hover:bg-white/80" onClick={ () => CreateRoomSession(room.roomId) }>
<div className="flex flex-col min-w-0 grow">
<Text bold small truncate>{ room.roomName }</Text>
{ room.description && <Text small truncate variant="muted">{ room.description }</Text> }
</div>
<Text small variant="muted" className="shrink-0">{ room.userCount }/{ room.maxUserCount }</Text>
</Flex>
)) }
</div>
) }
{ activeTab === 'gruppi' && (
<div className="h-full">
<GroupsContainerView fullWidth groups={ userProfile.groups } itsMe={ userProfile.id === GetSessionDataManager().userId } onLeaveGroup={ onLeaveGroup } />
</div>
) }
</div>
</NitroCard.Content>
</NitroCard>
);
+1
View File
@@ -0,0 +1 @@
export * from './useFurniEditor';
+239
View File
@@ -0,0 +1,239 @@
import { useCallback, useState } from 'react';
export interface FurniItem
{
id: number;
spriteId: number;
itemName: string;
publicName: string;
type: string;
width: number;
length: number;
stackHeight: number;
allowStack: boolean;
allowWalk: boolean;
allowSit: boolean;
allowLay: boolean;
interactionType: string;
interactionModesCount: number;
}
export interface FurniDetail extends FurniItem
{
allowGift: boolean;
allowTrade: boolean;
allowRecycle: boolean;
allowMarketplaceSell: boolean;
allowInventoryStack: boolean;
vendingIds: string;
customparams: string;
effectIdMale: number;
effectIdFemale: number;
clothingOnWalk: string;
multiheight: string;
description: string;
usageCount: number;
}
export interface CatalogRef
{
id: number;
catalogName: string;
costCredits: number;
costPoints: number;
pointsType: number;
pageId: number;
pageName: string;
}
const API_BASE = '/api/admin/furni-editor';
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T>
{
const res = await fetch(url, { credentials: 'include', ...options });
const data = await res.json();
if(!res.ok || data.error) throw new Error(data.error || 'API error');
return data;
}
export const useFurniEditor = () =>
{
const [ items, setItems ] = useState<FurniItem[]>([]);
const [ total, setTotal ] = useState(0);
const [ page, setPage ] = useState(1);
const [ loading, setLoading ] = useState(false);
const [ error, setError ] = useState<string | null>(null);
const [ selectedItem, setSelectedItem ] = useState<FurniDetail | null>(null);
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
const [ interactions, setInteractions ] = useState<string[]>([]);
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
const clearError = useCallback(() => setError(null), []);
const searchItems = useCallback(async (query: string, type: string, pg: number) =>
{
setLoading(true);
setError(null);
try
{
const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) });
if(type) params.set('type', type);
const data = await apiFetch<{ items: FurniItem[]; total: number; page: number }>(`${ API_BASE }?${ params }`);
setItems(data.items);
setTotal(data.total);
setPage(data.page);
}
catch(e: any)
{
setError(e.message);
}
finally
{
setLoading(false);
}
}, []);
const loadDetail = useCallback(async (id: number): Promise<boolean> =>
{
setLoading(true);
setError(null);
try
{
const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record<string, unknown> | null }>(`${ API_BASE }/detail?id=${ id }`);
setSelectedItem(data.item);
setCatalogItems(data.catalogItems);
setFurniDataEntry(data.furniDataEntry);
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
}, []);
const updateItem = useCallback(async (id: number, fields: Record<string, unknown>) =>
{
setLoading(true);
setError(null);
try
{
await apiFetch(`${ API_BASE }/update?id=${ id }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
});
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
}, []);
const createItem = useCallback(async (fields: Record<string, unknown>) =>
{
setLoading(true);
setError(null);
try
{
const data = await apiFetch<{ id: number }>(`${ API_BASE }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
});
return data.id;
}
catch(e: any)
{
setError(e.message);
return null;
}
finally
{
setLoading(false);
}
}, []);
const deleteItem = useCallback(async (id: number) =>
{
setLoading(true);
setError(null);
try
{
await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' });
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
}, []);
const loadInteractions = useCallback(async () =>
{
try
{
const data = await apiFetch<{ interactions: Array<string | { name: string }> }>(`${ API_BASE }/interactions`);
setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name));
}
catch {}
}, []);
const loadBySpriteId = useCallback(async (spriteId: number): Promise<boolean> =>
{
try
{
const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`);
return await loadDetail(data.id);
}
catch(e: any)
{
setError(e.message);
return false;
}
}, [ loadDetail ]);
return {
items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions
};
};
+18 -2
View File
@@ -1,8 +1,8 @@
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, DoorbellMessageEvent, FavouriteChangedEvent, FavouritesEvent, FlatAccessDeniedMessageEvent, FlatCreatedEvent, FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, NavigatorSearchesEvent, NavigatorSearchEvent, NavigatorSearchResultSet, NavigatorTopLevelContext, RoomDataParser, RoomDoorbellAcceptedEvent, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, DoorbellMessageEvent, FavouriteChangedEvent, FavouritesEvent, FlatAccessDeniedMessageEvent, FlatCreatedEvent, FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, NavigatorSearchesEvent, NavigatorSearchEvent, NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomDoorbellAcceptedEvent, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
import { useState } from 'react';
import { useBetween } from 'use-between';
import { CreateRoomSession, DoorStateType, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api';
import { useMessageEvent } from '../events';
import { useMessageEvent, useNitroEvent } from '../events';
import { useNotification } from '../notification';
const useNavigatorState = () =>
@@ -373,6 +373,15 @@ const useNavigatorState = () =>
CreateRoomSession(parser.roomId);
});
// When reconnection starts, reset settingsReceived so the login sequence's
// NavigatorHomeRoomEvent is treated as a fresh login. Without this, the
// prevSettingsReceived check blocks home room navigation after reconnection,
// leaving the user stuck on hotel view.
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () =>
{
setNavigatorData(prevValue => ({ ...prevValue, settingsReceived: false }));
});
useMessageEvent<NavigatorHomeRoomEvent>(NavigatorHomeRoomEvent, event =>
{
const parser = event.getParser();
@@ -397,6 +406,8 @@ const useNavigatorState = () =>
return;
}
// If a room session was already restored (from a network disconnect reload),
// skip the normal home room navigation to avoid overriding it.
if(GetRoomSessionManager().viewerSession) return;
let forwardType = -1;
@@ -458,6 +469,11 @@ const useNavigatorState = () =>
break;
}
// During reconnection, don't navigate to desktop — the reconnection guard
// will handle retrying or cleaning up. Calling VisitDesktop here would
// remove the session from the map and send the user to hotel view.
if(GetRoomSessionManager().isReconnecting) return;
VisitDesktop();
});
@@ -0,0 +1,162 @@
import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CommandDefinition } from '../../../api';
import { useMessageEvent } from '../../events';
const CLIENT_COMMANDS: CommandDefinition[] = [
// Effetti stanza
{ key: 'shake', description: 'Scuoti la stanza' },
{ key: 'rotate', description: 'Ruota la stanza' },
{ key: 'zoom', description: 'Zoom stanza' },
{ key: 'flip', description: 'Reset zoom' },
{ key: 'iddqd', description: 'Reset zoom' },
{ key: 'screenshot', description: 'Screenshot stanza' },
{ key: 'togglefps', description: 'Toggle FPS' },
// Espressioni
{ key: 'd', description: 'Ridi (VIP)' },
{ key: 'kiss', description: 'Manda un bacio (VIP)' },
{ key: 'jump', description: 'Salta (VIP)' },
{ key: 'idle', description: 'Vai in idle' },
{ key: 'sign', description: 'Mostra cartello' },
// Gestione stanza
{ key: 'furni', description: 'Furni chooser' },
{ key: 'chooser', description: 'User chooser' },
{ key: 'floor', description: 'Floor editor' },
{ key: 'bcfloor', description: 'Floor editor' },
{ key: 'pickall', description: 'Raccogli tutti i furni' },
{ key: 'ejectall', description: 'Espelli tutti i furni' },
{ key: 'settings', description: 'Impostazioni stanza' },
// Info
{ key: 'client', description: 'Info client' },
{ key: 'nitro', description: 'Info client' },
];
// Module-level cache: cattura i comandi dal server anche prima che React monti
let cachedServerCommands: CommandDefinition[] = [];
let globalListenerRegistered = false;
function ensureGlobalListener(): void
{
if(globalListenerRegistered) return;
globalListenerRegistered = true;
try
{
const event = new AvailableCommandsEvent((event: AvailableCommandsEvent) =>
{
const parser = event.getParser();
cachedServerCommands = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description }));
});
GetCommunication().registerMessageEvent(event);
}
catch(e)
{
// Communication not ready yet, will retry on hook mount
globalListenerRegistered = false;
}
}
// Try to register immediately at module load
ensureGlobalListener();
export const useChatCommandSelector = (chatValue: string) =>
{
const [ serverCommands, setServerCommands ] = useState<CommandDefinition[]>(cachedServerCommands);
const [ selectedIndex, setSelectedIndex ] = useState(0);
const [ dismissed, setDismissed ] = useState(false);
// Ensure global listener is registered
useEffect(() =>
{
ensureGlobalListener();
// If cache already has data (from login), use it
if(cachedServerCommands.length > 0 && serverCommands.length === 0)
{
setServerCommands(cachedServerCommands);
}
}, []);
// Also listen via React hook for any future updates (e.g. rank change)
useMessageEvent<AvailableCommandsEvent>(AvailableCommandsEvent, event =>
{
const parser = event.getParser();
const cmds = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description }));
cachedServerCommands = cmds;
setServerCommands(cmds);
});
const allCommands = useMemo(() =>
{
const merged = [ ...serverCommands ];
for(const clientCmd of CLIENT_COMMANDS)
{
if(!merged.some(cmd => cmd.key === clientCmd.key))
{
merged.push(clientCmd);
}
}
return merged.sort((a, b) => a.key.localeCompare(b.key));
}, [ serverCommands ]);
const filterText = useMemo(() =>
{
if(!chatValue.startsWith(':') || chatValue.includes(' ')) return '';
return chatValue.slice(1).toLowerCase();
}, [ chatValue ]);
const filteredCommands = useMemo(() =>
{
if(!filterText && !chatValue.startsWith(':')) return [];
return allCommands.filter(cmd => cmd.key.toLowerCase().startsWith(filterText));
}, [ allCommands, filterText, chatValue ]);
const isVisible = useMemo(() =>
{
return chatValue.startsWith(':') && !chatValue.includes(' ') && filteredCommands.length > 0 && !dismissed;
}, [ chatValue, filteredCommands, dismissed ]);
const moveUp = useCallback(() =>
{
setSelectedIndex(prev => (prev <= 0 ? filteredCommands.length - 1 : prev - 1));
}, [ filteredCommands.length ]);
const moveDown = useCallback(() =>
{
setSelectedIndex(prev => (prev >= filteredCommands.length - 1 ? 0 : prev + 1));
}, [ filteredCommands.length ]);
const selectCurrent = useCallback((): CommandDefinition | null =>
{
if(selectedIndex >= 0 && selectedIndex < filteredCommands.length)
{
return filteredCommands[selectedIndex];
}
return null;
}, [ selectedIndex, filteredCommands ]);
const close = useCallback(() =>
{
setDismissed(true);
}, []);
// Reset dismissed when chatValue changes to a new command start
useEffect(() =>
{
if(chatValue === ':' || chatValue === '') setDismissed(false);
}, [ chatValue ]);
// Reset selectedIndex when filtered list changes
useEffect(() =>
{
setSelectedIndex(0);
}, [ filterText ]);
return { isVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close };
};
+15 -5
View File
@@ -116,12 +116,22 @@ const useChatInputWidgetState = () =>
(async () =>
{
const image = new Image();
try
{
const imageUrl = await TextureUtils.generateImageUrl(texture);
if (!imageUrl) return;
image.src = await TextureUtils.generateImageUrl(texture);
const newWindow = window.open('');
newWindow.document.write(image.outerHTML);
const link = document.createElement('a');
link.href = imageUrl;
link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
catch (e)
{
console.warn('[Screenshot] Failed:', e);
}
})();
return null;
case ':pickall':
+36 -2
View File
@@ -3,12 +3,46 @@ import { resolve } from 'path';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
const renderer3 = resolve(__dirname, '..', 'renderer3');
export default defineConfig({
plugins: [ react(), tsconfigPaths() ],
server: {
fs: {
allow: [
resolve(__dirname), // nitro3 itself
renderer3, // renderer3 source + packages
]
},
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'~': resolve(__dirname, 'node_modules')
'~': resolve(__dirname, 'node_modules'),
// Renderer3 workspace packages → point to their src/index.ts
'@nitrots/api': resolve(renderer3, 'packages/api/src/index.ts'),
'@nitrots/assets': resolve(renderer3, 'packages/assets/src/index.ts'),
'@nitrots/avatar': resolve(renderer3, 'packages/avatar/src/index.ts'),
'@nitrots/camera': resolve(renderer3, 'packages/camera/src/index.ts'),
'@nitrots/communication': resolve(renderer3, 'packages/communication/src/index.ts'),
'@nitrots/configuration': resolve(renderer3, 'packages/configuration/src/index.ts'),
'@nitrots/events': resolve(renderer3, 'packages/events/src/index.ts'),
'@nitrots/localization': resolve(renderer3, 'packages/localization/src/index.ts'),
'@nitrots/room': resolve(renderer3, 'packages/room/src/index.ts'),
'@nitrots/session': resolve(renderer3, 'packages/session/src/index.ts'),
'@nitrots/sound': resolve(renderer3, 'packages/sound/src/index.ts'),
'@nitrots/utils/src': resolve(renderer3, 'packages/utils/src'),
'@nitrots/utils': resolve(renderer3, 'packages/utils/src/index.ts'),
// Resolve pixi.js and pixi-filters from renderer3's node_modules
'pixi.js': resolve(renderer3, 'node_modules/pixi.js'),
'pixi-filters': resolve(renderer3, 'node_modules/pixi-filters'),
'howler': resolve(renderer3, 'node_modules/howler'),
}
},
build: {
@@ -21,7 +55,7 @@ export default defineConfig({
{
if(id.includes('node_modules'))
{
if(id.includes('@nitrots/nitro-renderer')) return 'nitro-renderer';
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer';
return 'vendor';
}
+3238
View File
File diff suppressed because it is too large Load Diff