// ==UserScript==
// @name PteroSort Category
// @namespace http://tampermonkey.net/
// @version 1.2.1
// @description Pterodactyl server sorter with categories
// @homepage https://github.com/Ricman-MC/PteroSort
// @author Ricman
// @license Apache 2.0
// @match https://panel.your-server.eu/
// @match https://panel.your-second-server.com/
// @grant none
// ==/UserScript==
// IMPORTANT
// To make this script work on your Pterodactyl panel, you need to add the panel's full URL manually.
// Go to Tampermonkey dashboard → click the script name → Settings tab → look for Includes/Excludes → User matches → click Add...
// Then add the full HTTPS URL of your panel there (e.g., https://panel.your-server.eu/)
// You can add multiple panel URLs if needed.
// Script supports vanilla pterodactyl panel v.1.11.10 (its possible it will work on diferent versions not tested)
// IMPORTANT
// this script has some hardcoded parts it could break when update of pterodactyl panel comes
(function () {
'use strict';
const STORAGE_KEY_YOURS = 'ptero_server_order_yours_v2';
const STORAGE_KEY_OTHERS = 'ptero_server_order_others_v2';
const STORAGE_KEY_CATEGORIES_YOURS = 'ptero_categories_yours_v2';
const STORAGE_KEY_CATEGORIES_OTHERS = 'ptero_categories_others_v2';
const containerSelector = 'section > div';
const serverSelector = '.DashboardContainer___StyledServerRow-sc-1topkxf-2';
const categoryRowClass = 'dashboard-category-row';
const categoryColorStripeClass = 'category-color-stripe';
const collapsedCategoryClass = 'collapsed-category';
const categoryStoragePrefix = 'category_';
const toggleSelector = 'input[name="show_all_servers"]';
const buttonContainerSelector = '.DashboardContainer___StyledDiv-sc-1topkxf-0';
let dragLockEnabled = localStorage.getItem('dragLockEnabled') === 'true';
let categories = [];
function getStorageKey() {
return document.querySelector(toggleSelector)?.checked ? STORAGE_KEY_OTHERS : STORAGE_KEY_YOURS;
}
function getCategoryStorageKey() {
return document.querySelector(toggleSelector)?.checked ? STORAGE_KEY_CATEGORIES_OTHERS : STORAGE_KEY_CATEGORIES_YOURS;
}
function generateCategoryId() {
return categoryStoragePrefix + Math.random().toString(36).substring(2, 15);
}
function saveCategories() {
localStorage.setItem(getCategoryStorageKey(), JSON.stringify(categories));
}
function loadCategories() {
categories = JSON.parse(localStorage.getItem(getCategoryStorageKey()) || '[]');
}
function saveOrder() {
const container = document.querySelector(containerSelector);
if (!container) return;
const order = [];
for (const child of container.children) {
if (child.matches(serverSelector) && !child.classList.contains(categoryRowClass)) {
order.push({ type: 'server', id: child.href.split('/').pop(), categoryId: child.dataset.categoryId || null });
} else if (child.classList.contains(categoryRowClass)) {
order.push({ type: 'category', id: child.dataset.categoryId });
}
}
localStorage.setItem(getStorageKey(), JSON.stringify(order));
saveCategories();
}
function loadOrder() {
loadCategories();
const savedOrder = JSON.parse(localStorage.getItem(getStorageKey()) || '[]');
const container = document.querySelector(containerSelector);
if (!container) return;
const servers = Array.from(document.querySelectorAll(`${serverSelector}:not(.${categoryRowClass})`));
const serverMap = new Map(servers.map(el => [el.href.split('/').pop(), el]));
const categoryMap = new Map(categories.map(cat => [cat.id, cat]));
const existingRows = container.querySelectorAll(`${serverSelector}, .${categoryRowClass}`);
console.log(`PteroSort: Removing ${existingRows.length} existing server/category rows before loading.`);
existingRows.forEach(row => row.remove());
const placedServerIds = new Set();
savedOrder.forEach(item => {
if (item.type === 'server') {
if (serverMap.has(item.id)) {
const serverElement = serverMap.get(item.id);
serverElement.dataset.categoryId = item.categoryId || '';
container.appendChild(serverElement);
placedServerIds.add(item.id);
}
} else if (item.type === 'category') {
if (categoryMap.has(item.id)) {
container.appendChild(createCategoryElement(categoryMap.get(item.id)));
}
}
});
servers.forEach(serverElement => {
const serverId = serverElement.href.split('/').pop();
if (!placedServerIds.has(serverId)) {
serverElement.dataset.categoryId = '';
container.appendChild(serverElement);
}
});
}
function createCategoryElement(categoryData) {
const categoryElement = document.createElement('a');
categoryElement.className = `GreyRowBox-sc-1xo9c6v-0 ServerRow__StatusIndicatorBox-sc-1ibsw91-2 dyLna-D fRwFrz DashboardContainer___StyledServerRow-sc-1topkxf-2 jbVWLN ${categoryRowClass}`;
categoryElement.draggable = !dragLockEnabled;
categoryElement.dataset.categoryId = categoryData.id;
categoryElement.style.marginTop = '8px';
categoryElement.style.cursor = 'grab';
categoryElement.innerHTML = `
<div class="${categoryColorStripeClass}" style="background-color: ${categoryData.color};"></div>
<div class="ServerRow___StyledDiv-sc-1ibsw91-3 ecJXa-d" style="margin-left: 20px; display:flex; align-items:center; justify-content: flex-start; flex-grow: 1;">
<p class="ServerRow___StyledP-sc-1ibsw91-4 LWXmF" style="font-weight: bold;">${categoryData.name}</p>
</div>
<div class="ServerRow___StyledDiv4-sc-1ibsw91-10 gQExFz category-controls" style="justify-content: flex-end; margin-right: 10px; display:flex; align-items:center; flex-shrink: 0;">
<div class="ServerRow___StyledDiv9-sc-1ibsw91-18 fZEUwy" style="align-items: center; display:flex;">
<div class="category-collapse-wrapper" style="padding: 5px; cursor: pointer; margin-left: 5px;">
<svg class="category-collapse-icon" viewBox="0 0 24 24" style="width: 1.5em; height: 1.5em; display: block; transition: transform 0.2s ease-in-out;">
<path fill="currentColor" d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z"/>
</svg>
</div>
<div class="ServerRow___StyledDiv10-sc-1ibsw91-19 juhRZD category-description-wrapper" style="align-items: center; display:flex;">
<p class="ServerRow__IconDescription-sc-1ibsw91-1 kVwOgb category-description" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; text-align: right; color: #a7b4c0; font-size: 0.9em;">${categoryData.description || ''}</p>
</div>
</div>
</div>
`;
const deleteButtonWrapper = document.createElement('div');
deleteButtonWrapper.className = 'category-delete-wrapper';
deleteButtonWrapper.style.justifyContent = 'flex-end';
deleteButtonWrapper.style.marginLeft = '10px';
deleteButtonWrapper.style.display = 'flex';
deleteButtonWrapper.style.alignItems = 'center';
deleteButtonWrapper.style.flexShrink = '0';
const deleteButtonInner = document.createElement('div');
deleteButtonInner.className = 'ServerRow___StyledDiv11-sc-1ibsw91-21 iELGrp';
deleteButtonInner.style.backgroundColor = 'rgb(239, 68, 68)';
deleteButtonInner.style.padding = '4px';
deleteButtonInner.style.borderRadius = '4px';
deleteButtonInner.style.cursor = 'pointer';
deleteButtonInner.style.display = 'flex';
deleteButtonInner.style.alignItems = 'center';
deleteButtonInner.style.justifyContent = 'center';
deleteButtonInner.title = 'Delete Category';
const deleteIconDiv = document.createElement('div');
deleteIconDiv.className = 'ServerRow___StyledDiv12-sc-1ibsw91-22';
deleteIconDiv.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
`;
deleteButtonInner.appendChild(deleteIconDiv);
deleteButtonWrapper.appendChild(deleteButtonInner);
const controlsInnerContainer = categoryElement.querySelector('.ServerRow___StyledDiv9-sc-1ibsw91-18');
if (controlsInnerContainer && controlsInnerContainer.parentNode) {
controlsInnerContainer.parentNode.appendChild(deleteButtonWrapper);
} else {
console.error("PteroSort: Could not find controls container to append delete button.");
}
deleteButtonWrapper.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
deleteCategory(categoryData.id, categoryData.name);
});
categoryElement.addEventListener('click', (event) => {
if (!event.target.closest('.category-collapse-wrapper') && !event.target.closest('.category-delete-wrapper')) {
event.preventDefault();
}
});
const collapseWrapper = categoryElement.querySelector('.category-collapse-wrapper');
const collapseIconSvg = categoryElement.querySelector('.category-collapse-icon');
collapseWrapper.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
toggleCategoryCollapse(categoryElement, categoryData.id, collapseIconSvg);
});
if (categoryData.collapsed) {
categoryElement.classList.add(collapsedCategoryClass);
collapseCategoryVisual(categoryElement, collapseIconSvg);
}
return categoryElement;
}
function toggleCategoryCollapse(categoryElement, categoryId, collapseIcon) {
const container = document.querySelector(containerSelector);
if (!container) return;
const isCollapsed = categoryElement.classList.contains(collapsedCategoryClass);
const category = categories.find(cat => cat.id === categoryId);
if (!category) return;
if (isCollapsed) {
expandCategoryVisual(categoryElement, collapseIcon);
categoryElement.classList.remove(collapsedCategoryClass);
category.collapsed = false;
for (const child of container.children) {
if (child.matches(serverSelector) && child.dataset.categoryId === categoryId) {
child.style.display = '';
}
}
} else {
collapseCategoryVisual(categoryElement, collapseIcon);
categoryElement.classList.add(collapsedCategoryClass);
category.collapsed = true;
for (const child of container.children) {
if (child.matches(serverSelector) && child.dataset.categoryId === categoryId) {
child.style.display = 'none';
}
}
}
saveCategories();
fixSpacing();
}
function collapseCategoryVisual(categoryElement, collapseIcon) {
if (collapseIcon) {
collapseIcon.style.transform = 'rotate(-90deg)';
}
}
function expandCategoryVisual(categoryElement, collapseIcon) {
if (collapseIcon) {
collapseIcon.style.transform = 'rotate(0deg)';
}
}
function deleteCategory(categoryId, categoryName) {
if (confirm(`Are you sure you want to delete the category "${categoryName}"?\nServers within it will become uncategorized.`)) {
categories = categories.filter(cat => cat.id !== categoryId);
const categoryElement = document.querySelector(`.${categoryRowClass}[data-category-id="${categoryId}"]`);
if (categoryElement) categoryElement.remove();
const serversToUnassign = document.querySelectorAll(`${serverSelector}[data-category-id="${categoryId}"]`);
serversToUnassign.forEach(server => {
delete server.dataset.categoryId;
server.querySelector('.server-category-indicator')?.remove();
server.style.marginLeft = '0px';
server.style.display = '';
});
saveCategories();
saveOrder();
fixSpacing();
enableDragAndDrop();
console.log(`PteroSort: Deleted category ${categoryId}`);
}
}
function enableDragAndDrop() {
const container = document.querySelector(containerSelector);
if (!container) return;
let dragged = null;
let draggedType = null;
function resetDragListeners(el) {
el.removeEventListener('dragstart', handleDragStart);
el.removeEventListener('dragover', handleDragOver);
el.removeEventListener('drop', handleDrop);
el.removeEventListener('dragend', handleDragEnd);
}
function addDragListeners(el, type) {
el.addEventListener('dragstart', (e) => handleDragStart(e, type));
el.addEventListener('dragover', handleDragOver);
el.addEventListener('drop', handleDrop);
el.addEventListener('dragend', handleDragEnd);
}
document.querySelectorAll(`${serverSelector}:not(.${categoryRowClass})`).forEach(el => {
el.draggable = !dragLockEnabled;
resetDragListeners(el);
if (!dragLockEnabled) {
addDragListeners(el, 'server');
}
});
document.querySelectorAll(`.${categoryRowClass}`).forEach(el => {
el.draggable = !dragLockEnabled;
resetDragListeners(el);
if (!dragLockEnabled) {
addDragListeners(el, 'category');
}
});
function handleDragStart(e, type) {
if (dragLockEnabled) {
e.preventDefault();
return;
}
dragged = e.target.closest(`${serverSelector}, .${categoryRowClass}`);
if (!dragged) return;
draggedType = type;
e.dataTransfer.effectAllowed = 'move';
dragged.classList.add('dragging-active');
try {
e.dataTransfer.setData('text/plain', dragged.dataset.categoryId || dragged.href || 'dragged');
} catch (err) {
console.warn("Could not set drag data:", err);
}
if (draggedType === 'category' && !dragged.classList.contains(collapsedCategoryClass)) {
setTimeout(() => {
if (dragged && dragged.classList.contains('dragging-active')) {
dragged.dataset.wasExpandedBeforeDrag = 'true';
toggleCategoryCollapse(dragged, dragged.dataset.categoryId, dragged.querySelector('.category-collapse-icon'));
}
}, 0);
}
}
function handleDragOver(e) {
if (dragLockEnabled || !dragged) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest(`${serverSelector}, .${categoryRowClass}`);
if (!target || target === dragged) return;
const bounding = target.getBoundingClientRect();
const offset = e.clientY - bounding.top;
const middle = bounding.height / 2;
let insertedElement = null;
if (offset < middle) {
insertedElement = target.parentNode.insertBefore(dragged, target);
} else {
insertedElement = target.parentNode.insertBefore(dragged, target.nextSibling);
}
if (draggedType === 'category' && insertedElement) {
const container = insertedElement.parentNode;
if (!container) return;
const categoryId = insertedElement.dataset.categoryId;
const serversToMove = Array.from(container.querySelectorAll(`${serverSelector}[data-category-id="${categoryId}"]`));
let anchorElement = insertedElement;
serversToMove.forEach(server => {
container.insertBefore(server, anchorElement.nextSibling);
anchorElement = server;
});
}
}
function handleDrop(e) {
if (dragLockEnabled || !dragged) return;
e.preventDefault();
e.stopPropagation();
const dropTarget = e.target.closest(`.${categoryRowClass}`);
if (draggedType === 'server') {
let assignedCategoryId = '';
if (dropTarget && dropTarget !== dragged) {
assignedCategoryId = dropTarget.dataset.categoryId;
} else {
const previousElement = dragged.previousElementSibling;
if (previousElement) {
if (previousElement.classList.contains(categoryRowClass)) {
assignedCategoryId = previousElement.dataset.categoryId;
} else if (previousElement.matches(serverSelector) && previousElement.dataset.categoryId) {
assignedCategoryId = previousElement.dataset.categoryId;
}
}
}
dragged.dataset.categoryId = assignedCategoryId;
}
if (draggedType === 'category') {
const categoryId = dragged.dataset.categoryId;
const container = dragged.parentNode;
if (container && categoryId) {
const serversToMove = Array.from(container.querySelectorAll(`${serverSelector}[data-category-id="${categoryId}"]`));
let anchorElement = dragged;
serversToMove.forEach(server => {
container.insertBefore(server, anchorElement.nextSibling);
anchorElement = server;
});
}
}
if(dragged) {
dragged.classList.remove('dragging-active');
}
saveOrder();
fixSpacing();
}
function handleDragEnd(e) {
console.log("PteroSort: Drag End");
let categoryIdToExpand = null;
let shouldExpandAfterDrag = false;
if (dragged) {
if (dragged.classList.contains(categoryRowClass) && dragged.dataset.wasExpandedBeforeDrag === 'true') {
shouldExpandAfterDrag = true;
categoryIdToExpand = dragged.dataset.categoryId;
}
dragged.classList.remove('dragging-active');
}
if (shouldExpandAfterDrag && categoryIdToExpand) {
setTimeout(() => {
const finalDraggedElement = document.querySelector(`.${categoryRowClass}[data-category-id="${categoryIdToExpand}"]`);
if (finalDraggedElement) {
const container = finalDraggedElement.parentNode;
let needsFixSpacing = false;
const serversToMove = container ? Array.from(container.querySelectorAll(`${serverSelector}[data-category-id="${categoryIdToExpand}"]`)) : [];
if (container && serversToMove.length > 0) {
container.insertBefore(finalDraggedElement, serversToMove[0]);
}
if (finalDraggedElement.dataset.wasExpandedBeforeDrag === 'true') {
toggleCategoryCollapse(finalDraggedElement, finalDraggedElement.dataset.categoryId, finalDraggedElement.querySelector('.category-collapse-icon'));
delete finalDraggedElement.dataset.wasExpandedBeforeDrag;
needsFixSpacing = true;
}
if (needsFixSpacing) {
fixSpacing();
}
saveOrder(); //fixed saving after moving category
}
}, 0);
}
dragged = null;
draggedType = null;
}
}
function fixSpacing() {
const container = document.querySelector(containerSelector);
if (!container) return;
for (const child of container.children) {
child.style.marginTop = '8px';
if (child.classList.contains(categoryRowClass)) {
child.style.marginLeft = '0px';
child.style.display = '';
child.querySelector('.server-category-indicator')?.remove();
} else if (child.matches(serverSelector)) {
const categoryId = child.dataset.categoryId;
let indicator = child.querySelector('.server-category-indicator');
if (categoryId) {
const category = categories.find(cat => cat.id === categoryId);
child.style.marginLeft = '40px';
child.style.display = (category && category.collapsed) ? 'none' : '';
if (category) {
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'server-category-indicator';
child.prepend(indicator);
}
indicator.style.backgroundColor = category.color;
indicator.style.display = '';
} else {
if (indicator) indicator.remove();
child.style.marginLeft = '0px';
child.style.display = '';
delete child.dataset.categoryId;
}
} else {
child.style.marginLeft = '0px';
child.style.display = '';
if (indicator) {
indicator.remove();
}
}
}
}
if (container.firstElementChild) {
container.firstElementChild.style.marginTop = '0px';
}
}
function createButtons() {
const container = document.querySelector(buttonContainerSelector);
const toggleSwitch = document.querySelector(toggleSelector);
if (!container || !toggleSwitch) return;
if (document.getElementById('categoryButton')) return;
const buttonWrapper = document.createElement('div');
buttonWrapper.style.display = 'flex';
buttonWrapper.style.gap = '10px';
buttonWrapper.style.float = 'right';
const categoryButton = document.createElement('button');
categoryButton.id = 'categoryButton';
categoryButton.title = 'Create new category';
categoryButton.style.cursor = 'pointer';
categoryButton.style.padding = '5px 10px';
categoryButton.style.color = 'rgb(16, 185, 129)';
categoryButton.style.backgroundColor = 'transparent';
categoryButton.style.border = '1px solid rgb(16, 185, 129)';
categoryButton.style.borderRadius = '4px';
categoryButton.style.fontWeight = 'bold';
categoryButton.style.fontSize = '1.2em';
categoryButton.innerText = '+';
categoryButton.addEventListener('click', () => {
openCategoryOverlay();
});
const lockButton = document.createElement('button');
lockButton.id = 'lockDragButton';
lockButton.title = 'Toggle drag lock';
lockButton.style.padding = '5px 10px';
lockButton.style.cursor = 'pointer';
lockButton.innerText = dragLockEnabled ? '🔒' : '🔓';
lockButton.addEventListener('click', () => {
dragLockEnabled = !dragLockEnabled;
localStorage.setItem('dragLockEnabled', dragLockEnabled);
lockButton.innerText = dragLockEnabled ? '🔒' : '🔓';
enableDragAndDrop();
});
const settingsButton = document.createElement('button');
settingsButton.id = 'settingsButton';
settingsButton.title = 'Open Settings';
settingsButton.style.padding = '5px 10px';
settingsButton.style.cursor = 'pointer';
settingsButton.innerText = '⚙️';
settingsButton.addEventListener('click', () => {
openSettingsOverlay();
});
buttonWrapper.appendChild(categoryButton);
buttonWrapper.appendChild(lockButton);
buttonWrapper.appendChild(settingsButton);
console.log("PteroSort: Creating buttons...");
const newButtonContainer = document.getElementById('pterosort-button-container');
if (newButtonContainer) {
newButtonContainer.innerHTML = '';
buttonWrapper.style.display = 'flex';
buttonWrapper.style.justifyContent = 'flex-end';
buttonWrapper.style.alignItems = 'center';
buttonWrapper.style.width = '100%';
buttonWrapper.style.gap = '10px';
buttonWrapper.style.float = 'none';
buttonWrapper.style.position = 'static';
newButtonContainer.appendChild(buttonWrapper);
console.log("PteroSort: Appended button wrapper to new container:", newButtonContainer);
} else {
console.error("PteroSort: Could not find the new button container (#pterosort-button-container). Buttons not added.");
}
}
function createOverlay() {
const overlay = document.createElement('div');
overlay.className = 'ptero-sort-overlay';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '10000';
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
overlay.remove();
}
});
return overlay;
}
function createOverlayBox() {
const box = document.createElement('div');
box.className = 'ptero-sort-overlay-box';
box.style.padding = '25px';
box.style.background = 'var(--secondary-background-color, #2a3542)';
box.style.color = 'var(--secondary-foreground-color, #ffffff)';
box.style.boxShadow = '0px 5px 15px rgba(0,0,0,0.3)';
box.style.borderRadius = '8px';
box.style.textAlign = 'center';
box.style.minWidth = '350px';
box.style.maxWidth = '90vw';
box.addEventListener('click', (e) => {
e.stopPropagation();
});
return box;
}
function openCategoryOverlay(editCategory = null) {
const overlay = createOverlay();
const box = createOverlayBox();
const categoryId = editCategory ? editCategory.id : generateCategoryId();
const isEditMode = !!editCategory;
box.innerHTML = `
<h3 style="margin-bottom: 20px; font-size: 1.3em;">${isEditMode ? 'Edit Category' : 'Create New Category'}</h3>
<div style="display: flex; flex-direction: column; gap: 15px; text-align: left;">
<div>
<label for="categoryName" style="display: block; margin-bottom: 5px;">Name:</label>
<input type="text" id="categoryName" value="${editCategory ? editCategory.name : ''}" class="overlay-input">
</div>
<div>
<label for="categoryColor" style="display: block; margin-bottom: 5px;">Color:</label>
<input type="color" id="categoryColor" value="${editCategory ? editCategory.color : '#4CAF50'}" style="width: 100%; height: 35px; border: none; border-radius: 5px; cursor: pointer; background-color: transparent;">
</div>
<div>
<label for="categoryDescription" style="display: block; margin-bottom: 5px;">Description:</label>
<textarea id="categoryDescription" class="overlay-input" style="height: 70px; resize: vertical;">${editCategory ? editCategory.description : ''}</textarea>
</div>
</div>
<div style="margin-top: 25px; display: flex; justify-content: space-between; gap: 10px;">
${isEditMode ? `<button id="deleteCategory" class="overlay-button danger-button">Delete</button>` : '<div></div>'}
<div>
<button id="cancelCategory" class="overlay-button secondary-button" style="margin-right: 10px;">Cancel</button>
<button id="confirmCategory" class="overlay-button primary-button">${isEditMode ? 'Save' : 'Create'}</button>
</div>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
document.getElementById('categoryName').focus();
document.getElementById('confirmCategory').addEventListener('click', () => {
const name = document.getElementById('categoryName').value.trim();
const color = document.getElementById('categoryColor').value;
const description = document.getElementById('categoryDescription').value.trim();
if (!name) {
alert('Category name cannot be empty.');
return;
}
if (isEditMode) {
editCategory.name = name;
editCategory.color = color;
editCategory.description = description;
const categoryElement = document.querySelector(`.${categoryRowClass}[data-category-id="${editCategory.id}"]`);
if (categoryElement) {
categoryElement.querySelector('.ServerRow___StyledP-sc-1ibsw91-4').textContent = name;
categoryElement.querySelector(`.${categoryColorStripeClass}`).style.backgroundColor = color;
categoryElement.querySelector('.category-description').textContent = description;
}
} else {
const newCategory = { id: categoryId, name, color, description, collapsed: false };
categories.push(newCategory);
const categoryElement = createCategoryElement(newCategory);
const container = document.querySelector(containerSelector);
if (container) {
const firstItem = container.querySelector(`${serverSelector}, .${categoryRowClass}`);
container.insertBefore(categoryElement, firstItem);
}
}
saveCategories();
saveOrder();
enableDragAndDrop();
fixSpacing();
overlay.remove();
});
document.getElementById('cancelCategory').addEventListener('click', () => {
overlay.remove();
});
if (isEditMode) {
document.getElementById('deleteCategory').addEventListener('click', () => {
deleteCategory(editCategory.id, editCategory.name);
overlay.remove();
});
}
}
function openSettingsOverlay() {
const overlay = createOverlay();
const box = createOverlayBox();
box.innerHTML = `
<h3 style="margin-bottom: 20px; font-size: 1.3em;">Settings & Data</h3>
<div style="display: flex; flex-direction: column; gap: 15px;">
<button id="importSettings" class="overlay-button secondary-button">Import Settings</button>
<button id="exportSettings" class="overlay-button secondary-button">Export Settings</button>
<button id="clearAllStorage" class="overlay-button danger-button">Clear All Saved Data</button>
</div>
<div style="margin-top: 25px; text-align: right;">
<button id="closeSettings" class="overlay-button primary-button">Close</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
document.getElementById('importSettings').addEventListener('click', () => {
overlay.remove();
openImportOverlay();
});
document.getElementById('exportSettings').addEventListener('click', () => {
exportSettings();
});
document.getElementById('clearAllStorage').addEventListener('click', () => {
overlay.remove();
openClearConfirmationOverlay();
});
document.getElementById('closeSettings').addEventListener('click', () => {
overlay.remove();
});
}
function openImportOverlay() {
const overlay = createOverlay();
const box = createOverlayBox();
box.innerHTML = `
<h3 style="margin-bottom: 15px;">Import Settings</h3>
<p style="margin-bottom: 15px; font-size: 0.9em;">Paste the exported JSON data below.</p>
<textarea id="importTextArea" class="overlay-input" placeholder="{...}" style="width: 100%; height: 150px; resize: vertical; margin-bottom: 20px;"></textarea>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button id="cancelImport" class="overlay-button secondary-button">Cancel</button>
<button id="confirmImport" class="overlay-button primary-button">Import & Reload</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
document.getElementById('confirmImport').addEventListener('click', () => {
const settingsJson = document.getElementById('importTextArea').value.trim();
if (!settingsJson) {
alert('Import data cannot be empty.');
return;
}
try {
const settings = JSON.parse(settingsJson);
if (typeof settings !== 'object' || settings === null ||
(!settings.order_yours && !settings.order_others && !settings.categories_yours && !settings.categories_others)) {
throw new Error("Invalid settings format.");
}
importSettings(settings);
overlay.remove();
alert('Settings imported successfully! Reloading page...');
location.reload();
} catch (e) {
console.error("Import Error:", e);
alert('Failed to import settings. Invalid JSON data or format.\n\n' + e.message);
}
});
document.getElementById('cancelImport').addEventListener('click', () => {
overlay.remove();
});
}
function exportSettings() {
const settings = {
categories_yours: JSON.parse(localStorage.getItem(STORAGE_KEY_CATEGORIES_YOURS) || '[]'),
categories_others: JSON.parse(localStorage.getItem(STORAGE_KEY_CATEGORIES_OTHERS) || '[]'),
order_yours: JSON.parse(localStorage.getItem(STORAGE_KEY_YOURS) || '[]'),
order_others: JSON.parse(localStorage.getItem(STORAGE_KEY_OTHERS) || '[]'),
dragLockEnabled: localStorage.getItem('dragLockEnabled')
};
const settingsJson = JSON.stringify(settings, null, 2);
const blob = new Blob([settingsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, '');
downloadLink.href = url;
downloadLink.download = `pterosort_settings_${timestamp}.json`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(url);
}
function importSettings(settings) {
if (settings.categories_yours) localStorage.setItem(STORAGE_KEY_CATEGORIES_YOURS, JSON.stringify(settings.categories_yours));
if (settings.categories_others) localStorage.setItem(STORAGE_KEY_CATEGORIES_OTHERS, JSON.stringify(settings.categories_others));
if (settings.order_yours) localStorage.setItem(STORAGE_KEY_YOURS, JSON.stringify(settings.order_yours));
if (settings.order_others) localStorage.setItem(STORAGE_KEY_OTHERS, JSON.stringify(settings.order_others));
if (settings.dragLockEnabled !== undefined && settings.dragLockEnabled !== null) localStorage.setItem('dragLockEnabled', settings.dragLockEnabled);
}
function openClearConfirmationOverlay() {
const overlay = createOverlay();
const box = createOverlayBox();
box.innerHTML = `
<h3 style="margin-bottom: 15px; color: #ef4444;">Confirm Deletion</h3>
<p style="margin-bottom: 20px;">Are you sure you want to delete ALL saved server orders and categories? This cannot be undone.</p>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button id="cancelDeleteAll" class="overlay-button secondary-button">Cancel</button>
<button id="confirmDeleteAll" class="overlay-button danger-button">Yes, Delete All</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
document.getElementById('confirmDeleteAll').addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY_YOURS);
localStorage.removeItem(STORAGE_KEY_OTHERS);
localStorage.removeItem(STORAGE_KEY_CATEGORIES_YOURS);
localStorage.removeItem(STORAGE_KEY_CATEGORIES_OTHERS);
overlay.remove();
alert('All saved order and category data has been deleted. Reloading page...');
location.reload();
});
document.getElementById('cancelDeleteAll').addEventListener('click', () => {
overlay.remove();
});
}
function createButtonContainerDiv() {
if (document.getElementById('pterosort-button-container')) {
console.log("PteroSort: Button container already exists.");
return;
}
const originalHeader = document.querySelector(buttonContainerSelector);
if (!originalHeader) {
console.error("PteroSort: Could not find original header container:", buttonContainerSelector);
return;
}
const newContainer = originalHeader.cloneNode(false);
newContainer.id = 'pterosort-button-container';
newContainer.style.cssText = '';
newContainer.style.marginTop = '10px';
newContainer.style.display = 'flex';
newContainer.style.alignItems = 'center';
newContainer.style.padding = '0 1.5rem';
originalHeader.parentNode.insertBefore(newContainer, originalHeader.nextSibling);
console.log("PteroSort: Created and inserted new button container div.");
}
function init() {
console.log("PteroSort: Running init()...");
createButtonContainerDiv();
loadOrder();
enableDragAndDrop();
fixSpacing();
setTimeout(createButtons, 50);
console.log("PteroSort: init() finished.");
}
function observePageChanges() {
let lastUrl = location.href;
let lastPath = location.pathname;
const observer = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
if (location.pathname !== lastPath) {
console.log("PteroSort: Path changed, re-initializing.");
lastPath = location.pathname;
waitForElement(serverSelector, init);
}
}
});
observer.observe(document.body, { attributes: true, attributeFilter: ['href', 'class'] });
setInterval(() => {
if (location.pathname !== lastPath) {
console.log("PteroSort: Path change detected by interval, re-initializing.");
lastPath = location.pathname;
waitForElement(serverSelector, init);
}
}, 1500);
}
function observeViewSwitch() {
const toggleSwitch = document.querySelector(toggleSelector);
if (toggleSwitch) {
toggleSwitch.addEventListener('change', () => {
setTimeout(() => {
waitForElement(serverSelector, () => {
console.log("PteroSort: View switched, re-initializing.");
loadOrder();
enableDragAndDrop();
fixSpacing();
});
}, 0);
});
} else {
console.warn("PteroSort: Could not find view toggle switch.");
}
}
function waitForElement(selector, callback) {
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
observer.disconnect();
callback();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
waitForElement(serverSelector, () => {
console.log("PteroSort: Initializing...");
init();
observePageChanges();
observeViewSwitch();
});
const style = document.createElement('style');
style.textContent = `
.${categoryRowClass} {
background-color: rgba(0, 0, 0, 0.15);
border-left: none;
cursor: grab;
position: relative;
padding-left: 0 !important;
}
.${categoryRowClass}:hover {
background-color: rgba(0, 0, 0, 0.25);
}
.${categoryColorStripeClass} {
width: 0.6rem;
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 5px 0 0 5px;
}
.${categoryRowClass} > div:not(.${categoryColorStripeClass}) {
margin-left: calc(0.6rem + 15px);
}
.${categoryRowClass} .ServerRow___StyledDiv4-sc-1ibsw91-10 {
margin-left: auto;
padding-right: 15px;
}
.${categoryRowClass} .category-description {
color: #a7b4c0;
font-size: 0.9em;
}
.server-category-indicator {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #ccc;
box-shadow: 0 0 3px rgba(0,0,0,0.5);
z-index: 1;
}
.dragging-active {
opacity: 0.6;
border: 2px dashed #888;
}
.ptero-sort-overlay-box {
background-color: #2a3542;
color: #e1e7ec;
border: 1px solid #4f5d6a;
}
.overlay-input, .ptero-sort-overlay-box textarea {
padding: 10px;
border: 1px solid #4f5d6a;
background-color: #1e2730;
color: #e1e7ec;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
.overlay-input:focus, .ptero-sort-overlay-box textarea:focus {
outline: none;
border-color: #687f96;
box-shadow: 0 0 0 2px rgba(104, 127, 150, 0.3);
}
.overlay-button {
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.primary-button {
background-color: #3498db;
color: white;
}
.primary-button:hover {
background-color: #2980b9;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.secondary-button {
background-color: #5f7387;
color: white;
}
.secondary-button:hover {
background-color: #4e6072;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.danger-button {
background-color: #e74c3c;
color: white;
}
.danger-button:hover {
background-color: #c0392b;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.flex.items-center + div[style*="float: right"] {
display: flex;
align-items: center;
}
.flex.items-center + div[style*="float: right"] > button {
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--secondary-background-color, #2a3542);
color: var(--secondary-foreground-color, #ffffff);
border: 1px solid #4f5d6a;
border-radius: 4px;
}
.flex.items-center + div[style*="float: right"] > button:hover {
background-color: #3a4a5a;
}
.flex.items-center + div[style*="float: right"] > button#categoryButton {
border-color: rgb(16, 185, 129);
color: rgb(16, 185, 129);
background-color: transparent;
}
.flex.items-center + div[style*="float: right"] > button#categoryButton:hover {
background-color: rgba(16, 185, 129, 0.1);
}
`;
document.head.appendChild(style);
})();