Modifica i colori e lo stile dell'indicatore dello stato di un oggetto di libib.com
// ==UserScript==
// @name Libib - Custom status indicator style
// @name:it Libib - Stile indicatore stato personalizzato
// @description Set a custom color and style for libib.com item status indicator and more
// @description:it Modifica i colori e lo stile dell'indicatore dello stato di un oggetto di libib.com
// @author JetpackCat
// @namespace https://github.com/JetpackCat-IT/libib-custom-status-style
// @supportURL https://github.com/JetpackCat-IT/libib-custom-status-style/issues
// @icon https://cdn.jsdelivr.net/gh/JetpackCat-IT/libib-custom-status-style/img/icon_64.png
// @version 3.0.0
// @license GPL-3.0-or-later; https://raw.githubusercontent.com/JetpackCat-IT/libib-custom-status-style/master/LICENSE
// @match https://www.libib.com/library
// @run-at document-idle
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.xmlHttpRequest
// @connect libib-sync.jetpackcat.workers.dev
// ==/UserScript==
(function () {
"use strict";
const STATUS_SETTINGS_ICONS = {
"cog": "https://cdn.jsdelivr.net/gh/JetpackCat-IT/libib-custom-status-style@main/img/assets/cog.svg",
"cloudUp": "https://cdn.jsdelivr.net/gh/JetpackCat-IT/libib-custom-status-style@main/img/assets/cloud-up.svg",
"cloudDown": "https://cdn.jsdelivr.net/gh/JetpackCat-IT/libib-custom-status-style@main/img/assets/cloud-down.svg",
"trash": "https://cdn.jsdelivr.net/gh/JetpackCat-IT/libib-custom-status-style@main/img/assets/trash.svg",
"crossedEye": "https://cdn.jsdelivr.net/gh/JetpackCat-IT/libib-custom-status-style@main/img/assets/crossed-eye.svg",
"eye": "https://cdn.jsdelivr.net/gh/JetpackCat-IT/libib-custom-status-style@main/img/assets/eye.svg"
}
const CLOUD_SYNC_URL = "https://libib-sync.jetpackcat.workers.dev/";
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const STATUS_SETTINGS_FIELDS = [
{"id": "type", "label": "Indicator type", "default": "Triangle", "inputId": "status-settings-indicator-type", "inputType": "select", "options": ["Triangle", "Border"]},
{"id": "trianglePosition", "label": "Triangle position", "default": "Top left", "inputId": "status-settings-triangle-position", "inputType": "select", "options": ["Top left", "Top right", "Bottom left", "Bottom right"]},
{"id": "borderPosition", "label": "Border position", "default": "Bottom", "inputId": "status-settings-border-position", "inputType": "select", "options": ["Top", "Bottom"]},
{"id": "borderHeight", "label": "Border height", "default": 5, "inputId": "status-settings-border-height", "inputType": "number"},
{"id": "colorNotBegun", "label": '"Not begun" color', "default": "#ffffff", "inputId": "status-settings-color-notbegun", "inputType": "color"},
{"id": "colorCompleted", "label": '"Completed" color', "default": "#76eb99", "inputId": "status-settings-color-completed", "inputType": "color"},
{"id": "colorProgress", "label": '"In progress" color', "default": "#ffec8a", "inputId": "status-settings-color-inprogress", "inputType": "color"},
{"id": "colorAbandoned", "label": '"Abandoned" color', "default": "#ff7a7a", "inputId": "status-settings-color-abandoned", "inputType": "color"},
{"id": "blurGroups", "label": "Blur all covers from specified groups (separated by \";\")", "default": "", "inputId": "status-settings-blurgroups", "inputType": "text"},
{"id": "noBlurOnHover", "label": "Disable blur on hover", "default": false, "inputId": "status-settings-nobluronhover", "inputType": "checkbox"}
];
// For cover blur
let BLUR_GROUPS = [];
let settingsCleanupTimeout = null;
let hasInternalHistory = false;
const statusSettingsInit = async () => {
const JSONConfig = await getJSONConfigFromGM();
const css = await generateCSS(JSONConfig);
setCustomStyle(css);
BLUR_GROUPS = JSONConfig.blurGroups;
loadBlurredCovers(JSONConfig);
}
// Get libib sidebar menu. The settings button will be added to the sidebar
const libibSidebarMenu = document.getElementById("primary-menu");
// General CSS
const scriptCssStyle = `
.button-icon, .button-icon:hover {
&.cloud-up {
background-image: url(${STATUS_SETTINGS_ICONS.cloudUp}) !important;
}
&.cloud-down {
background-image: url(${STATUS_SETTINGS_ICONS.cloudDown}) !important;
}
&.cloud-delete {
background-image: url(${STATUS_SETTINGS_ICONS.trash}) !important;
}
background-repeat: no-repeat !important;
background-position: left 20px center !important;
background-size: 18px auto !important;
padding-left: 50px !important;
}
.status-settings-detail-container {
display: grid;
}
.status-settings-detail-body {
display: flex;
flex-direction: column;
gap: 20px;
}
.status-settings-items {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-weight: 700;
padding-left: 3px;
margin: 0;
}
}
.status-settings-preview {
width: 100%;
box-sizing: border-box;
text-align: center;
.item-group {
background: none;
padding-left: 0px;
padding-right: 0px;
display: flex;
justify-content: center;
}
.cover {
max-width: 100%;
height: auto;
box-sizing: border-box;
}
}
#status-settings-preview-item {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 600px) {
.status-settings-detail-container {
.jump-save-wrapper {
flex-direction: column;
}
.jump-save-wrapper .button {
width: 100%; /* On mobile take 100% width */
margin-bottom: 5px !important;
}
}
}
@media (min-width: 768px) {
.status-settings-detail-body {
flex-direction: row;
}
.status-settings-items {
flex: 2;
}
.status-settings-preview {
flex: 1;
}
}
#libib-status-settings-link>a {
text-decoration: underline;
user-select: none;
}
ul#primary-menu li#libib-status-settings-link a.active {
text-indent: 0px; !important;
}
ul#primary-menu li#libib-status-settings-link:hover a.active {
text-indent: 35px !important;
}
li#libib-status-settings-link a:hover {
background: url('${STATUS_SETTINGS_ICONS.cog}') no-repeat left 40px center #fff;
background-size: 20px auto;
}
.dark li#libib-status-settings-link a:hover {
background: url('${STATUS_SETTINGS_ICONS.cog}') no-repeat left 40px center #1b1b1b;
background-size: 20px auto;
}
`;
const generateSettingsHTML = () => {
// Input container structure
let html = `
<div class="status-settings-detail-container">
<div class="status-settings-detail-header">
<div id="status-settings-close-btn" class="close"></div>
</div>
<div class="status-settings-detail-body">
<div class="status-settings-items">
`;
// Loop array to generate input fields
STATUS_SETTINGS_FIELDS.forEach(field => {
html += '<div class="status-settings-item">';
if (field.inputType === "checkbox") {
html += `<label>${field.label} <input id="${field.inputId}" type="checkbox" /></label>`;
} else {
// Standard structure for input/select
html += `<label>${field.label}</label>`;
if (field.inputType === "select") {
html += `<select id="${field.inputId}">`;
field.options.forEach(opt => {
html += `<option value="${opt}">${opt}</option>`;
});
html += "</select>";
} else {
// Text, Number and Color
html += `<input id="${field.inputId}" type="${field.inputType}" />`;
}
}
html += "</div>";
});
// Close structure, add preview divs and buttons
html += `
</div>
<div class="status-settings-preview">
<p>Live preview</p>
<div id="status-settings-preview-item"></div>
</div>
</div>
<div class="jump-save-wrapper" style="display: flex; gap: 10px; margin-top: 10px;">
<button id="status-settings-save-btn" class="button">Save</button>
<button id="status-settings-copy-settings-btn" class="button secondary">Copy settings</button>
<button id="status-settings-paste-settings-btn" class="button secondary">Paste settings</button>
</div>
<div class="status-settings-items">
<div class="status-settings-item">
<label>Cloud Sync ID</label>
<small class="help-text">Save this ID or paste one you already have to sync across other devices</small>
<input id="status-settings-sync-id" type="text">
</div>
</div>
<small>By syncing, your UI preferences and this anonymous ID are securely stored on the cloud. No personal Libib data is collected.
<a href="https://jetpackcat-it.github.io/libib-custom-status-style/" target="_blank">Read Privacy Policy</a>.</small>
<div class="jump-save-wrapper" style="display: flex; gap: 10px; margin-top: 10px;">
<button id="status-settings-cloud-save-btn" class="button button-icon cloud-up">Save and Sync to Cloud</button>
<button id="status-settings-cloud-load-btn" class="button secondary button-icon cloud-down">Load from Cloud</button>
<button id="status-settings-cloud-delete-btn" class="button delete button-icon cloud-delete">Delete from Cloud</button>
</div>
</div>
`;
return html;
};
const getJSONConfigFromGM = async () => {
let JSONConfig = {};
for (const item of STATUS_SETTINGS_FIELDS)
JSONConfig[item.id] = await GM.getValue(item.id, item.default);
return JSONConfig;
}
const setGMFromJSONConfig = async (JSONConfig) => {
if (JSONConfig == null) return;
for (const item of STATUS_SETTINGS_FIELDS)
await GM.setValue(item.id, JSONConfig[item.id] ?? item.default);
}
const getJSONConfigFromInputs = () => {
const JSONConfig = {};
STATUS_SETTINGS_FIELDS.forEach( item => {
if(item.inputType != "checkbox")
JSONConfig[item.id] = document.getElementById(item.inputId).value;
else
JSONConfig[item.id] = document.getElementById(item.inputId).checked;
});
return JSONConfig;
}
const setInputValuesFromGM = async () => {
for (const item of STATUS_SETTINGS_FIELDS) {
if(item.inputType != "checkbox")
document.getElementById(item.inputId).value = await GM.getValue(item.id, item.default);
else
document.getElementById(item.inputId).checked = await GM.getValue(item.id, item.default);
}
}
const setInputValuesFromJSONConfig = (JSONConfig) => {
if (JSONConfig == null) return;
for (const item of STATUS_SETTINGS_FIELDS) {
if(item.inputType != "checkbox")
document.getElementById(item.inputId).value = JSONConfig[item.id] ?? item.default;
else
document.getElementById(item.inputId).checked = JSONConfig[item.id] ?? item.default;
}
}
// Geretate CSS based on saved settings
const generateCSS = async (JSONConfig, preview = false) => {
if (JSONConfig == null) JSONConfig = await getJSONConfigFromGM();
const noBlurOnHover = JSONConfig.noBlurOnHover;
// Set array with states and associated colors
const statuses = [
{ name: 'completed', color: JSONConfig.colorCompleted },
{ name: 'in-progress', color: JSONConfig.colorProgress },
{ name: 'abandoned', color: JSONConfig.colorAbandoned },
{ name: 'not-begun', color: JSONConfig.colorNotBegun }
];
let cssStyle = "";
if(!preview)
cssStyle += scriptCssStyle;
// Open wrapper
cssStyle += preview ? '.status-settings-preview {' : '#library-items-wrapper {';
// Make libib buttons still clickable
cssStyle += `
.quick-edit-link{
z-index: 10;
}
.quick-blur-link{
position: absolute;
height: 24px;
width: 24px;
top: 5px;
left: 5px;
border: none;
background-color: #fff;
background-image: url(${STATUS_SETTINGS_ICONS.crossedEye});
background-repeat: no-repeat;
background-position: center;
background-size: 70%;
opacity: 0;
border-radius: 100px;
transition: all 0.3s ease-in-out;
cursor: pointer;
text-indent: -99999px;
z-index: 10;
&.blurred {
background-image: url(${STATUS_SETTINGS_ICONS.eye});
}
}
.item.cover:hover .quick-blur-link {
opacity: 1;
}
.batch-select{
z-index: 10;
}
.cover-blur{
overflow: hidden;
}
.cover-blur img{
filter: blur(8px);
}`;
// Disable blur on cover hover
if(noBlurOnHover){
cssStyle += `
.cover-blur:hover img{
filter: blur(0px);
}`;
}
// Set the save, close and reset buttons color to white id dark mode
cssStyle += `
body.dark #libib_status_config_resetLink,body.dark #libib_status_config_saveBtn,body.dark #libib_status_config_closeBtn{
color:white!important
}`;
// --- TRIANGLE STYLE ---
if (JSONConfig.type == "Triangle") {
// Mapping triangle positions and colors
const positionSettings = {
"Top left": {
position: "top: 0; left: 0; bottom: auto; right: auto;",
colorShorthand: (color) => `border-color: ${color} transparent transparent ${color};`
},
"Top right": {
position: "top: 0; right: 0; bottom: auto; left: auto;",
colorShorthand: (color) => `border-color: ${color} ${color} transparent transparent;`
},
"Bottom right": {
position: "bottom: 0; right: 0; top: auto; left: auto;",
colorShorthand: (color) => `border-color: transparent ${color} ${color} transparent;`
},
"Bottom left": {
position: "bottom: 0; left: 0; top: auto; right: auto;",
colorShorthand: (color) => `border-color: transparent transparent ${color} ${color};`
}
};
// Get position from settings
const currentPosition = positionSettings[JSONConfig.trianglePosition];
// Set triangle position
cssStyle += `
.cover .cover-wrapper::after {
${currentPosition.position}
}
`;
// Dynamically create color classes
statuses.forEach(status => {
cssStyle += `
.cover .${status.name}.cover-wrapper::after {
${currentPosition.colorShorthand(status.color)}
}
`;
});
// --- BORDER STYLE ---
} else if (JSONConfig.type == "Border") {
// The box-shadow prevents the click on the item, so it needs to be hidden on hover
cssStyle += `
.cover-wrapper {
--shadow-y: ${JSONConfig.borderPosition == "Top" ? '' : '-'}${JSONConfig.borderHeight}px;
}
.cover-wrapper:hover::after {
display:none!important;
--shadow-y: 0px;
transition: all 0.25s;
transition-behavior: allow-discrete;
}`;
cssStyle += `
.cover .cover-wrapper::before, .cover .cover-wrapper::after {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
border: none;
z-index: 0;
}
`;
statuses.forEach(status => {
cssStyle += `
.cover .${status.name}.cover-wrapper::after {
box-shadow: inset 0px var(--shadow-y) ${status.color};
}
`;
});
}
// Close wrapper
cssStyle += '}';
return cssStyle;
};
// Create the element to open the settings, it needs to be an <a> tag inside an <li> tag
const settingsButtonA = document.createElement("a");
settingsButtonA.appendChild(
document.createTextNode("Status settings")
);
// Create <li> element and insert the <a> element inside
const settingsButtonLi = document.createElement("li");
settingsButtonLi.id = "libib-status-settings-link"
settingsButtonLi.appendChild(settingsButtonA);
// Assign click event handler to open the settings' panel
settingsButtonLi.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
// If click is performed by a user, register that we are now inside the site
// this is useful to know if we can perform history.back() or not
// Ex. If we came directly on the page with the #custom-settings in the url
// we can't perform a history.back
if (event.isTrusted) {
hasInternalHistory = true;
}
const detailsViewContainer = document.getElementById("item-details-view");
if (!detailsViewContainer) return;
// Check if custom panel is currently visible
const isSettingsActive = window.location.hash.includes("custom-settings");
// If custom panel is visible, close it
// Use isTrusted becouse this should happen only if clicked manually, not simulated
if (isSettingsActive && event.isTrusted) {
if (hasInternalHistory) {
window.history.back(); // Go back in history
} else {
// Entered from external link, clean the url and manually close the panel
window.history.pushState(null, "", window.location.pathname + window.location.search);
statusSettingsCleanup();
}
return;
}
const existingPanel = document.getElementById("libib-custom-settings-panel");
// Make sure to actually remove the custom panel
// Keeping it might create problems such as page refresh
if (existingPanel) {
existingPanel.remove();
}
// Hide libib's HTML (DO NOT remove childrens or libib will break)
Array.from(detailsViewContainer.children).forEach(child => {
if (child.id !== "libib-custom-settings-panel") { // Do not hide my panel
child.style.display = "none";
}
});
// Create a new panel
const myPanel = document.createElement("div");
myPanel.id = "libib-custom-settings-panel";
// Set HTML only in this div
myPanel.innerHTML = generateSettingsHTML();
detailsViewContainer.appendChild(myPanel);
await setInputValuesFromGM();
// Load Sync ID (if empty, create one) and load
const currentSyncId = await getSyncId();
const syncIdInput = document.getElementById("status-settings-sync-id");
if (syncIdInput) syncIdInput.value = currentSyncId;
const copyDestination = document.getElementById("status-settings-preview-item");
const populatePreview = (sourceBook) => {
if(!copyDestination) return;
// Clean destination div
copyDestination.innerHTML = "";
// Create base book template
const baseBook = sourceBook.cloneNode(true)
// Remove useless elements
baseBook.querySelectorAll("div, span").forEach(item => item.remove());
// Remove all classes from book element (needed to remove 'completed', 'abandoned', ecc.)
baseBook.className = "cover-wrapper";
const bookStates = [
{ label: "Not begun", classes: ["not-begun"] },
{ label: "In progress", classes: ["in-progress"] },
{ label: "Completed", classes: ["completed"] },
{ label: "Abandoned", classes: ["abandoned"] },
{ label: "Blurred", classes: ["not-begun", "cover-blur"] }
];
// Create fragment to insert elemento into the DOM
const documentFragment = document.createDocumentFragment();
bookStates.forEach(state => {
// Cover wrapper container
const bookFather = document.createElement("div");
bookFather.classList.add("item", "cover", "book", "preview");
// Book cover
const bookCover = baseBook.cloneNode(true);
bookCover.classList.add(...state.classes);
// Laber of the preview item
const labelContainer = document.createElement("div");
labelContainer.classList.add("item-group");
const labelSpan = document.createElement("span");
labelSpan.textContent = state.label;
labelContainer.appendChild(labelSpan);
// Merge all items
bookFather.appendChild(bookCover);
bookFather.appendChild(labelContainer);
documentFragment.appendChild(bookFather);
});
// Add fragment to the DOM
copyDestination.appendChild(documentFragment);
}
//Copy book element to use as preview
const bookElement = document.querySelector(".cover-wrapper:not(.preview .cover-wrapper)");
if (bookElement) {
// If covers are already loaded populate the preview
populatePreview(bookElement);
} else {
// If covers still have to load wait for them to load, then pupulate the preview
if (copyDestination)
copyDestination.innerHTML = "<span style='margin-top:20px; font-style: italic;'>Loading preview...</span>";
// Create an obserber waiting for covers to load
const previewObserver = new MutationObserver((mutations, obs) => {
const newBook = document.querySelector(".cover-wrapper:not(.preview .cover-wrapper)");
if (newBook) {
obs.disconnect(); // Cover found → Disable observer
populatePreview(newBook); // Populate preview
}
});
// Start observer
previewObserver.observe(document.body, { childList: true, subtree: true });
}
// Apply preview CSS
const JSONConfig = await getJSONConfigFromGM();
const css = await generateCSS(JSONConfig, true);
setCustomStyle(css, true);
// Set click event on close button
// Libib will automatically close the panel when detecting history.back
// This feels more native, but it might break if libib changes how this works
const closeBtn = document.getElementById("status-settings-close-btn");
if(closeBtn) closeBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (hasInternalHistory) {
window.history.back();
} else {
// If entered from direct link, clear the url and manually close panel
window.history.pushState(null, "", window.location.pathname + window.location.search);
statusSettingsCleanup();
}
});
// Open settings windows
detailsViewContainer.classList.add("open");
// Add hash to browser history
// Do not add "+ window.location.search" to avoid conflict with libib's logic with the panel
if (!window.location.hash.includes("custom-settings")) {
window.history.pushState(null, "", window.location.pathname + "#custom-settings");
}
// Hide menu on mobile
const libibSidebar = document.getElementById("left-sidebar");
if (libibSidebar) libibSidebar.classList.remove("show-menu");
});
// Handle the panel "closing"
// Sometimes it should not close but only remove the custom settings panel
const statusSettingsCleanup = () => {
const detailsViewContainer = document.getElementById("item-details-view");
if (!detailsViewContainer) return;
// If the location contains "id=" we should not close the panel as it will be used to display libib item's details
if (!window.location.search.includes("id=")) {
detailsViewContainer.classList.remove("open");
}
if (settingsCleanupTimeout) clearTimeout(settingsCleanupTimeout);
settingsCleanupTimeout = setTimeout(() => {
// Check if user went back to panel
if (window.location.hash.includes("custom-settings")) return;
// Remove custom panel div to avoid showing when libib's detail opens
const myPanel = document.getElementById("libib-custom-settings-panel");
if (myPanel) myPanel.remove();
// Respore libib's div visibility
Array.from(detailsViewContainer.children).forEach(child => {
child.style.display = "";
});
}, 400);
}
// Add <li> element to the sidebar
libibSidebarMenu.appendChild(settingsButtonLi);
// Create a container for the configuration elements
const configContainer = document.createElement("div");
document.body.appendChild(configContainer);
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error("Failed to copy text: ", err);
return false;
}
return true;
}
const readFromClipboard = async () => {
return await navigator.clipboard.readText();
}
// Apply blur to initial loaded covers
const loadBlurredCovers = async (JSONConfig) => {
if (JSONConfig == null) JSONConfig = await getJSONConfigFromGM();
// Remove old blur (except from preview items)
//document.querySelectorAll(".cover-blur:not(.preview .cover-blur)").forEach(el => el.classList.remove("cover-blur"));
// Remove "blurred" class from blur button
//document.querySelectorAll(".quick-blur-link").forEach(el => el.classList.remove("blurred"));
// Get groups to blur from config
const blurGroupsString = JSONConfig.blurGroups || "";
// Create array of groups
const blurGroups = blurGroupsString.split(";").map(g => g.trim()).filter(g => g.length > 0);
// Update "BLUR_GROUPS" global variable
BLUR_GROUPS = blurGroups;
// Early exit if there are groups to blur
//if (blurGroups.length === 0) return;
// Get all items in the DOM
const allItemGroups = document.querySelectorAll('.item-group:not(.preview .item-group)');
// Loop through DOM items
allItemGroups.forEach(itemGroup => {
const groupName = itemGroup.textContent.trim();
// Check if group name is in the array
const needsBlur = blurGroups.includes(groupName);
// Get parent of item
const parent = itemGroup.parentNode;
// Set the "cover-blur" class to the right element
const coverWrapper = parent.querySelector('.cover-wrapper') || parent.firstChild;
if (coverWrapper){
const haveBlur = coverWrapper.classList.contains("cover-blur");
// Change DOM only if the state is changed to prevent page crash (oops)
if (needsBlur && !haveBlur) {
coverWrapper.classList.add("cover-blur");
const blurBtn = coverWrapper.querySelector('.quick-blur-link');
if (blurBtn) blurBtn.classList.add("blurred");
}
else if (!needsBlur && haveBlur) {
coverWrapper.classList.remove("cover-blur");
const blurBtn = coverWrapper.querySelector('.quick-blur-link');
if (blurBtn) blurBtn.classList.remove("blurred");
}
}
});
};
const setCustomStyle = (css, preview = false) => {
// Remove existing style if present
let id = "libib-custom-status-indicator-style";
if(preview) id += "-preview";
const existingStyle = document.getElementById(id);
if (existingStyle != null) {
existingStyle.remove();
}
// Add style tag to document
document.head.append(
Object.assign(document.createElement("style"), {
type: "text/css",
id: id,
textContent: css,
})
);
};
// Generate UUID for Cloud Sync
const generateUUID = () => {
if (crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback if crypto.randomUUID is not available
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// Get UUID from GM storage or generate new if not found
const getSyncId = async () => {
let syncId = await GM.getValue("syncId", "");
if (!syncId) {
syncId = generateUUID();
await GM.setValue("syncId", syncId);
}
return syncId;
};
// Save settings to cloud
const saveToCloud = async (syncId, JSONConfig) => {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "POST",
url: CLOUD_SYNC_URL + syncId,
headers: { "Content-Type": "application/json" },
data: JSON.stringify(JSONConfig),
onload: (response) => {
if (response.status === 200) resolve();
else reject(response.responseText || "Sync error");
},
onerror: (err) => reject("Network error")
});
});
};
// Get settings from cloud
const loadFromCloud = async (syncId) => {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url: CLOUD_SYNC_URL + syncId,
onload: (response) => {
if (response.status === 200) {
try {
resolve(JSON.parse(response.responseText));
} catch (e) {
reject("Data not valid");
}
} else if (response.status === 404) {
reject("Data not found with this Sync ID");
} else {
reject("Server error or invalid Sync ID");
}
},
onerror: (err) => reject("Network error")
});
});
};
// Delete settings from cloud
const deleteFromCloud = async (syncId) => {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "DELETE",
url: CLOUD_SYNC_URL + syncId,
onload: (response) => {
if (response.status === 200) resolve();
else reject("Server error or invalid Sync ID");
},
onerror: (err) => reject("Network error")
});
});
};
// Listen to settings change to edit live preview
document.addEventListener("change", async (event) => {
// Check if event is from setting's input
const isSettingInput = STATUS_SETTINGS_FIELDS.some(item => item.inputId === event.target.id);
if (isSettingInput) {
const JSONConfig = getJSONConfigFromInputs();
const css = await generateCSS(JSONConfig, true);
setCustomStyle(css, true);
}
});
// Click event on "Save" button
document.addEventListener("click", async (event) => {
// -- SAVE LOCALLY --
if(event.target.matches("#status-settings-save-btn"))
{
const JSONConfig = getJSONConfigFromInputs();
setGMFromJSONConfig(JSONConfig);
statusSettingsInit();
window.history.back();
notification("Custom settings saved!", "notification-success");
}
// -- SAVE IN CLOUD --
else if (event.target.matches("#status-settings-cloud-save-btn"))
{
const modalResult = await customModal("Upload your settings to the Cloud?", "Upload", "modal-confirm");
if(!modalResult)
return;
const syncIdInput = document.getElementById("status-settings-sync-id").value.trim();
const JSONConfig = getJSONConfigFromInputs();
// Validate UUID before send
if (!UUID_REGEX.test(syncIdInput)) {
notification("Invalid Sync ID format! Must be a valid UUID.", "notification-error");
return;
}
try {
// Save locally both Sync ID and settings
await GM.setValue("syncId", syncIdInput);
setGMFromJSONConfig(JSONConfig);
// Send to cloud
await saveToCloud(syncIdInput, JSONConfig);
notification("Settings saved to Cloud!", "notification-success");
} catch (err) {
notification("Error: " + err, "notification-error");
}
}
// --- LOAD FROM CLOUD ---
else if (event.target.matches("#status-settings-cloud-load-btn"))
{
const modalResult = await customModal("Load from Cloud and overwrite local settings?", "Confirm", "modal-confirm");
if(!modalResult)
return;
const syncIdInput = document.getElementById("status-settings-sync-id").value.trim();
if (!UUID_REGEX.test(syncIdInput)) {
notification("Invalid Sync ID format!", "notification-error");
return;
}
try {
// Download from cloud
const JSONConfig = await loadFromCloud(syncIdInput);
// If download is succesfull, overwrite everything saved localltìy
await GM.setValue("syncId", syncIdInput);
setGMFromJSONConfig(JSONConfig);
// Update css
setInputValuesFromJSONConfig(JSONConfig);
const cssPreview = await generateCSS(JSONConfig, true);
const css = await generateCSS(JSONConfig, false);
setCustomStyle(cssPreview, true);
setCustomStyle(css, false);
notification("Settings loaded from Cloud!", "notification-success");
} catch (err) {
notification("Error: " + err, "notification-error");
}
}
// --- DELETE FROM CLOUD ---
else if (event.target.matches("#status-settings-cloud-delete-btn")) {
const modalResult = await customModal("Delete your saved settings from the Cloud?", "Delete", "modal-delete");
if (!modalResult)
return;
const syncIdInput = document.getElementById("status-settings-sync-id").value.trim();
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(syncIdInput)) {
notification("Invalid Sync ID format!", "notification-error");
return;
}
try {
// Chiama il Worker con il metodo DELETE
await deleteFromCloud(syncIdInput);
notification("Data successfully deleted from Cloud!", "notification-success");
} catch (err) {
notification("Error: " + err, "notification-error");
}
}
// -- COPY TO CLIPBOARD --
else if(event.target.matches("#status-settings-copy-settings-btn"))
{
const JSONConfig = await getJSONConfigFromGM();
const copyResult = await copyToClipboard(JSON.stringify(JSONConfig));
copyResult ? notification("Settings copied to clipboard!", "notification-success") : notification("Failed to copy", "notification-error");
}
// -- PASTE FROM CLIPBOARD --
else if(event.target.matches("#status-settings-paste-settings-btn"))
{
let JSONConfig = {};
const settings = await readFromClipboard();
try {
JSONConfig = JSON.parse(settings);
} catch (ex) {
notification("Error while reading settings from clipboard", "notification-error");
return;
}
setInputValuesFromJSONConfig(JSONConfig);
const css = await generateCSS(JSONConfig, true);
setCustomStyle(css, true);
notification("Settings pasted from clipboard!", "notification-success");
}
});
// Add the item group to the 'blurGroups' if not present, if already present remove it
const toggleBlurForGroup = async (div) => {
div.preventDefault();
div.stopPropagation();
// Search for the span containing the item group
const span = div.target.parentNode.parentNode.querySelectorAll(".item-group>span");
if(span.length != 1) return;
// Create array from blurredGroups string
let blurredGroupsString = await GM.getValue("blurGroups", "");
if(blurredGroupsString == null) return;
let blurredGroups = blurredGroupsString.split(";");
let itemGroup = span[0].innerText;
const index = blurredGroups.indexOf(itemGroup);
// If item found, remove it
if(index > -1) blurredGroups.splice(index, 1);
// If not found, add to array
else blurredGroups.push(itemGroup);
// Save to settings
await GM.setValue("blurGroups", blurredGroups.join(";"));
loadBlurredCovers();
}
// Create the button for flagging groups to blur
const createSetBlurButton = (isBlurred) => {
const newDiv = document.createElement("div");
newDiv.classList.add("quick-blur-link");
if(isBlurred)
newDiv.classList.add("blurred");
newDiv.title = "Toggle blur for group";
newDiv.addEventListener("click",toggleBlurForGroup);
return newDiv;
}
// Check if the item needs to be blurred based on the group
const divNeedsBlur = (coverNode) => {
if (BLUR_GROUPS.length === 0) return false;
const groupElement = coverNode.querySelector('.item-group');
if (!groupElement) return false;
return BLUR_GROUPS.includes(groupElement.textContent.trim());
};
// Run when new books get loaded on the page
// Check new nodes
const findDivInNode = (node) => {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === "div") {
// Check if new div is actually a cover
if (node.classList.contains('cover') && !node.classList.contains('preview')) {
// Find cover wrapper element
const coverWrapper = node.querySelector('.cover-wrapper') || node.firstChild;
if(coverWrapper) {
const needsBlur = divNeedsBlur(node);
// Add flag element only if not already present
if (!coverWrapper.querySelector('.quick-blur-link')) {
coverWrapper.appendChild(createSetBlurButton(needsBlur));
}
// Apply blur if necessary
if (needsBlur) {
coverWrapper.classList.add("cover-blur");
}
}
}
}
}
// "Promisifing" libib's modal to avoid callback hell. This might break if libib changes how modals work
const customModal = (message, buttonText, modalClass) => {
return new Promise((resolve) => {
// Libib's original method
modal(
message,
buttonText,
modalClass,
() => resolve(true), // Confirm callback returns "true"
() => resolve(false) // Confirm callback returns "false"
);
});
};
// Setup observer
const blurObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
findDivInNode(node);
}
}
});
// Start observer
blurObserver.observe(document.body, { childList: true, subtree: true });
// Listen for changes in history to handle panel show/close
window.addEventListener("popstate", () => {
hasInternalHistory = true;
const myPanel = document.getElementById("libib-custom-settings-panel");
const detailsViewContainer = document.getElementById("item-details-view");
// If the user goes to the settings panel URL
if (window.location.hash.includes("custom-settings")) {
// If the panel is missing, we create it
if (!myPanel){
document.getElementById("libib-status-settings-link").click();
// This is needed to avoid libib from closing the panel
// when navigating back and forth betweenlibib's detail panel
// and the custom settings panel
if (detailsViewContainer) {
detailsViewContainer.classList.add("open");
const keepOpenInterval = setInterval(() => {
detailsViewContainer.classList.add("open");
}, 10);
// Automatically disable the loop after 200ms
setTimeout(() => clearInterval(keepOpenInterval), 200);
}
}
}
// If the user goes away from the settings panel URL
else {
// If the panel is still present, we clean it
if (myPanel) statusSettingsCleanup();
}
});
statusSettingsInit();
// On first load, if url contains #custom-settings, open the custom panel
if (window.location.hash.includes("custom-settings"))
document.getElementById("libib-status-settings-link").click();
})();