mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
@@ -27,3 +27,4 @@ Thumbs.db
|
||||
/build
|
||||
*.zip
|
||||
.env
|
||||
.claude/
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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 = '💾';
|
||||
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 = '↩';
|
||||
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">⚠</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">⚠</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)');
|
||||
});
|
||||
})();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useFurniEditor';
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user