mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Add badge drag & drop system for InfoStand and inventory
- Drag & drop badges between slots in InfoStand (own user only) - Mini badge picker on empty slot click with search - Swap badges between occupied slots - Hover animation (scale, glow) on badge slots - Configurable group slot (user.badges.group.slot.enabled) - Support for 6 badge slots when group slot disabled - Race condition fix with localChangeRef - Fixed-size array logic to prevent badge disappearing Co-Authored-By: medievalshell <medievalshell@users.noreply.github.com>
This commit is contained in:
@@ -24,7 +24,20 @@
|
|||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(find /e/www/habbo-next/src/app -type f -path *catalog*)",
|
"Bash(find /e/www/habbo-next/src/app -type f -path *catalog*)",
|
||||||
"Bash(echo \"EXIT:$?\")",
|
"Bash(echo \"EXIT:$?\")",
|
||||||
"Bash(find /E/www/habbo-next/src -type f -name *prisma*)"
|
"Bash(find /E/www/habbo-next/src -type f -name *prisma*)",
|
||||||
|
"Bash(node -e \"const mysql = require\\(''mysql2/promise''\\); \\(async\\(\\)=>{const c=await mysql.createConnection\\({host:''localhost'',user:''root'',database:''habbo''}\\); const [r]=await c.query\\(''DESCRIBE items_base''\\); console.log\\(r.map\\(x=>x.Field\\).join\\(''\\\\n''\\)\\); await c.end\\(\\);}\\)\\(\\)\")",
|
||||||
|
"Bash(node -e \"const mysql = require\\(''mysql2/promise''\\); \\(async\\(\\)=>{const c=await mysql.createConnection\\({host:''localhost'',user:''habbo'',password:''habbo'',database:''next''}\\); const [r]=await c.query\\(''DESCRIBE items_base''\\); console.log\\(r.map\\(x=>x.Field\\).join\\(''\\\\n''\\)\\); const [r2]=await c.query\\(''DESCRIBE catalog_items''\\); console.log\\(''---''\\); console.log\\(r2.map\\(x=>x.Field\\).join\\(''\\\\n''\\)\\); await c.end\\(\\);}\\)\\(\\)\")",
|
||||||
|
"Bash(node -e \"const mysql = require\\(''mysql2/promise''\\); \\(async\\(\\)=>{const c=await mysql.createConnection\\({host:''localhost'',user:''habbo'',password:''habbo'',database:''next''}\\); const [r]=await c.query\\(''DESCRIBE items_base''\\); const cols=r.map\\(x=>x.Field\\); console.log\\(''items_base columns:'', JSON.stringify\\(cols\\)\\); const [r2]=await c.query\\(''DESCRIBE catalog_items''\\); const cols2=r2.map\\(x=>x.Field\\); console.log\\(''catalog_items columns:'', JSON.stringify\\(cols2\\)\\); await c.end\\(\\);}\\)\\(\\)\")",
|
||||||
|
"Bash(node -e \":*)",
|
||||||
|
"Bash(npx prisma:*)",
|
||||||
|
"WebFetch(domain:www.habbo.it)",
|
||||||
|
"Bash(grep -r \"slider\\\\|height\\\\|rotation\\\\|state\\\\|speed\" /e/www/habbo-next/public/nitro3/src/components/room/widgets/furniture/*.tsx)",
|
||||||
|
"Bash(grep -r \"processAction\\\\|handleAction\\\\|dispatch\" /e/www/habbo-next/public/nitro3/src/components/room/widgets/furniture/*.tsx)",
|
||||||
|
"Bash(xargs ls:*)",
|
||||||
|
"Bash(find /e/www/habbo-next/public/nitro3/src -type f \\\\\\(-name *Modif* -o -name *Manip* -o -name *Floorplan* -o -name *Builder* \\\\\\))",
|
||||||
|
"Bash(mkdir -p \"E:/www/habbo-next/public/nitro3/src/api/plugins\")",
|
||||||
|
"Bash(mkdir -p \"E:/www/habbo-next/public/nitro3/src/components/plugins/room-builder\")",
|
||||||
|
"Bash(ls \"E:/www/habbo-next/public/nitro3/vite.config\"*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"external.plugins": [
|
||||||
|
"plugins/room-builder.js"
|
||||||
|
],
|
||||||
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
|
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
|
||||||
"achievements.images.url": "${image.library.url}Quests/%image%.png",
|
"achievements.images.url": "${image.library.url}Quests/%image%.png",
|
||||||
"camera.url": "/swf/usercontent/camera/",
|
"camera.url": "/swf/usercontent/camera/",
|
||||||
@@ -10,7 +13,8 @@
|
|||||||
"chat.viewer.height.percentage": 0.4,
|
"chat.viewer.height.percentage": 0.4,
|
||||||
"widget.dimmer.colorwheel": false,
|
"widget.dimmer.colorwheel": false,
|
||||||
"avatar.wardrobe.max.slots": 10,
|
"avatar.wardrobe.max.slots": 10,
|
||||||
"user.badges.max.slots": 5,
|
"user.badges.max.slots": 6,
|
||||||
|
"user.badges.group.slot.enabled": false,
|
||||||
"user.tags.enabled": false,
|
"user.tags.enabled": false,
|
||||||
"camera.publish.disabled": false,
|
"camera.publish.disabled": false,
|
||||||
"hc.disabled": false,
|
"hc.disabled": false,
|
||||||
|
|||||||
@@ -11,8 +11,22 @@ export const InventoryBadgeItemView: FC<PropsWithChildren<{ badgeCode: string }>
|
|||||||
const { isUnseen = null } = useInventoryUnseenTracker();
|
const { isUnseen = null } = useInventoryUnseenTracker();
|
||||||
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
|
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
|
||||||
|
|
||||||
|
const onDragStart = (event: React.DragEvent<HTMLDivElement>) =>
|
||||||
|
{
|
||||||
|
event.dataTransfer.setData('badgeCode', badgeCode);
|
||||||
|
event.dataTransfer.setData('source', 'inventory');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteGrid.Item itemActive={ (selectedBadgeCode === badgeCode) } itemUnseen={ unseen } onDoubleClick={ event => toggleBadge(selectedBadgeCode) } onMouseDown={ event => setSelectedBadgeCode(badgeCode) } { ...rest }>
|
<InfiniteGrid.Item
|
||||||
|
draggable
|
||||||
|
itemActive={ (selectedBadgeCode === badgeCode) }
|
||||||
|
itemUnseen={ unseen }
|
||||||
|
onDoubleClick={ event => toggleBadge(selectedBadgeCode) }
|
||||||
|
onDragStart={ onDragStart }
|
||||||
|
onMouseDown={ event => setSelectedBadgeCode(badgeCode) }
|
||||||
|
{ ...rest }>
|
||||||
<LayoutBadgeImageView badgeCode={ badgeCode } />
|
<LayoutBadgeImageView badgeCode={ badgeCode } />
|
||||||
{ children }
|
{ children }
|
||||||
</InfiniteGrid.Item>
|
</InfiniteGrid.Item>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
|
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useCallback, useEffect, useState } from 'react';
|
||||||
import { FaTrashAlt } from 'react-icons/fa';
|
import { FaTrashAlt } from 'react-icons/fa';
|
||||||
import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
|
import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
|
||||||
import { LayoutBadgeImageView } from '../../../../common';
|
import { LayoutBadgeImageView } from '../../../../common';
|
||||||
@@ -7,14 +7,74 @@ import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '
|
|||||||
import { InfiniteGrid, NitroButton } from '../../../../layout';
|
import { InfiniteGrid, NitroButton } from '../../../../layout';
|
||||||
import { InventoryBadgeItemView } from './InventoryBadgeItemView';
|
import { InventoryBadgeItemView } from './InventoryBadgeItemView';
|
||||||
|
|
||||||
|
const ActiveBadgeSlot: FC<{
|
||||||
|
slotIndex: number;
|
||||||
|
badgeCode?: string;
|
||||||
|
onDropBadge: (badgeCode: string, slotIndex: number, sourceSlot?: number) => void;
|
||||||
|
onRemoveBadge: (badgeCode: string) => void;
|
||||||
|
onDragStartFromSlot: (event: React.DragEvent, badgeCode: string, slotIndex: number) => void;
|
||||||
|
onSelectBadge: (badgeCode: string) => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) =>
|
||||||
|
{
|
||||||
|
const [ isDragOver, setIsDragOver ] = useState(false);
|
||||||
|
|
||||||
|
const onDragOver = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDragLeave = useCallback(() => setIsDragOver(false), []);
|
||||||
|
|
||||||
|
const onDrop = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
|
||||||
|
const sourceSlotStr = event.dataTransfer.getData('activeSlot');
|
||||||
|
const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined;
|
||||||
|
|
||||||
|
if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot);
|
||||||
|
}, [ slotIndex, onDropBadge ]);
|
||||||
|
|
||||||
|
const onDragStart = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
if(!badgeCode) return;
|
||||||
|
onDragStartFromSlot(event, badgeCode, slotIndex);
|
||||||
|
}, [ badgeCode, slotIndex, onDragStartFromSlot ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer aspect-square transition-colors
|
||||||
|
${ isDragOver ? 'border-blue-400 bg-blue-400/20' : '' }
|
||||||
|
${ isSelected && badgeCode ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
|
||||||
|
${ !badgeCode ? 'border-dashed opacity-60' : '' }` }
|
||||||
|
draggable={ !!badgeCode }
|
||||||
|
onDragLeave={ onDragLeave }
|
||||||
|
onDragOver={ onDragOver }
|
||||||
|
onDragStart={ onDragStart }
|
||||||
|
onDrop={ onDrop }
|
||||||
|
onMouseDown={ () => badgeCode && onSelectBadge(badgeCode) }>
|
||||||
|
{ badgeCode
|
||||||
|
? <LayoutBadgeImageView badgeCode={ badgeCode } />
|
||||||
|
: <span className="text-xs text-white/30">{ slotIndex + 1 }</span> }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =>
|
export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =>
|
||||||
{
|
{
|
||||||
const { filteredBadgeCodes = null } = props;
|
const { filteredBadgeCodes = null } = props;
|
||||||
const [ isVisible, setIsVisible ] = useState(false);
|
const [ isVisible, setIsVisible ] = useState(false);
|
||||||
const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, activate = null, deactivate = null } = useInventoryBadges();
|
const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, setBadgeAtSlot = null, removeBadge = null, reorderBadges = null, setSelectedBadgeCode = null, activate = null, deactivate = null } = useInventoryBadges();
|
||||||
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
|
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
|
||||||
const { showConfirm = null } = useNotification();
|
const { showConfirm = null } = useNotification();
|
||||||
|
const [ isDragOverInventory, setIsDragOverInventory ] = useState(false);
|
||||||
|
|
||||||
|
const maxSlots = 5;
|
||||||
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
||||||
|
|
||||||
const attemptDeleteBadge = () =>
|
const attemptDeleteBadge = () =>
|
||||||
@@ -31,6 +91,58 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDropOnSlot = useCallback((badgeCode: string, slotIndex: number, sourceSlot?: number) =>
|
||||||
|
{
|
||||||
|
if(sourceSlot !== undefined)
|
||||||
|
{
|
||||||
|
// Reorder within active badges
|
||||||
|
reorderBadges(sourceSlot, slotIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Drop from inventory to active slot
|
||||||
|
setBadgeAtSlot(badgeCode, slotIndex);
|
||||||
|
}
|
||||||
|
}, [ setBadgeAtSlot, reorderBadges ]);
|
||||||
|
|
||||||
|
const handleDragStartFromSlot = useCallback((event: React.DragEvent, badgeCode: string, slotIndex: number) =>
|
||||||
|
{
|
||||||
|
event.dataTransfer.setData('badgeCode', badgeCode);
|
||||||
|
event.dataTransfer.setData('activeSlot', slotIndex.toString());
|
||||||
|
event.dataTransfer.setData('source', 'active');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveBadge = useCallback((badgeCode: string) =>
|
||||||
|
{
|
||||||
|
removeBadge(badgeCode);
|
||||||
|
}, [ removeBadge ]);
|
||||||
|
|
||||||
|
// Handle drop on inventory area (remove from active)
|
||||||
|
const onInventoryDragOver = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
const source = event.dataTransfer.types.includes('activeslot') ? 'active' : '';
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
setIsDragOverInventory(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []);
|
||||||
|
|
||||||
|
const onInventoryDrop = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOverInventory(false);
|
||||||
|
|
||||||
|
const badgeCode = event.dataTransfer.getData('badgeCode');
|
||||||
|
const source = event.dataTransfer.getData('source');
|
||||||
|
|
||||||
|
if(source === 'active' && badgeCode)
|
||||||
|
{
|
||||||
|
removeBadge(badgeCode);
|
||||||
|
}
|
||||||
|
}, [ removeBadge ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
if(!selectedBadgeCode || !isUnseen(UnseenItemCategory.BADGE, getBadgeId(selectedBadgeCode))) return;
|
if(!selectedBadgeCode || !isUnseen(UnseenItemCategory.BADGE, getBadgeId(selectedBadgeCode))) return;
|
||||||
@@ -56,7 +168,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-12 gap-2">
|
<div className="grid h-full grid-cols-12 gap-2">
|
||||||
<div className="flex flex-col col-span-7 gap-1 overflow-hidden">
|
<div
|
||||||
|
className={ `flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-colors ${ isDragOverInventory ? 'bg-blue-400/10' : '' }` }
|
||||||
|
onDragLeave={ onInventoryDragLeave }
|
||||||
|
onDragOver={ onInventoryDragOver }
|
||||||
|
onDrop={ onInventoryDrop }>
|
||||||
<InfiniteGrid<string>
|
<InfiniteGrid<string>
|
||||||
columnCount={ 5 }
|
columnCount={ 5 }
|
||||||
estimateSize={ 50 }
|
estimateSize={ 50 }
|
||||||
@@ -66,11 +182,20 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
<div className="flex flex-col justify-between col-span-5 overflow-auto">
|
<div className="flex flex-col justify-between col-span-5 overflow-auto">
|
||||||
<div className="flex flex-col gap-2 overflow-hidden">
|
<div className="flex flex-col gap-2 overflow-hidden">
|
||||||
<span className="text-sm truncate min-h-[1.25rem] leading-5">{ LocalizeText('inventory.badges.activebadges') }</span>
|
<span className="text-sm truncate min-h-[1.25rem] leading-5">{ LocalizeText('inventory.badges.activebadges') }</span>
|
||||||
<InfiniteGrid<string>
|
<div className="grid grid-cols-3 gap-1">
|
||||||
columnCount={ 3 }
|
{ Array.from({ length: maxSlots }).map((_, index) => (
|
||||||
estimateSize={ 50 }
|
<ActiveBadgeSlot
|
||||||
itemRender={ item => <InventoryBadgeItemView badgeCode={ item } /> }
|
key={ index }
|
||||||
items={ activeBadgeCodes } />
|
badgeCode={ activeBadgeCodes[index] }
|
||||||
|
isSelected={ selectedBadgeCode === activeBadgeCodes[index] && !!activeBadgeCodes[index] }
|
||||||
|
slotIndex={ index }
|
||||||
|
onDropBadge={ handleDropOnSlot }
|
||||||
|
onDragStartFromSlot={ handleDragStartFromSlot }
|
||||||
|
onRemoveBadge={ handleRemoveBadge }
|
||||||
|
onSelectBadge={ setSelectedBadgeCode }
|
||||||
|
/>
|
||||||
|
)) }
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ !!selectedBadgeCode &&
|
{ !!selectedBadgeCode &&
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { GetRoomEngine } from '@nitrots/nitro-renderer';
|
import { FurnitureStackHeightComposer, GetRoomEngine, TextureUtils } from '@nitrots/nitro-renderer';
|
||||||
import { CreateLinkEvent, GetRoomSession, SendMessageComposer } from '../../api';
|
import { CreateLinkEvent, GetRoomSession, SendMessageComposer, VisitDesktop } from '../../api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin descriptor registered by external plugin scripts.
|
* Plugin descriptor registered by external plugin scripts.
|
||||||
@@ -39,6 +39,14 @@ export interface INitroPluginApi
|
|||||||
getRoomSession: () => ReturnType<typeof GetRoomSession>;
|
getRoomSession: () => ReturnType<typeof GetRoomSession>;
|
||||||
/** Send a message composer to the server */
|
/** Send a message composer to the server */
|
||||||
sendMessage: typeof SendMessageComposer;
|
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 */
|
/** Create a draggable floating window and return its container element */
|
||||||
createWindow: (id: string, title: string, width: number) => HTMLDivElement | null;
|
createWindow: (id: string, title: string, width: number) => HTMLDivElement | null;
|
||||||
/** Destroy a floating window by id */
|
/** Destroy a floating window by id */
|
||||||
@@ -96,6 +104,50 @@ const pluginApi: INitroPluginApi = {
|
|||||||
|
|
||||||
sendMessage: SendMessageComposer,
|
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
|
createWindow(id: string, title: string, width: number): HTMLDivElement | null
|
||||||
{
|
{
|
||||||
// Remove existing window with same id
|
// Remove existing window with same id
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { FaPlus } from 'react-icons/fa';
|
||||||
|
import { LayoutBadgeImageView } from '../../../../../common';
|
||||||
|
import { useInventoryBadges } from '../../../../../hooks';
|
||||||
|
|
||||||
|
interface InfoStandBadgeSlotProps
|
||||||
|
{
|
||||||
|
slotIndex: number;
|
||||||
|
badgeCode?: string;
|
||||||
|
isOwnUser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeMiniPicker: FC<{
|
||||||
|
onSelect: (badgeCode: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
activeBadgeCodes: string[];
|
||||||
|
}> = ({ onSelect, onClose, activeBadgeCodes }) =>
|
||||||
|
{
|
||||||
|
const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [ search, setSearch ] = useState('');
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(badgeCodes.length === 0) requestBadges();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code));
|
||||||
|
const filtered = search.length > 0
|
||||||
|
? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: availableBadges;
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
const handleClickOutside = (event: MouseEvent) =>
|
||||||
|
{
|
||||||
|
if(ref.current && !ref.current.contains(event.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [ onClose ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ ref }
|
||||||
|
className="absolute right-[calc(100%+8px)] top-0 z-50 bg-[rgba(28,28,32,0.97)] border border-white/20 rounded-md p-2 shadow-lg min-w-[160px]"
|
||||||
|
onClick={ e => e.stopPropagation() }>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="w-full text-xs text-white bg-white/10 border border-white/20 rounded px-2 py-1 mb-2 outline-none focus:border-white/40"
|
||||||
|
placeholder="Cerca badge..."
|
||||||
|
type="text"
|
||||||
|
value={ search }
|
||||||
|
onChange={ e => setSearch(e.target.value) }
|
||||||
|
/>
|
||||||
|
{ badgeCodes.length === 0
|
||||||
|
? <span className="text-xs text-white/40 text-center py-2 block">Caricamento...</span>
|
||||||
|
: (
|
||||||
|
<div className="grid grid-cols-4 gap-1 max-h-[160px] overflow-y-auto">
|
||||||
|
{ filtered.slice(0, 40).map(code => (
|
||||||
|
<div
|
||||||
|
key={ code }
|
||||||
|
className="flex items-center justify-center w-[36px] h-[36px] cursor-pointer rounded border border-transparent hover:border-white/40 hover:bg-white/10 transition-all"
|
||||||
|
onClick={ () => onSelect(code) }>
|
||||||
|
<LayoutBadgeImageView badgeCode={ code } />
|
||||||
|
</div>
|
||||||
|
)) }
|
||||||
|
{ filtered.length === 0 && (
|
||||||
|
<span className="text-xs text-white/40 col-span-4 text-center py-2">Nessun badge</span>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) =>
|
||||||
|
{
|
||||||
|
const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges();
|
||||||
|
const [ isDragOver, setIsDragOver ] = useState(false);
|
||||||
|
const [ showPicker, setShowPicker ] = useState(false);
|
||||||
|
|
||||||
|
// For own user, use activeBadgeCodes from the hook (updates immediately on drag/drop)
|
||||||
|
// For other users, use the badge code from props (from server via avatarInfo)
|
||||||
|
const badgeCode = isOwnUser ? (activeBadgeCodes[slotIndex] ?? null) : badgeCodeFromProps;
|
||||||
|
|
||||||
|
const onDragStart = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
if(!badgeCode || !isOwnUser) return;
|
||||||
|
event.dataTransfer.setData('badgeCode', badgeCode);
|
||||||
|
event.dataTransfer.setData('infostandSlot', slotIndex.toString());
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}, [ badgeCode, slotIndex, isOwnUser ]);
|
||||||
|
|
||||||
|
const onDragOver = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
if(!isOwnUser) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, [ isOwnUser ]);
|
||||||
|
|
||||||
|
const onDragLeave = useCallback(() => setIsDragOver(false), []);
|
||||||
|
|
||||||
|
const onDrop = useCallback((event: React.DragEvent) =>
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
if(!isOwnUser) return;
|
||||||
|
|
||||||
|
const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
|
||||||
|
const sourceSlotStr = event.dataTransfer.getData('infostandSlot');
|
||||||
|
|
||||||
|
if(!droppedBadgeCode) return;
|
||||||
|
|
||||||
|
if(sourceSlotStr !== '')
|
||||||
|
{
|
||||||
|
// Dragged from another infostand slot -> always swap (works with empty slots too)
|
||||||
|
const sourceSlot = parseInt(sourceSlotStr);
|
||||||
|
|
||||||
|
if(sourceSlot !== slotIndex) swapBadges(sourceSlot, slotIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Dragged from inventory or external -> place at this slot
|
||||||
|
setBadgeAtSlot(droppedBadgeCode, slotIndex);
|
||||||
|
}
|
||||||
|
}, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]);
|
||||||
|
|
||||||
|
const handleSlotClick = useCallback(() =>
|
||||||
|
{
|
||||||
|
if(!isOwnUser || badgeCode) return;
|
||||||
|
|
||||||
|
setShowPicker(true);
|
||||||
|
}, [ isOwnUser, badgeCode ]);
|
||||||
|
|
||||||
|
const handlePickerSelect = useCallback((code: string) =>
|
||||||
|
{
|
||||||
|
setBadgeAtSlot(code, slotIndex);
|
||||||
|
setShowPicker(false);
|
||||||
|
}, [ setBadgeAtSlot, slotIndex ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={ `flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center transition-all duration-150
|
||||||
|
${ isOwnUser && badgeCode ? 'cursor-grab active:cursor-grabbing' : '' }
|
||||||
|
${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' }
|
||||||
|
${ isOwnUser ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' }
|
||||||
|
${ isDragOver ? 'scale-115 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15' : '' }
|
||||||
|
${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` }
|
||||||
|
draggable={ isOwnUser && !!badgeCode }
|
||||||
|
onDragLeave={ onDragLeave }
|
||||||
|
onDragOver={ onDragOver }
|
||||||
|
onDragStart={ onDragStart }
|
||||||
|
onDrop={ onDrop }
|
||||||
|
onClick={ handleSlotClick }>
|
||||||
|
{ badgeCode
|
||||||
|
? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } />
|
||||||
|
: isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> }
|
||||||
|
</div>
|
||||||
|
{ showPicker && (
|
||||||
|
<BadgeMiniPicker
|
||||||
|
activeBadgeCodes={ activeBadgeCodes }
|
||||||
|
onClose={ () => setShowPicker(false) }
|
||||||
|
onSelect={ handlePickerSelect }
|
||||||
|
/>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { FaPencilAlt, FaTimes } from 'react-icons/fa';
|
|||||||
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
|
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||||
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common';
|
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common';
|
||||||
import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
|
import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
|
||||||
|
import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView';
|
||||||
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
|
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
|
||||||
import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView';
|
import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView';
|
||||||
import { BackgroundsView } from '../../../../backgrounds/BackgroundsView';
|
import { BackgroundsView } from '../../../../backgrounds/BackgroundsView';
|
||||||
@@ -158,31 +159,43 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Column grow alignItems="center" gap={0}>
|
<Column grow alignItems="center" gap={0}>
|
||||||
<div className="flex gap-1">
|
{ GetConfigurationValue<boolean>('user.badges.group.slot.enabled', true)
|
||||||
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
|
? (
|
||||||
{avatarInfo.badges[0] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[0]} showInfo={true} />}
|
<>
|
||||||
</div>
|
<div className="flex gap-1">
|
||||||
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
|
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
{avatarInfo.groupId > 0 &&
|
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
|
||||||
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
|
{avatarInfo.groupId > 0 &&
|
||||||
</Flex>
|
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
|
||||||
</div>
|
</Flex>
|
||||||
<Flex center gap={1}>
|
</div>
|
||||||
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
|
<Flex center gap={1}>
|
||||||
{avatarInfo.badges[1] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[1]} showInfo={true} />}
|
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
</div>
|
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
|
</Flex>
|
||||||
{avatarInfo.badges[2] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[2]} showInfo={true} />}
|
<Flex center gap={1}>
|
||||||
</div>
|
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
</Flex>
|
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
<Flex center gap={1}>
|
</Flex>
|
||||||
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
|
</>
|
||||||
{avatarInfo.badges[3] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[3]} showInfo={true} />}
|
)
|
||||||
</div>
|
: (
|
||||||
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
|
<>
|
||||||
{avatarInfo.badges[4] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[4]} showInfo={true} />}
|
<Flex center gap={1}>
|
||||||
</div>
|
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
</Flex>
|
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
|
</Flex>
|
||||||
|
<Flex center gap={1}>
|
||||||
|
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
|
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
|
</Flex>
|
||||||
|
<Flex center gap={1}>
|
||||||
|
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
|
<InfoStandBadgeSlotView slotIndex={5} badgeCode={avatarInfo.badges[5]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
</Column>
|
</Column>
|
||||||
</div>
|
</div>
|
||||||
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { FC, useEffect, useState } from 'react';
|
|||||||
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
|
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
|
||||||
import { Text } from '../../../../common';
|
import { Text } from '../../../../common';
|
||||||
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
|
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
|
||||||
|
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
|
||||||
|
|
||||||
export const RoomToolsWidgetView: FC<{}> = props => {
|
export const RoomToolsWidgetView: FC<{}> = props => {
|
||||||
const [areBubblesMuted, setAreBubblesMuted] = useState(false);
|
const [areBubblesMuted, setAreBubblesMuted] = useState(false);
|
||||||
@@ -15,12 +16,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
|||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
|
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
|
||||||
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
|
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
|
||||||
|
const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
|
||||||
const { navigatorData = null } = useNavigator();
|
const { navigatorData = null } = useNavigator();
|
||||||
const { roomSession = null } = useRoom();
|
const { roomSession = null } = useRoom();
|
||||||
|
|
||||||
|
// Subscribe to external plugin changes
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
setPlugins(getRegisteredPlugins());
|
||||||
|
return subscribePlugins(() => setPlugins(getRegisteredPlugins()));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToolClick = (action: string, value?: string) => {
|
const handleToolClick = (action: string, value?: string) => {
|
||||||
if (!roomSession) return;
|
if (!roomSession) return;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'settings':
|
case 'settings':
|
||||||
CreateLinkEvent('navigator/toggle-room-info');
|
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={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="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')} />
|
<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 && (
|
{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-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-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')} />
|
<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>
|
||||||
<div className="flex flex-col justify-center">
|
<div className="flex flex-col justify-center">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -159,4 +176,4 @@ export const RoomToolsWidgetView: FC<{}> = props => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer';
|
import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useBetween } from 'use-between';
|
import { useBetween } from 'use-between';
|
||||||
import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api';
|
import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api';
|
||||||
import { useMessageEvent } from '../events';
|
import { useMessageEvent } from '../events';
|
||||||
@@ -17,9 +17,18 @@ const useInventoryBadgesState = () =>
|
|||||||
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
|
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
|
||||||
|
|
||||||
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
|
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
|
||||||
|
const localChangeRef = useRef(false);
|
||||||
const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0);
|
const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0);
|
||||||
const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount);
|
const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount);
|
||||||
|
|
||||||
|
const sendActiveBadges = (badges: string[]) =>
|
||||||
|
{
|
||||||
|
localChangeRef.current = true;
|
||||||
|
const composer = new SetActivatedBadgesComposer();
|
||||||
|
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
|
||||||
|
SendMessageComposer(composer);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleBadge = (badgeCode: string) =>
|
const toggleBadge = (badgeCode: string) =>
|
||||||
{
|
{
|
||||||
setActiveBadgeCodes(prevValue =>
|
setActiveBadgeCodes(prevValue =>
|
||||||
@@ -30,7 +39,7 @@ const useInventoryBadgesState = () =>
|
|||||||
|
|
||||||
if(index === -1)
|
if(index === -1)
|
||||||
{
|
{
|
||||||
if(!canWearBadges()) return prevValue;
|
if(newValue.length >= maxBadgeCount) return prevValue;
|
||||||
|
|
||||||
newValue.push(badgeCode);
|
newValue.push(badgeCode);
|
||||||
}
|
}
|
||||||
@@ -39,11 +48,7 @@ const useInventoryBadgesState = () =>
|
|||||||
newValue.splice(index, 1);
|
newValue.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const composer = new SetActivatedBadgesComposer();
|
sendActiveBadges(newValue);
|
||||||
|
|
||||||
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(newValue[i] ?? '');
|
|
||||||
|
|
||||||
SendMessageComposer(composer);
|
|
||||||
|
|
||||||
return newValue;
|
return newValue;
|
||||||
});
|
});
|
||||||
@@ -77,7 +82,16 @@ const useInventoryBadgesState = () =>
|
|||||||
return newValue;
|
return newValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
setActiveBadgeCodes(parser.getActiveBadgeCodes());
|
// Skip overwriting activeBadgeCodes if we recently made a local change
|
||||||
|
if(localChangeRef.current)
|
||||||
|
{
|
||||||
|
localChangeRef.current = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setActiveBadgeCodes(parser.getActiveBadgeCodes());
|
||||||
|
}
|
||||||
|
|
||||||
setBadgeCodes(allBadgeCodes);
|
setBadgeCodes(allBadgeCodes);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +155,83 @@ const useInventoryBadgesState = () =>
|
|||||||
setNeedsUpdate(false);
|
setNeedsUpdate(false);
|
||||||
}, [ isVisible, needsUpdate ]);
|
}, [ isVisible, needsUpdate ]);
|
||||||
|
|
||||||
return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, activate, deactivate };
|
const setBadgeAtSlot = (badgeCode: string, slotIndex: number) =>
|
||||||
|
{
|
||||||
|
setActiveBadgeCodes(prevValue =>
|
||||||
|
{
|
||||||
|
// Build a fixed-size array of maxBadgeCount slots
|
||||||
|
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
|
||||||
|
|
||||||
|
// Remove badge if already in another slot
|
||||||
|
const existingIndex = slots.indexOf(badgeCode);
|
||||||
|
if(existingIndex >= 0) slots[existingIndex] = null;
|
||||||
|
|
||||||
|
// Place badge at target slot
|
||||||
|
slots[slotIndex] = badgeCode;
|
||||||
|
|
||||||
|
// Compact: remove nulls, keep order
|
||||||
|
const result = slots.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
sendActiveBadges(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBadge = (badgeCode: string) =>
|
||||||
|
{
|
||||||
|
setActiveBadgeCodes(prevValue =>
|
||||||
|
{
|
||||||
|
const result = prevValue.filter(code => code !== badgeCode);
|
||||||
|
|
||||||
|
sendActiveBadges(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderBadges = (fromIndex: number, toIndex: number) =>
|
||||||
|
{
|
||||||
|
setActiveBadgeCodes(prevValue =>
|
||||||
|
{
|
||||||
|
if(fromIndex === toIndex) return prevValue;
|
||||||
|
if(fromIndex >= prevValue.length) return prevValue;
|
||||||
|
|
||||||
|
const newValue = [ ...prevValue ];
|
||||||
|
const [ moved ] = newValue.splice(fromIndex, 1);
|
||||||
|
newValue.splice(toIndex, 0, moved);
|
||||||
|
|
||||||
|
sendActiveBadges(newValue);
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapBadges = (fromIndex: number, toIndex: number) =>
|
||||||
|
{
|
||||||
|
setActiveBadgeCodes(prevValue =>
|
||||||
|
{
|
||||||
|
if(fromIndex === toIndex) return prevValue;
|
||||||
|
|
||||||
|
// Build fixed-size array so swap works even with empty slots
|
||||||
|
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
|
||||||
|
|
||||||
|
// Swap the two slots
|
||||||
|
const temp = slots[fromIndex];
|
||||||
|
slots[fromIndex] = slots[toIndex];
|
||||||
|
slots[toIndex] = temp;
|
||||||
|
|
||||||
|
// Compact: remove nulls, keep order
|
||||||
|
const result = slots.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
sendActiveBadges(result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestBadges = () =>
|
||||||
|
{
|
||||||
|
SendMessageComposer(new RequestBadgesComposer());
|
||||||
|
};
|
||||||
|
|
||||||
|
return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, setBadgeAtSlot, removeBadge, reorderBadges, swapBadges, requestBadges, activate, deactivate };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useInventoryBadges = () => useBetween(useInventoryBadgesState);
|
export const useInventoryBadges = () => useBetween(useInventoryBadgesState);
|
||||||
|
|||||||
@@ -116,12 +116,22 @@ const useChatInputWidgetState = () =>
|
|||||||
|
|
||||||
(async () =>
|
(async () =>
|
||||||
{
|
{
|
||||||
const image = new Image();
|
try
|
||||||
|
{
|
||||||
|
const imageUrl = await TextureUtils.generateImageUrl(texture);
|
||||||
|
if (!imageUrl) return;
|
||||||
|
|
||||||
image.src = await TextureUtils.generateImageUrl(texture);
|
const link = document.createElement('a');
|
||||||
|
link.href = imageUrl;
|
||||||
const newWindow = window.open('');
|
link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`;
|
||||||
newWindow.document.write(image.outerHTML);
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
console.warn('[Screenshot] Failed:', e);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return null;
|
return null;
|
||||||
case ':pickall':
|
case ':pickall':
|
||||||
|
|||||||
Reference in New Issue
Block a user