Client-side folders for gpop.io user levels list (drag & drop, persistent, export/import)
// ==UserScript==
// @name BetterLevelFoldersPop
// @namespace https://gpop.io
// @version 1.2.0
// @description Client-side folders for gpop.io user levels list (drag & drop, persistent, export/import)
// @author Purrfect
// @icon https://www.google.com/s2/favicons?sz=64&domain=gpop.io
// @match https://gpop.io
// @match https://gpop.io/user/*
// @match https://gpop.io/user/*?*
// @match https://gpop.io/user/?*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
"use strict";
const MOD_FLAG = "__betterlevelfolderspop_loaded__";
if (window[MOD_FLAG]) return;
window[MOD_FLAG] = true;
const PREFIX_USER = "__blfp_v1__";
const KEY_GLOBAL = "__blfp_global_v1__";
// ---------------------------
// Helpers
// ---------------------------
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const now = () => Date.now();
function safeRead(key) { try { return localStorage.getItem(key); } catch { return null; } }
function safeWrite(key, value) { try { localStorage.setItem(key, value); } catch {} }
function safeRemove(key) { try { localStorage.removeItem(key); } catch {} }
function getQueryParam(name) {
try { return new URL(location.href).searchParams.get(name); } catch { return null; }
}
function getUserSlug() {
const u = getQueryParam("u");
if (u && String(u).trim()) return String(u).trim();
const m = location.pathname.match(/\/user\/([^/]+)/i);
if (m && m[1]) return decodeURIComponent(m[1]);
return "unknown";
}
function keyForUser(user) { return `${PREFIX_USER}${user}`; }
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
function normalizeFolderName(name) {
const s = String(name ?? "").trim();
if (!s) return null;
return s.slice(0, 40);
}
function getLevelIdFromCard(card) {
const a = card.querySelector('a[href^="/play/"]');
if (!a) return null;
const href = a.getAttribute("href") || "";
const m = href.match(/^\/play\/([^/?#]+)/i);
return m ? m[1] : null;
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function listUserKeys() {
const out = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(PREFIX_USER)) out.push(k);
}
} catch {}
return out.sort();
}
// ---------------------------
// Storage Model
// ---------------------------
const DEFAULT_STATE = Object.freeze({
folders: ["Favorites", "Unsorted"],
map: {}, // levelId -> folderName
ui: {
active: "All",
collapsed: {}, // sectionKey -> bool
hiddenFolders: {}, // folderName -> bool
showBadge: true, // show folder badge on cards
},
meta: { updatedAt: 0 },
});
function loadState(storeKey) {
try {
const raw = safeRead(storeKey);
if (!raw) return deepClone(DEFAULT_STATE);
const parsed = JSON.parse(raw);
const st = deepClone(DEFAULT_STATE);
if (parsed && typeof parsed === "object") {
if (Array.isArray(parsed.folders)) st.folders = parsed.folders.map(normalizeFolderName).filter(Boolean);
if (parsed.map && typeof parsed.map === "object") st.map = parsed.map;
if (parsed.ui && typeof parsed.ui === "object") {
st.ui.active = typeof parsed.ui.active === "string" ? parsed.ui.active : st.ui.active;
st.ui.collapsed = parsed.ui.collapsed && typeof parsed.ui.collapsed === "object" ? parsed.ui.collapsed : {};
st.ui.hiddenFolders = parsed.ui.hiddenFolders && typeof parsed.ui.hiddenFolders === "object" ? parsed.ui.hiddenFolders : {};
st.ui.showBadge = typeof parsed.ui.showBadge === "boolean" ? parsed.ui.showBadge : st.ui.showBadge;
}
st.meta.updatedAt = Number.isFinite(+parsed?.meta?.updatedAt) ? +parsed.meta.updatedAt : 0;
}
st.folders = Array.from(new Set(st.folders)).filter(n => n !== "All");
return st;
} catch {
return deepClone(DEFAULT_STATE);
}
}
function saveState(storeKey, state) {
try {
state.meta.updatedAt = now();
safeWrite(storeKey, JSON.stringify(state));
} catch {}
}
// ---------------------------
// Styling
// ---------------------------
function injectCSS() {
if ($("#__blfp_css")) return;
const css = `
:root{
--blfp-bg: rgba(20, 20, 24, 0.78);
--blfp-bg2: rgba(28, 28, 36, 0.72);
--blfp-border: rgba(255,255,255,0.12);
--blfp-border2: rgba(255,255,255,0.18);
--blfp-text: rgba(248,255,253,0.92);
--blfp-dim: rgba(248,255,253,0.65);
--blfp-accent: rgba(180, 200, 255, 0.90);
--blfp-danger: rgba(255, 70, 90, 0.95);
}
#__blfp_wrap{
margin-top: 14px;
margin-bottom: 14px;
background: var(--blfp-bg);
border: 1px solid var(--blfp-border);
border-bottom: 3px solid var(--blfp-border2);
border-radius: 16px;
box-shadow: 0 14px 40px rgba(0,0,0,0.28);
backdrop-filter: blur(8px);
overflow: hidden;
}
#__blfp_head{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
border-bottom: 1px solid rgba(255,255,255,0.10);
user-select:none;
}
#__blfp_title{
display:flex;
align-items:center;
gap:10px;
font-weight: 850;
letter-spacing: 0.2px;
font-size: 13px;
color: var(--blfp-text);
}
#__blfp_title small{
font-weight: 700;
color: var(--blfp-dim);
}
#__blfp_actions{
display:flex;
align-items:center;
gap:8px;
}
.blfp-btn{
all: unset;
cursor: pointer;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.10);
color: rgba(248,255,253,0.88);
font-size: 12px;
font-weight: 800;
user-select: none;
line-height: 1;
white-space: nowrap;
}
.blfp-btn:hover{ background: rgba(255,255,255,0.14); }
.blfp-btn:active{ transform: translateY(1px); }
.blfp-btn-danger{
background: rgba(255, 70, 90, 0.20) !important;
border-color: rgba(255, 70, 90, 0.55) !important;
color: rgba(255, 220, 225, 0.96) !important;
}
.blfp-btn-dangerArm{
background: rgba(255, 70, 90, 0.32) !important;
border-color: rgba(255, 70, 90, 0.75) !important;
}
.blfp-tabs{
display:flex;
gap:8px;
flex-wrap: wrap;
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.10);
background: rgba(0,0,0,0.06);
}
.blfp-tab{
position: relative;
all: unset;
cursor: pointer;
padding: 7px 12px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.10);
color: rgba(248,255,253,0.82);
font-size: 12px;
font-weight: 800;
user-select: none;
line-height: 1;
}
.blfp-tab:hover{ background: rgba(255,255,255,0.12); }
.blfp-tab[data-active="1"]{
color: rgba(248,255,253,0.95);
background: rgba(180,200,255,0.16);
border-color: rgba(180,200,255,0.30);
box-shadow: 0 0 0 1px rgba(180,200,255,0.10) inset;
}
.blfp-dropHint{
outline: 2px dashed rgba(180,200,255,0.70);
outline-offset: 2px;
}
.blfp-section.blfp-dropHint{
outline: 2px dashed rgba(180,200,255,0.75);
outline-offset: 3px;
}
.blfp-sectionHeader.blfp-dropHint{
outline: 2px dashed rgba(180,200,255,0.75);
outline-offset: 3px;
}
#__blfp_body{ padding: 12px; }
.blfp-section{
margin-bottom: 14px;
background: var(--blfp-bg2);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 14px;
overflow: hidden;
}
.blfp-sectionHeader{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
border-bottom: 1px solid rgba(255,255,255,0.10);
user-select:none;
}
.blfp-sectionHeaderLeft{
display:flex;
align-items:center;
gap:10px;
font-weight: 900;
letter-spacing: 0.2px;
color: rgba(248,255,253,0.92);
font-size: 12px;
}
.blfp-pill{
padding: 2px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.12);
color: rgba(248,255,253,0.75);
font-weight: 900;
font-size: 11px;
}
.blfp-collapse{
all: unset;
cursor:pointer;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.10);
color: rgba(248,255,253,0.82);
font-weight: 900;
font-size: 11px;
}
.blfp-sectionContent{ padding: 12px 10px 10px; }
.blfp-grid{
display:flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.blfp-draggable{ cursor: grab; }
.blfp-draggable:active{ cursor: grabbing; }
.blfp-tag{
position:absolute;
right: 6px;
top: 6px;
z-index: 6;
padding: 3px 7px;
border-radius: 999px;
background: rgba(0,0,0,0.35);
border: 1px solid rgba(255,255,255,0.16);
color: rgba(248,255,253,0.85);
font-size: 10px;
font-weight: 900;
pointer-events: none;
}
#__blfp_overlay{
position: fixed;
inset: 0;
z-index: 2147483647;
display:none;
background: rgba(0,0,0,0.45);
backdrop-filter: blur(4px);
overscroll-behavior: contain;
}
#__blfp_modalHeader{
position: sticky;
top: 0;
z-index: 5;
display:flex;
align-items:center;
justify-content:space-between;
padding: 12px 12px;
border-bottom: 1px solid rgba(255,255,255,0.10);
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.04));
backdrop-filter: blur(8px);
user-select:none;
}
#__blfp_modalTitle{
font-weight: 950;
font-size: 13px;
letter-spacing: 0.2px;
color: rgba(248,255,253,0.94);
}
#__blfp_modal{
position:absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 560px;
max-width: calc(100vw - 28px);
/* IMPORTANT: make the modal itself the scroll container */
max-height: min(86vh, 760px);
overflow: auto;
background: var(--blfp-bg);
border: 1px solid var(--blfp-border);
border-bottom: 3px solid var(--blfp-border2);
border-radius: 16px;
box-shadow: 0 20px 70px rgba(0,0,0,0.55);
color: var(--blfp-text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
}
#__blfp_modalBody{
padding: 12px;
display:flex;
flex-direction: column;
gap: 12px;
/* remove max-height here completely */
max-height: none;
overflow: visible;
}
.blfp-row{
display:flex;
gap:10px;
align-items:center;
justify-content:space-between;
}
.blfp-label{
font-size: 12px;
font-weight: 900;
color: rgba(248,255,253,0.88);
}
.blfp-sub{
font-size: 11px;
color: rgba(248,255,253,0.62);
margin-top: 2px;
}
.blfp-input{
all: unset;
padding: 7px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.22);
color: rgba(248,255,253,0.92);
font-size: 12px;
min-width: 220px;
text-align: center;
}
.blfp-textarea{
width: 100%;
min-height: 120px;
resize: vertical;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.22);
color: rgba(248,255,253,0.92);
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
outline: none;
box-sizing: border-box;
}
.blfp-divider{
height: 1px;
background: rgba(255,255,255,0.10);
margin: 2px 0;
}
.blfp-chipRow{
display:flex;
flex-wrap:wrap;
gap:8px;
align-items:center;
justify-content:flex-end;
}
.blfp-miniBtn{
all: unset;
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 10px;
display:flex;
align-items:center;
justify-content:center;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
color: rgba(248,255,253,0.85);
font-weight: 900;
user-select:none;
}
.blfp-miniBtn:hover{ background: rgba(255,255,255,0.12); }
.blfp-miniBtn:active{ transform: translateY(1px); }
/* Switch */
.blfp-switch{
display:flex;
align-items:center;
gap:10px;
}
.blfp-switchTrack{
position: relative;
width: 46px;
height: 24px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.14);
cursor: pointer;
flex: 0 0 auto;
}
.blfp-switchTrack[data-on="1"]{
background: rgba(180,200,255,0.20);
border-color: rgba(180,200,255,0.28);
}
.blfp-switchKnob{
position:absolute;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(248,255,253,0.90);
left: 3px;
transition: left 120ms ease;
}
.blfp-switchTrack[data-on="1"] .blfp-switchKnob{
left: 25px;
}
`;
const style = document.createElement("style");
style.id = "__blfp_css";
style.textContent = css;
document.head.appendChild(style);
}
// ---------------------------
// UI Build
// ---------------------------
function buildUI(levelsRoot, state, storeKey, userSlug) {
injectCSS();
const stash = document.createElement("div");
stash.id = "__blfp_stash";
stash.style.display = "none";
// Wrapper inserted above the levels list
const wrap = document.createElement("div");
wrap.id = "__blfp_wrap";
const head = document.createElement("div");
head.id = "__blfp_head";
const title = document.createElement("div");
title.id = "__blfp_title";
title.innerHTML = `Folders <small></small>`;
const actions = document.createElement("div");
actions.id = "__blfp_actions";
const btnNew = document.createElement("button");
btnNew.className = "blfp-btn";
btnNew.textContent = "New folder";
const btnManage = document.createElement("button");
btnManage.className = "blfp-btn";
btnManage.textContent = "Manage";
actions.appendChild(btnNew);
actions.appendChild(btnManage);
head.appendChild(title);
head.appendChild(actions);
const tabs = document.createElement("div");
tabs.className = "blfp-tabs";
const body = document.createElement("div");
body.id = "__blfp_body";
wrap.appendChild(head);
wrap.appendChild(tabs);
wrap.appendChild(body);
// modal
const overlay = document.createElement("div");
overlay.id = "__blfp_overlay";
overlay.innerHTML = `
<div id="__blfp_modal" role="dialog" aria-modal="true">
<div id="__blfp_modalHeader">
<div id="__blfp_modalTitle">Manage folders</div>
<button class="blfp-btn" id="__blfp_close">Close</button>
</div>
<div id="__blfp_modalBody"></div>
</div>
`;
document.body.appendChild(overlay);
const modalBody = $("#__blfp_modalBody", overlay);
const closeBtn = $("#__blfp_close", overlay);
function openModal() {
renderManage();
overlay.style.display = "block";
document.documentElement.style.overflow = "hidden";
}
function closeModal() {
overlay.style.display = "none";
document.documentElement.style.overflow = "";
}
overlay.addEventListener("mousedown", (e) => {
if (e.target === overlay) closeModal();
});
closeBtn.addEventListener("click", closeModal);
document.addEventListener("keydown", (e) => {
if (overlay.style.display === "block" && e.key === "Escape") closeModal();
});
function visibleFolders() {
return state.folders.filter(f => !state.ui.hiddenFolders?.[f]);
}
function setActive(name) {
state.ui.active = name;
saveState(storeKey, state);
renderTabs();
renderSections();
}
function renderTabs() {
tabs.innerHTML = "";
const makeTab = (name, droppableKey) => {
const b = document.createElement("button");
b.className = "blfp-tab";
b.textContent = name;
b.dataset.active = (state.ui.active === name) ? "1" : "0";
b.addEventListener("click", () => setActive(name));
// Drop target
b.addEventListener("dragover", (e) => {
e.preventDefault();
b.classList.add("blfp-dropHint");
});
b.addEventListener("dragleave", () => b.classList.remove("blfp-dropHint"));
b.addEventListener("drop", (e) => {
e.preventDefault();
b.classList.remove("blfp-dropHint");
const id = e.dataTransfer?.getData("text/blfp-levelid") || "";
if (!id) return;
if (droppableKey === "__all__") return;
if (droppableKey === "Unsorted") delete state.map[id];
else state.map[id] = droppableKey;
saveState(storeKey, state);
renderSections();
});
return b;
};
tabs.appendChild(makeTab("All", "__all__"));
for (const f of visibleFolders()) {
tabs.appendChild(makeTab(f, f));
}
// if active points to hidden folder, back to All
if (state.ui.active !== "All" && !visibleFolders().includes(state.ui.active)) {
state.ui.active = "All";
saveState(storeKey, state);
}
}
function ensureFolder(name) {
const n = normalizeFolderName(name);
if (!n) return null;
if (n === "All" || n === "Unsorted" || n === "Favorites") return null;
if (!state.folders.includes(n)) state.folders.push(n);
state.folders = Array.from(new Set(state.folders));
saveState(storeKey, state);
return n;
}
function removeFolder(name) {
state.folders = state.folders.filter(f => f !== name);
for (const [id, folder] of Object.entries(state.map)) {
if (folder === name) delete state.map[id];
}
delete state.ui.collapsed[name];
delete state.ui.hiddenFolders[name];
if (state.ui.active === name) state.ui.active = "All";
saveState(storeKey, state);
}
function renameFolder(oldName, newName) {
const nn = normalizeFolderName(newName);
if (!nn || nn === "All" || nn === "Unsorted") return null;
if (state.folders.includes(nn) && nn !== oldName) return null;
state.folders = state.folders.map(f => (f === oldName ? nn : f));
for (const [id, folder] of Object.entries(state.map)) {
if (folder === oldName) state.map[id] = nn;
}
if (state.ui.active === oldName) state.ui.active = nn;
if (state.ui.collapsed[oldName] != null) {
state.ui.collapsed[nn] = !!state.ui.collapsed[oldName];
delete state.ui.collapsed[oldName];
}
if (state.ui.hiddenFolders[oldName] != null) {
state.ui.hiddenFolders[nn] = !!state.ui.hiddenFolders[oldName];
delete state.ui.hiddenFolders[oldName];
}
saveState(storeKey, state);
return nn;
}
function moveFolder(oldIndex, newIndex) {
const arr = state.folders.slice();
if (oldIndex < 0 || oldIndex >= arr.length) return;
newIndex = Math.max(0, Math.min(arr.length - 1, newIndex));
const [item] = arr.splice(oldIndex, 1);
arr.splice(newIndex, 0, item);
state.folders = arr;
saveState(storeKey, state);
}
function makeSwitch(label, value, onChange, subText) {
const row = document.createElement("div");
row.className = "blfp-row";
const left = document.createElement("div");
left.innerHTML = `
<div class="blfp-label">${escapeHtml(label)}</div>
${subText ? `<div class="blfp-sub">${escapeHtml(subText)}</div>` : ""}
`;
const right = document.createElement("div");
right.className = "blfp-switch";
const track = document.createElement("div");
track.className = "blfp-switchTrack";
track.dataset.on = value ? "1" : "0";
const knob = document.createElement("div");
knob.className = "blfp-switchKnob";
track.appendChild(knob);
function setOn(v) {
track.dataset.on = v ? "1" : "0";
}
track.addEventListener("click", () => {
const newVal = track.dataset.on !== "1";
setOn(newVal);
onChange(newVal);
});
right.appendChild(track);
row.appendChild(left);
row.appendChild(right);
return row;
}
function resetCurrentUser() {
safeRemove(storeKey);
// reload state in-place
const fresh = deepClone(DEFAULT_STATE);
state.folders = fresh.folders;
state.map = fresh.map;
state.ui = fresh.ui;
saveState(storeKey, state);
renderTabs();
renderSections();
}
function resetAllProfiles() {
// Remove all user states + global key
for (const k of listUserKeys()) safeRemove(k);
safeRemove(KEY_GLOBAL);
}
function exportStateCurrentUser() {
const payload = {
type: "blfp-export",
version: "1.2.0",
scope: "user",
user: userSlug,
data: {
folders: Array.isArray(state.folders) ? state.folders.slice() : [],
map: (state.map && typeof state.map === "object") ? state.map : {},
},
exportedAt: new Date().toISOString(),
};
return JSON.stringify(payload, null, 2);
}
function isDefaultState(st) {
try {
const d = deepClone(DEFAULT_STATE);
// Normalize folders
const folders = Array.isArray(st?.folders) ? st.folders.slice() : [];
const defFolders = Array.isArray(d?.folders) ? d.folders.slice() : [];
// Check folder structure
if (folders.length !== defFolders.length) return false;
for (let i = 0; i < folders.length; i++) {
if (folders[i] !== defFolders[i]) return false;
}
// Check level assignments
const map = (st?.map && typeof st.map === "object") ? st.map : {};
if (Object.keys(map).length !== 0) return false;
// Ignore ALL UI settings and meta
return true;
} catch {
return false;
}
}
function exportStateAllProfiles() {
const users = {};
for (const k of listUserKeys()) {
const u = k.slice(PREFIX_USER.length);
const st = loadState(k);
// Skip profiles that are still default (folders+map only)
if (isDefaultState(st)) continue;
users[u] = {
folders: Array.isArray(st.folders) ? st.folders.slice() : [],
map: (st.map && typeof st.map === "object") ? st.map : {},
};
}
const payload = {
type: "blfp-export",
version: "1.2.0",
scope: "all",
users,
exportedAt: new Date().toISOString(),
};
return JSON.stringify(payload, null, 2);
}
function importPayload(raw, mode, scopeTarget) {
// mode: "merge" | "replace"
// scopeTarget: "current" | "all"
let data;
try {
data = JSON.parse(raw);
} catch {
return { ok: false, msg: "Invalid JSON." };
}
const isExport = data && typeof data === "object" && data.type === "blfp-export";
if (!isExport) return { ok: false, msg: "Not a BLFP export JSON." };
const applyOne = (targetKey, incomingState) => {
const incoming = incomingState || {};
// Accept minimal payloads: {folders, map}
const incomingFolders = Array.isArray(incoming.folders) ? incoming.folders : [];
const incomingMap = (incoming.map && typeof incoming.map === "object") ? incoming.map : {};
if (mode === "replace") {
const base = loadState(targetKey);
base.folders = Array.isArray(incomingFolders) ? incomingFolders.slice() : base.folders;
base.map = incomingMap;
saveState(targetKey, base);
return;
}
// merge
const cur = loadState(targetKey);
// folders: keep order of existing, append new (in incoming order)
const curSet = new Set(cur.folders);
const mergedFolders = cur.folders.slice();
for (const f of incomingFolders) {
const nf = normalizeFolderName(f);
if (!nf) continue;
if (!curSet.has(nf)) {
mergedFolders.push(nf);
curSet.add(nf);
}
}
cur.folders = mergedFolders.filter(n => n !== "All");
// Ensure base folders exist and stay on top
cur.folders = Array.isArray(cur.folders) ? cur.folders : [];
cur.folders = cur.folders.filter(f => f !== "Favorites" && f !== "Unsorted");
cur.folders.unshift("Unsorted");
cur.folders.unshift("Favorites");
cur.folders = Array.from(new Set(cur.folders)).filter(n => n !== "All");
// map: incoming overwrites same level ids
for (const [id, folder] of Object.entries(incomingMap)) {
const fn = normalizeFolderName(folder);
if (!fn) continue;
cur.map[id] = fn;
}
saveState(targetKey, cur);
};
if (data.scope === "user") {
const incomingState = data.data || data.state;
if (scopeTarget === "all") {
for (const k of listUserKeys()) applyOne(k, incomingState);
applyOne(storeKey, incomingState);
return { ok: true, msg: "Imported to all profiles." };
} else {
applyOne(storeKey, incomingState);
return { ok: true, msg: "Imported to current profile." };
}
}
if (data.scope === "all") {
const users = data.users && typeof data.users === "object" ? data.users : null;
if (!users) return { ok: false, msg: "Missing users payload." };
if (scopeTarget === "all") {
for (const [user, st] of Object.entries(users)) {
const k = keyForUser(user);
applyOne(k, st);
}
return { ok: true, msg: "Imported to all profiles." };
} else {
const st = users[userSlug] || null;
if (!st) {
return { ok: false, msg: "This export does not contain the current user." };
}
applyOne(storeKey, st);
return { ok: true, msg: "Imported to current profile." };
}
}
return { ok: false, msg: "Unknown export scope." };
}
function renderManage() {
const SYSTEM = new Set(["Favorites", "Unsorted"]);
modalBody.innerHTML = "";
// Section: Behavior
{
const sec = document.createElement("div");
sec.className = "blfp-section";
const h = document.createElement("div");
h.className = "blfp-sectionHeader";
h.innerHTML = `<div class="blfp-sectionHeaderLeft">Appearance <span class="blfp-pill">UI</span></div><div></div>`;
const c = document.createElement("div");
c.className = "blfp-sectionContent";
const sw = makeSwitch(
"Show folder badge on cards",
!!state.ui.showBadge,
(v) => {
state.ui.showBadge = !!v;
saveState(storeKey, state);
renderSections();
},
"Tiny label on the top-right of each level card."
);
c.appendChild(sw);
sec.appendChild(h);
sec.appendChild(c);
modalBody.appendChild(sec);
}
// Section: Folders
{
const sec = document.createElement("div");
sec.className = "blfp-section";
const h = document.createElement("div");
h.className = "blfp-sectionHeader";
h.innerHTML = `
<div class="blfp-sectionHeaderLeft">Folders <span class="blfp-pill">${state.folders.length}</span></div>
<div></div>
`;
const c = document.createElement("div");
c.className = "blfp-sectionContent";
const list = document.createElement("div");
list.style.display = "flex";
list.style.flexDirection = "column";
list.style.gap = "10px";
for (let idx = 0; idx < state.folders.length; idx++) {
const f = state.folders[idx];
const isSystem = SYSTEM.has(f);
const isHidden = !!state.ui.hiddenFolders?.[f];
const row = document.createElement("div");
row.className = "blfp-row";
row.innerHTML = `
<div style="min-width: 200px;">
<div class="blfp-label">${escapeHtml(f)} ${isHidden ? `<span class="blfp-pill">hidden</span>` : ""}</div>
<div class="blfp-sub">Order affects tabs and sections. Hidden folders are not shown.</div>
</div>
<div class="blfp-chipRow">
<button class="blfp-miniBtn" data-act="up" title="Move up">▲</button>
<button class="blfp-miniBtn" data-act="down" title="Move down">▼</button>
<button class="blfp-btn" data-act="hide">${isHidden ? "Unhide" : "Hide"}</button>
${isSystem ? "" : `<button class="blfp-btn" data-act="rename">Rename</button>`}
${isSystem ? "" : `<button class="blfp-btn blfp-btn-danger" data-act="delete">Delete</button>`}
</div>
`;
row.querySelector('[data-act="up"]').addEventListener("click", () => {
moveFolder(idx, idx - 1);
renderTabs(); renderSections(); renderManage();
});
row.querySelector('[data-act="down"]').addEventListener("click", () => {
moveFolder(idx, idx + 1);
renderTabs(); renderSections(); renderManage();
});
row.querySelector('[data-act="hide"]').addEventListener("click", () => {
state.ui.hiddenFolders = state.ui.hiddenFolders || {};
state.ui.hiddenFolders[f] = !state.ui.hiddenFolders[f];
saveState(storeKey, state);
// if active folder is hidden -> back to All
if (state.ui.hiddenFolders[f] && state.ui.active === f) {
state.ui.active = "All";
saveState(storeKey, state);
}
renderTabs(); renderSections(); renderManage();
});
const renameBtn = row.querySelector('[data-act="rename"]');
if (renameBtn) {
renameBtn.addEventListener("click", () => {
const nn = prompt(`Rename folder "${f}" to:`, f);
if (nn == null) return;
const res = renameFolder(f, nn);
if (!res) alert("Invalid name (or already exists).");
renderTabs(); renderSections(); renderManage();
});
}
const deleteBtn = row.querySelector('[data-act="delete"]');
if (deleteBtn) {
deleteBtn.addEventListener("click", () => {
const ok = confirm(`Delete folder "${f}"? Levels inside will go to Unsorted.`);
if (!ok) return;
removeFolder(f);
renderTabs(); renderSections(); renderManage();
});
}
list.appendChild(row);
const div = document.createElement("div");
div.className = "blfp-divider";
list.appendChild(div);
}
// Create folder
const createRow = document.createElement("div");
createRow.className = "blfp-row";
createRow.innerHTML = `
<div>
<div class="blfp-label">Create folder</div>
<div class="blfp-sub">New folder will appear at the bottom. Active tab stays on "All".</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<input class="blfp-input" id="__blfp_newName" placeholder="Folder name" />
<button class="blfp-btn" id="__blfp_createNow">Create</button>
</div>
`;
c.appendChild(list);
c.appendChild(createRow);
sec.appendChild(h);
sec.appendChild(c);
modalBody.appendChild(sec);
const inp = $("#__blfp_newName", createRow);
const btn = $("#__blfp_createNow", createRow);
btn.addEventListener("click", () => {
const name = inp.value;
const made = ensureFolder(name);
if (!made) return alert("Invalid folder name.");
inp.value = "";
state.ui.active = "All";
saveState(storeKey, state);
renderTabs(); renderSections(); renderManage();
});
}
// Section: Export / Import
{
const sec = document.createElement("div");
sec.className = "blfp-section";
const h = document.createElement("div");
h.className = "blfp-sectionHeader";
h.innerHTML = `<div class="blfp-sectionHeaderLeft">Export / Import <span class="blfp-pill">JSON</span></div><div></div>`;
const c = document.createElement("div");
c.className = "blfp-sectionContent";
const row1 = document.createElement("div");
row1.className = "blfp-row";
row1.innerHTML = `
<div>
<div class="blfp-label">Export</div>
<div class="blfp-sub">Copy your folder layout to share it or backup.</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button class="blfp-btn" id="__blfp_exp_user">Export current user</button>
<button class="blfp-btn" id="__blfp_exp_all">Export all profiles</button>
</div>
`;
const ta = document.createElement("textarea");
ta.className = "blfp-textarea";
ta.id = "__blfp_json";
ta.placeholder = "Export will appear here. Paste an export here to import.";
const row2 = document.createElement("div");
row2.className = "blfp-row";
row2.innerHTML = `
<div>
<div class="blfp-label">Import</div>
<div class="blfp-sub">Merge adds/overwrites fields. Replace wipes and uses the export as-is.</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button class="blfp-btn" id="__blfp_imp_merge_cur">Merge → current</button>
<button class="blfp-btn" id="__blfp_imp_replace_cur">Replace → current</button>
</div>
`;
const row3 = document.createElement("div");
row3.className = "blfp-row";
row3.innerHTML = `
<div>
<div class="blfp-label">Import to all profiles</div>
<div class="blfp-sub">Apply the same layout to every saved profile (dangerous if you replace).</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button class="blfp-btn" id="__blfp_imp_merge_all">Merge → all</button>
<button class="blfp-btn blfp-btn-danger" id="__blfp_imp_replace_all">Replace → all</button>
</div>
`;
const msg = document.createElement("div");
msg.className = "blfp-sub";
msg.style.marginTop = "6px";
msg.id = "__blfp_msg";
c.appendChild(row1);
c.appendChild(ta);
c.appendChild(row2);
c.appendChild(row3);
c.appendChild(msg);
sec.appendChild(h);
sec.appendChild(c);
modalBody.appendChild(sec);
const expUser = $("#__blfp_exp_user");
const expAll = $("#__blfp_exp_all");
const impMergeCur = $("#__blfp_imp_merge_cur");
const impReplaceCur = $("#__blfp_imp_replace_cur");
const impMergeAll = $("#__blfp_imp_merge_all");
const impReplaceAll = $("#__blfp_imp_replace_all");
const out = $("#__blfp_json");
const outMsg = $("#__blfp_msg");
function setMsg(t) { outMsg.textContent = t; }
expUser.addEventListener("click", async () => {
const s = exportStateCurrentUser();
out.value = s;
try { await navigator.clipboard.writeText(s); setMsg("Export copied to clipboard."); }
catch { setMsg("Export generated (copy manually)."); }
});
expAll.addEventListener("click", async () => {
const s = exportStateAllProfiles();
out.value = s;
try { await navigator.clipboard.writeText(s); setMsg("Export copied to clipboard."); }
catch { setMsg("Export generated (copy manually)."); }
});
function doImport(mode, scopeTarget) {
const raw = out.value.trim();
if (!raw) { setMsg("Paste JSON first."); return; }
if (mode === "replace" && scopeTarget === "all") {
const ok = confirm("This will REPLACE every profile. Continue?");
if (!ok) return;
}
const res = importPayload(raw, mode, scopeTarget);
setMsg(res.ok ? res.msg : `Import failed: ${res.msg}`);
// reload current state from storage after import
const re = loadState(storeKey);
state.folders = re.folders;
state.map = re.map;
state.ui = re.ui;
state.meta = re.meta;
renderTabs();
renderSections();
renderManage();
}
impMergeCur.addEventListener("click", () => doImport("merge", "current"));
impReplaceCur.addEventListener("click", () => doImport("replace", "current"));
impMergeAll.addEventListener("click", () => doImport("merge", "all"));
impReplaceAll.addEventListener("click", () => doImport("replace", "all"));
}
// Section: Reset
{
const sec = document.createElement("div");
sec.className = "blfp-section";
const h = document.createElement("div");
h.className = "blfp-sectionHeader";
h.innerHTML = `<div class="blfp-sectionHeaderLeft">Danger Zone <span class="blfp-pill">reset</span></div><div></div>`;
const c = document.createElement("div");
c.className = "blfp-sectionContent";
const row1 = document.createElement("div");
row1.className = "blfp-row";
row1.innerHTML = `
<div>
<div class="blfp-label">Reset current user</div>
<div class="blfp-sub">Deletes folder layout for <b>@${escapeHtml(userSlug)}</b> only (local).</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button class="blfp-btn blfp-btn-danger" id="__blfp_reset_user">Reset</button>
</div>
`;
const row2 = document.createElement("div");
row2.className = "blfp-row";
row2.innerHTML = `
<div>
<div class="blfp-label">Reset ALL profiles</div>
<div class="blfp-sub">Deletes every BLFP profile stored in this browser.</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<button class="blfp-btn blfp-btn-danger" id="__blfp_reset_all">Reset all</button>
</div>
`;
c.appendChild(row1);
c.appendChild(row2);
sec.appendChild(h);
sec.appendChild(c);
modalBody.appendChild(sec);
const btnResetUser = $("#__blfp_reset_user");
const btnResetAll = $("#__blfp_reset_all");
btnResetUser.addEventListener("click", () => {
const ok = confirm("Reset current user folders? (Local only)");
if (!ok) return;
resetCurrentUser();
renderManage();
});
// double confirm
let armedAt = 0;
btnResetAll.addEventListener("click", () => {
const t = now();
if (t - armedAt > 6500) {
armedAt = t;
btnResetAll.classList.add("blfp-btn-dangerArm");
btnResetAll.textContent = "Confirm reset all";
setTimeout(() => {
if (now() - armedAt > 6500) {
armedAt = 0;
btnResetAll.classList.remove("blfp-btn-dangerArm");
btnResetAll.textContent = "Reset all";
}
}, 6600);
return;
}
const ok = confirm("Last warning: delete ALL BLFP profiles from this browser?");
if (!ok) return;
resetAllProfiles();
resetCurrentUser();
armedAt = 0;
btnResetAll.classList.remove("blfp-btn-dangerArm");
btnResetAll.textContent = "Reset all";
renderManage();
});
}
}
// Sections display
function makeSection(titleText, keyName, cards) {
const section = document.createElement("div");
section.className = "blfp-section";
const collapsed = !!state.ui.collapsed[keyName];
const header = document.createElement("div");
header.className = "blfp-sectionHeader";
const left = document.createElement("div");
left.className = "blfp-sectionHeaderLeft";
left.innerHTML = `${escapeHtml(titleText)} <span class="blfp-pill">${cards.length}</span>`;
const right = document.createElement("div");
right.style.display = "flex";
right.style.gap = "8px";
right.style.alignItems = "center";
const btnCollapse = document.createElement("button");
btnCollapse.className = "blfp-collapse";
btnCollapse.textContent = collapsed ? "Expand" : "Collapse";
btnCollapse.addEventListener("click", () => {
state.ui.collapsed[keyName] = !state.ui.collapsed[keyName];
saveState(storeKey, state);
renderSections();
});
right.appendChild(btnCollapse);
header.appendChild(left);
header.appendChild(right);
const content = document.createElement("div");
content.className = "blfp-sectionContent";
content.style.display = collapsed ? "none" : "block";
const grid = document.createElement("div");
grid.className = "blfp-grid";
for (const c of cards) grid.appendChild(c);
content.appendChild(grid);
section.appendChild(header);
section.appendChild(content);
// Drop target: section + header
const isDroppable =
(typeof keyName === "string" && keyName.length > 0 && keyName !== "All");
function applyDrop(levelId) {
if (!levelId) return;
if (keyName === "Unsorted") delete state.map[levelId];
else state.map[levelId] = keyName;
saveState(storeKey, state);
if (state.ui.collapsed[keyName]) {
state.ui.collapsed[keyName] = false;
saveState(storeKey, state);
}
renderSections();
}
function onDragOver(e) {
if (!isDroppable) return;
e.preventDefault();
section.classList.add("blfp-dropHint");
}
function onDragLeave() {
section.classList.remove("blfp-dropHint");
}
function onDrop(e) {
if (!isDroppable) return;
e.preventDefault();
section.classList.remove("blfp-dropHint");
const id = e.dataTransfer?.getData("text/blfp-levelid") || "";
applyDrop(id);
}
header.addEventListener("dragover", onDragOver);
header.addEventListener("dragleave", onDragLeave);
header.addEventListener("drop", onDrop);
section.addEventListener("dragover", onDragOver);
section.addEventListener("dragleave", onDragLeave);
section.addEventListener("drop", onDrop);
return section;
}
// ---- Card pool
const orderIndex = new Map(); // card -> index
const cardsAll = []; // stable references
function addCardToPool(card) {
if (!card || card.nodeType !== 1) return;
if (!card.classList.contains("pixii-level")) return;
if (card.__blfpPooled) return;
card.__blfpPooled = true;
orderIndex.set(card, orderIndex.size);
cardsAll.push(card);
stash.appendChild(card);
}
function ingestFromLevelsRoot() {
const fresh = $$(".pixii-level", levelsRoot);
for (const c of fresh) addCardToPool(c);
levelsRoot.innerHTML = "";
}
function tagCard(card, folderName) {
const old = card.querySelector(".__blfp_tag");
if (old) old.remove();
if (!state.ui.showBadge) return;
if (!folderName) return;
const style = getComputedStyle(card);
if (style.position === "static") card.style.position = "relative";
const t = document.createElement("div");
t.className = "blfp-tag __blfp_tag";
t.textContent = folderName;
card.appendChild(t);
}
function enableDragOnce(card, levelId) {
if (card.__blfpDragReady) return;
card.__blfpDragReady = true;
card.setAttribute("draggable", "true");
card.classList.add("blfp-draggable");
card.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/blfp-levelid", levelId);
e.dataTransfer.effectAllowed = "move";
});
}
function renderSections() {
ingestFromLevelsRoot();
const byFolder = new Map();
const all = [];
for (const card of cardsAll) {
const id = getLevelIdFromCard(card);
if (!id) continue;
all.push(card);
const folder = normalizeFolderName(state.map[id] || "") || "Unsorted";
if (!byFolder.has(folder)) byFolder.set(folder, []);
byFolder.get(folder).push(card);
}
const sortByOriginal = (arr) =>
arr.sort((a, b) => (orderIndex.get(a) ?? 0) - (orderIndex.get(b) ?? 0));
sortByOriginal(all);
for (const arr of byFolder.values()) sortByOriginal(arr);
for (const c of all) stash.appendChild(c);
for (const card of all) {
const id = getLevelIdFromCard(card);
const folder = normalizeFolderName(state.map[id] || "");
tagCard(card, folder || "");
enableDragOnce(card, id);
}
body.innerHTML = "";
const active = state.ui.active;
const visFolders = visibleFolders();
if (active === "All") {
for (const f of visFolders) {
const arr = byFolder.get(f) || [];
body.appendChild(makeSection(f, f, arr));
}
} else {
// only show if not hidden
if (!visFolders.includes(active)) {
state.ui.active = "All";
saveState(storeKey, state);
renderTabs();
return renderSections();
}
body.appendChild(makeSection(active, active, byFolder.get(active) || []));
}
levelsRoot.style.display = "none";
}
function rebuildAll() {
renderTabs();
renderSections();
}
// Buttons
btnNew.addEventListener("click", () => {
const name = prompt("New folder name:", "New Folder");
if (name == null) return;
const made = ensureFolder(name);
if (!made) return alert("Invalid folder name.");
state.ui.active = "All";
saveState(storeKey, state);
renderTabs();
renderSections();
});
btnManage.addEventListener("click", openModal);
// Insert UI + stash
levelsRoot.parentElement.insertBefore(wrap, levelsRoot);
levelsRoot.parentElement.insertBefore(stash, levelsRoot);
// Initial pool ingest + render
ingestFromLevelsRoot();
rebuildAll();
const mo = new MutationObserver(() => {
const cardsNow = $$(".pixii-level", levelsRoot);
if (cardsNow.length) {
ingestFromLevelsRoot();
renderSections();
}
});
mo.observe(levelsRoot, { childList: true, subtree: false });
}
// ---------------------------
// Boot
// ---------------------------
function boot() {
const user = getUserSlug();
const storeKey = keyForUser(user);
const levelsRoot = $(".levelspage-levels");
if (!levelsRoot) return;
const state = loadState(storeKey);
const allowed = new Set(state.folders);
for (const [id, f] of Object.entries(state.map)) {
if (!f) continue;
if (!allowed.has(f)) delete state.map[id];
}
// Keep UI sane
if (!state.ui) state.ui = deepClone(DEFAULT_STATE.ui);
if (typeof state.ui.showBadge !== "boolean") state.ui.showBadge = true;
if (!state.ui.hiddenFolders || typeof state.ui.hiddenFolders !== "object") state.ui.hiddenFolders = {};
// Ensure base folders exist and have a sane default order
state.folders = Array.isArray(state.folders) ? state.folders : [];
state.folders = state.folders.filter(f => f !== "Favorites" && f !== "Unsorted");
state.folders.unshift("Unsorted");
state.folders.unshift("Favorites");
state.folders = Array.from(new Set(state.folders)).filter(n => n !== "All");
const anyVisible = state.folders.some(f => !state.ui.hiddenFolders?.[f]);
if (!anyVisible) {
state.ui.hiddenFolders["Favorites"] = false;
state.ui.hiddenFolders["Unsorted"] = false;
state.ui.active = "All";
}
saveState(storeKey, state);
buildUI(levelsRoot, state, storeKey, user);
}
// Wait for levels
const start = now();
const t = setInterval(() => {
const levelsRoot = $(".levelspage-levels");
const cards = levelsRoot ? $$(".pixii-level", levelsRoot) : [];
if (levelsRoot && cards.length) {
clearInterval(t);
boot();
return;
}
if (now() - start > 15000) {
clearInterval(t);
boot();
}
}, 200);
})();