// ==UserScript== // @name FarmRPG QuickNav // @namespace http://tampermonkey.net/ // @version 1.1.0 - 2026-03-20 // @description A draggable favorites overlay with quick navigation links and grouping // @author Cadis Etrama Di Raizel // @match https://farmrpg.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=farmrpg.com // @grant none // ==/UserScript== // 2026-03-20 (function () { "use strict"; // =========================================================================== // STATE // =========================================================================== const state = { // Array of { id, label, url } | { id, label, isGroup: true, members: [{id, label, url}] } favorites: JSON.parse(localStorage.getItem("frpg_favorites") || "[]"), isVisible: false, isEditMode: false, openGroupId: null, drag: { active: false, startX: 0, startY: 0, originX: 0, originY: 0 }, reorder: { draggedId: null, draggedFromGroupId: null }, }; function saveFavorites() { localStorage.setItem("frpg_favorites", JSON.stringify(state.favorites)); } // =========================================================================== // HELPERS // =========================================================================== function getCurrentPageInfo() { const hash = window.location.hash; if (!hash || hash === "#!" || hash === "#!/" || hash === "#!/home.php") { return { url: hash || "#!/home.php", label: "Home" }; } const path = hash.replace(/^#!\/?/, ""); const navTitle = document.querySelector(".navbar .title") || document.querySelector(".navbar-inner .title"); if (navTitle && navTitle.textContent.trim()) { return { url: hash, label: navTitle.textContent.trim() }; } const filename = path.split("?")[0].replace(".php", ""); const label = filename .split("_") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); return { url: path, label }; } function generateId() { return "_" + Math.random().toString(36).slice(2, 9); } // =========================================================================== // CHIP BUILDERS - NORMAL MODE // =========================================================================== function buildChipNormal(fav, groupId = null) { const chip = document.createElement("a"); chip.className = "frpg-fav-chip"; chip.dataset.id = fav.id; if (groupId) chip.dataset.groupId = groupId; chip.textContent = fav.label; chip.title = fav.url; chip.href = fav.url; return chip; } function buildChipGroup(group) { const wrap = document.createElement("div"); wrap.className = "frpg-group-chip"; wrap.dataset.id = group.id; const isOpen = state.openGroupId === group.id; if (isOpen) wrap.classList.add("frpg-group-chip--open"); // Header button (the visible chip part) const header = document.createElement("button"); header.className = "frpg-group-header"; header.innerHTML = `${escHtml(group.label)}` + `${isOpen ? "▴" : "▾"}`; header.addEventListener("click", () => { if (state.openGroupId === group.id) { state.openGroupId = null; } else { state.openGroupId = group.id; } renderFavList(); }); wrap.appendChild(header); // Inline expanded members if (isOpen) { const membersRow = document.createElement("div"); membersRow.className = "frpg-group-members"; if (group.members.length === 0) { const empty = document.createElement("span"); empty.className = "frpg-empty"; empty.textContent = "Empty group"; membersRow.appendChild(empty); } else { group.members.forEach((m) => { membersRow.appendChild(buildChipNormal(m, group.id)); }); } wrap.appendChild(membersRow); } return wrap; } // =========================================================================== // CHIP BUILDERS - EDIT MODE // =========================================================================== function buildChipEdit(fav, groupId = null) { const wrap = document.createElement("div"); wrap.className = "frpg-fav-chip frpg-fav-chip--edit"; wrap.dataset.id = fav.id; if (groupId) wrap.dataset.groupId = groupId; wrap.draggable = true; const handle = document.createElement("span"); handle.className = "frpg-chip-handle"; handle.textContent = "⠿"; handle.title = "Drag to reorder or drop onto a group"; const labelEl = document.createElement("span"); labelEl.className = "frpg-chip-label"; labelEl.textContent = fav.label; labelEl.contentEditable = true; labelEl.spellcheck = false; labelEl.title = "Click to rename"; labelEl.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); labelEl.blur(); } }); labelEl.addEventListener("blur", () => { const newLabel = labelEl.textContent.trim(); if (newLabel) { fav.label = newLabel; saveFavorites(); } else { labelEl.textContent = fav.label; } }); const delBtn = document.createElement("button"); delBtn.className = "frpg-chip-del"; delBtn.textContent = "✕"; delBtn.title = "Remove favorite"; delBtn.addEventListener("click", () => { if (groupId) { // Remove from inside a group const grp = state.favorites.find((f) => f.id === groupId); if (grp) { grp.members = grp.members.filter((m) => m.id !== fav.id); saveFavorites(); renderFavList(); } } else { state.favorites = state.favorites.filter((f) => f.id !== fav.id); saveFavorites(); renderFavList(); } }); // ── Drag events ── wrap.addEventListener("dragstart", (e) => { state.reorder.draggedId = fav.id; state.reorder.draggedFromGroupId = groupId; e.dataTransfer.effectAllowed = "move"; setTimeout(() => wrap.classList.add("frpg-dragging"), 0); }); wrap.addEventListener("dragend", () => { wrap.classList.remove("frpg-dragging"); state.reorder.draggedId = null; state.reorder.draggedFromGroupId = null; document .querySelectorAll(".frpg-drop-target") .forEach((el) => el.classList.remove("frpg-drop-target")); }); wrap.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; document .querySelectorAll(".frpg-drop-target") .forEach((el) => el.classList.remove("frpg-drop-target")); wrap.classList.add("frpg-drop-target"); }); wrap.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); // don't bubble up to a group chip const draggedId = state.reorder.draggedId; const fromGroupId = state.reorder.draggedFromGroupId; const targetId = fav.id; const targetGroupId = groupId; if (!draggedId || draggedId === targetId) return; // Pull the dragged item out of wherever it lives const dragged = extractItem(draggedId, fromGroupId); if (!dragged) return; // Insert next to the target insertItemNextTo(dragged, targetId, targetGroupId); saveFavorites(); renderFavList(); }); wrap.appendChild(handle); wrap.appendChild(labelEl); wrap.appendChild(delBtn); return wrap; } function buildChipGroupEdit(group) { const wrap = document.createElement("div"); wrap.className = "frpg-group-chip frpg-group-chip--edit"; wrap.dataset.id = group.id; wrap.draggable = true; // ── Group header (rename + delete) ── const header = document.createElement("div"); header.className = "frpg-group-header frpg-group-header--edit"; const handle = document.createElement("span"); handle.className = "frpg-chip-handle"; handle.textContent = "⠿"; handle.title = "Drag to reorder groups"; const labelEl = document.createElement("span"); labelEl.className = "frpg-chip-label"; labelEl.textContent = group.label; labelEl.contentEditable = true; labelEl.spellcheck = false; labelEl.title = "Click to rename group"; labelEl.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); labelEl.blur(); } }); labelEl.addEventListener("blur", () => { const newLabel = labelEl.textContent.trim(); if (newLabel) { group.label = newLabel; saveFavorites(); } else { labelEl.textContent = group.label; } }); const delBtn = document.createElement("button"); delBtn.className = "frpg-chip-del"; delBtn.textContent = "✕"; delBtn.title = "Delete group (members returned to bar)"; delBtn.addEventListener("click", () => { const idx = state.favorites.findIndex((f) => f.id === group.id); if (idx === -1) return; // Return members to top-level before the group's position state.favorites.splice(idx, 1, ...group.members); saveFavorites(); renderFavList(); }); header.appendChild(handle); header.appendChild(labelEl); header.appendChild(delBtn); wrap.appendChild(header); // ── Members row (editable) ── if (group.members.length > 0) { const membersRow = document.createElement("div"); membersRow.className = "frpg-group-members frpg-group-members--edit"; group.members.forEach((m) => { membersRow.appendChild(buildChipEdit(m, group.id)); }); wrap.appendChild(membersRow); } // ── Drop zone: accept regular chips dragged onto this group ── wrap.addEventListener("dragover", (e) => { // Only accept if dragged item is NOT itself a group const draggedId = state.reorder.draggedId; if (!draggedId) return; const draggedItem = findItem(draggedId, state.reorder.draggedFromGroupId); if (draggedItem && draggedItem.isGroup) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; document .querySelectorAll(".frpg-drop-target") .forEach((el) => el.classList.remove("frpg-drop-target")); wrap.classList.add("frpg-drop-target"); }); wrap.addEventListener("drop", (e) => { e.preventDefault(); const draggedId = state.reorder.draggedId; const fromGroupId = state.reorder.draggedFromGroupId; if (!draggedId) return; // Don't allow dropping a group into a group const draggedItem = findItem(draggedId, fromGroupId); if (!draggedItem || draggedItem.isGroup) return; // Don't re-add if already in this group if (fromGroupId === group.id) return; // Move dragged item into this group const extracted = extractItem(draggedId, fromGroupId); if (!extracted) return; group.members.push(extracted); saveFavorites(); renderFavList(); }); // ── Drag the group itself (reorder groups) ── wrap.addEventListener("dragstart", (e) => { // Only start drag from the handle or header, not from child chips if (!e.target.closest(".frpg-group-header--edit")) return; state.reorder.draggedId = group.id; state.reorder.draggedFromGroupId = null; e.dataTransfer.effectAllowed = "move"; setTimeout(() => wrap.classList.add("frpg-dragging"), 0); }); wrap.addEventListener("dragend", () => { wrap.classList.remove("frpg-dragging"); state.reorder.draggedId = null; state.reorder.draggedFromGroupId = null; document .querySelectorAll(".frpg-drop-target") .forEach((el) => el.classList.remove("frpg-drop-target")); }); return wrap; } // =========================================================================== // ITEM MANIPULATION HELPERS // =========================================================================== // Find an item by id (optionally scoped to a group's members) function findItem(id, groupId) { if (groupId) { const grp = state.favorites.find((f) => f.id === groupId); return grp ? grp.members.find((m) => m.id === id) : null; } return state.favorites.find((f) => f.id === id) || null; } // Remove and return an item from wherever it lives function extractItem(id, groupId) { if (groupId) { const grp = state.favorites.find((f) => f.id === groupId); if (!grp) return null; const idx = grp.members.findIndex((m) => m.id === id); if (idx === -1) return null; return grp.members.splice(idx, 1)[0]; } const idx = state.favorites.findIndex((f) => f.id === id); if (idx === -1) return null; return state.favorites.splice(idx, 1)[0]; } // Insert item next to a target (in the same list the target lives in) function insertItemNextTo(item, targetId, targetGroupId) { if (targetGroupId) { const grp = state.favorites.find((f) => f.id === targetGroupId); if (!grp) return; const idx = grp.members.findIndex((m) => m.id === targetId); grp.members.splice(idx, 0, item); } else { const idx = state.favorites.findIndex((f) => f.id === targetId); state.favorites.splice(idx, 0, item); } } // =========================================================================== // OVERLAY RENDER // =========================================================================== function renderFavList() { const list = document.getElementById("frpg-fav-list"); if (!list) return; list.innerHTML = ""; if (state.favorites.length === 0) { const empty = document.createElement("span"); empty.className = "frpg-empty"; empty.textContent = state.isEditMode ? "No favorites yet." : 'No favorites yet - navigate to a page and click "+ Add".'; list.appendChild(empty); return; } state.favorites.forEach((fav) => { if (fav.isGroup) { list.appendChild( state.isEditMode ? buildChipGroupEdit(fav) : buildChipGroup(fav), ); } else { list.appendChild( state.isEditMode ? buildChipEdit(fav) : buildChipNormal(fav), ); } }); } function updateOverlayMode() { const overlay = document.getElementById("frpg-favorites-overlay"); if (!overlay) return; const addBtn = document.getElementById("frpg-add-btn"); const addGroupBtn = document.getElementById("frpg-add-group-btn"); const editBtn = document.getElementById("frpg-edit-btn"); if (state.isEditMode) { overlay.classList.add("frpg-edit-mode"); if (addBtn) addBtn.style.display = "none"; if (addGroupBtn) addGroupBtn.style.display = "none"; if (editBtn) { editBtn.textContent = "✔ Done"; editBtn.classList.add("frpg-btn--active"); } } else { overlay.classList.remove("frpg-edit-mode"); if (addBtn) addBtn.style.display = ""; if (addGroupBtn) addGroupBtn.style.display = ""; if (editBtn) { editBtn.textContent = "✎ Edit"; editBtn.classList.remove("frpg-btn--active"); } } renderFavList(); } // =========================================================================== // NEW GROUP INLINE INPUT // =========================================================================== function showNewGroupInput() { // Avoid duplicates if (document.getElementById("frpg-newgroup-form")) return; const actions = document.getElementById("frpg-overlay-actions"); const form = document.createElement("div"); form.id = "frpg-newgroup-form"; const input = document.createElement("input"); input.type = "text"; input.placeholder = "Group name…"; input.className = "frpg-newgroup-input"; input.maxLength = 24; const confirmBtn = document.createElement("button"); confirmBtn.textContent = "✔"; confirmBtn.className = "frpg-newgroup-confirm"; confirmBtn.title = "Create group"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "✕"; cancelBtn.className = "frpg-newgroup-cancel"; cancelBtn.title = "Cancel"; function dismiss() { form.remove(); } function confirm() { const label = input.value.trim(); if (!label) { input.focus(); return; } state.favorites.push({ id: generateId(), label, isGroup: true, members: [], }); saveFavorites(); dismiss(); renderFavList(); } confirmBtn.addEventListener("click", confirm); cancelBtn.addEventListener("click", dismiss); input.addEventListener("keydown", (e) => { if (e.key === "Enter") confirm(); if (e.key === "Escape") dismiss(); }); form.appendChild(input); form.appendChild(confirmBtn); form.appendChild(cancelBtn); // Insert the form before the actions row actions.parentNode.insertBefore(form, actions); input.focus(); } // =========================================================================== // CREATE OVERLAY // =========================================================================== function createOverlay() { if (document.getElementById("frpg-favorites-overlay")) return; const overlay = document.createElement("div"); overlay.id = "frpg-favorites-overlay"; overlay.innerHTML = `