////
// ==UserScript==
// @name Remanga
// @namespace http://tampermonkey.net/
// @version 5.2.2
// @description ////
// @author You
// @license MIT
// @match https://remanga.org/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=remanga.org
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
/* ===== КЛЮЧИ ХРАНИЛИЩА ===== */
const MY_ID_KEY = 'rem_user_id_v1';
const MY_INV_KEY = 'rem_inventory_covers_v1';
const STORAGE_FIX_KEY = 'rem_script_fix_active';
/* ===== КЛЮЧИ НАСТРОЕК (GM_getValue/GM_setValue) ===== */
const SETTINGS_KEYS = {
onlineStatus: 'rem_set_online_status',
ownBadge: 'rem_set_own_badge',
cardStats: 'rem_set_card_stats',
titleProgress: 'rem_set_title_progress',
profileStats: 'rem_set_profile_stats',
fixMode: 'rem_set_fix_mode',
customBgEnabled: 'rem_set_bg_enabled',
customBgOpacity: 'rem_set_bg_opacity',
customBgBlur: 'rem_set_bg_blur',
customBgFit: 'rem_set_bg_fit',
themeAccent: 'rem_set_theme_accent',
themeButtonBg: 'rem_set_theme_btn_bg',
themeNameColor: 'rem_set_theme_name_color',
themeNameFont: 'rem_set_theme_name_font',
themeMenuBg: 'rem_set_theme_menu_bg',
themeSiteBg: 'rem_set_theme_site_bg',
};
/* URL фона храним в localStorage (может быть очень большим base64) */
const BG_URL_LS_KEY = 'rem_bg_url_data';
/* Значения по умолчанию */
const DEFAULTS = {
onlineStatus: true,
ownBadge: true,
cardStats: true,
titleProgress: true,
profileStats: true,
fixMode: false,
customBgEnabled: false,
customBgOpacity: 80,
customBgBlur: 0,
customBgFit: 'cover',
themeAccent: '',
themeButtonBg: '',
themeNameColor: '',
themeNameFont: '',
themeMenuBg: '',
themeSiteBg: '',
};
/* Темы-пресеты */
const THEME_PRESETS = [
{ name: 'По умолчанию', value: '' },
{ name: '🔵 Синий', value: '#3b82f6' },
{ name: '🟣 Фиолетовый', value: '#a855f7' },
{ name: '🟢 Зелёный', value: '#22c55e' },
{ name: '🔴 Красный', value: '#ef4444' },
{ name: '🟠 Оранжевый', value: '#f97316' },
{ name: '🌸 Розовый', value: '#ec4899' },
{ name: '🌊 Бирюзовый', value: '#06b6d4' },
{ name: '🌟 Золотой', value: '#eab308' },
];
/* Кэш настроек в памяти */
const cfg = {};
function loadSettings() {
for (const [k, dflt] of Object.entries(DEFAULTS)) {
cfg[k] = GM_getValue(SETTINGS_KEYS[k], dflt);
}
/* URL читаем из localStorage */
cfg.customBgUrl = localStorage.getItem(BG_URL_LS_KEY) || '';
}
function saveSetting(key, value) {
cfg[key] = value;
if (key === 'customBgUrl') {
/* Большие base64 храним в localStorage */
try { localStorage.setItem(BG_URL_LS_KEY, value); } catch (e) { console.warn('REM BG: localStorage переполнен'); }
} else {
GM_setValue(SETTINGS_KEYS[key], value);
}
}
loadSettings();
/* ===== ПРОЧИЕ КОНСТАНТЫ ===== */
let activeObserver = null;
let isCalculating = false;
let lastSlug = '';
const RANK_ORDER = ['rank_f', 'rank_e', 'rank_d', 'rank_c', 'rank_b', 'rank_a', 'rank_s', 'rank_re'];
const RANK_MAP = {
'rank_f': { name: 'F', color: '#9ca3af' },
'rank_e': { name: 'E', color: '#9ca3af' },
'rank_d': { name: 'D', color: '#ffffff' },
'rank_c': { name: 'C', color: '#4ade80' },
'rank_b': { name: 'B', color: '#60a5fa' },
'rank_a': { name: 'A', color: '#c084fc' },
'rank_s': { name: 'S', color: '#fbbf24' },
'rank_re': { name: 'RE', color: '#f59e0b' },
};
/* ===== СТИЛИ ===== */
const STYLES = `
.rem-online-dot {
position:absolute!important;top:2px!important;right:2px!important;
width:11px;height:11px;border-radius:50%;border:2px solid #000;
z-index:101;pointer-events:none;
}
.rem-online-dot.online{background-color:#22c55e!important;}
.rem-online-dot.offline{background-color:#ef4444!important;}
.rem-own-badge {
position:absolute!important;bottom:5px!important;left:5px!important;z-index:50!important;
width:22px;height:22px;border-radius:50%;
background-color:#22c55e;color:#fff;
display:flex;align-items:center;justify-content:center;
border:2px solid #fff;pointer-events:none;
opacity:0;animation:remFadeIn .3s forwards;box-shadow:none!important;
}
.rem-own-badge svg{width:14px;height:14px;stroke-width:3;}
@keyframes remFadeIn{from{opacity:0;transform:scale(.5)}to{opacity:1;transform:scale(1)}}
.rem-toggle {
display:inline-flex;align-items:center;justify-content:center;
margin-left:10px;padding:2px 8px;border-radius:6px;
font-size:11px;font-weight:800;text-transform:uppercase;
cursor:pointer;border:1px solid #3f3f46;color:#fff;height:22px;vertical-align:middle;
transition:all .2s;
}
.rem-toggle.on{background-color:#15803d;border-color:#22c55e;}
.rem-toggle.off{background-color:transparent;color:#71717a;border-color:#3f3f46;}
.rem-toggle:hover{filter:brightness(1.2);}
#rem-loader-status {
position:fixed;bottom:20px;left:70px;z-index:999999;
background:#18181b;color:#fff;padding:8px 16px;border-radius:20px;
border:1px solid #27272a;font-size:13px;font-weight:500;
box-shadow:0 4px 15px rgba(0,0,0,.6);display:none;align-items:center;gap:10px;
}
#rem-loader-status.active{display:flex;animation:slideIn .3s;}
@keyframes slideIn{from{opacity:0;transform:translateX(-20px)}to{opacity:1;transform:translateX(0)}}
html.rem-checking body>*:not(#rem-loader){display:none!important;}
#rem-loader{position:fixed;inset:0;background:#0c0c0c;z-index:999999;display:flex;align-items:center;justify-content:center;color:#666;font-family:sans-serif;}
body.rem-active main>*:not(#rem-inject){display:none!important;}
#rem-inject{width:100%;max-width:650px;margin:0 auto;min-height:80vh;font-family:system-ui,-apple-system,sans-serif;padding:10px;}
.rem-grid{display:grid;grid-template-columns:repeat(4,1fr)!important;gap:10px;padding-bottom:40px;}
.rem-card{position:relative;aspect-ratio:2/3;background:#18181b;border:1px solid #27272a;border-radius:6px;overflow:hidden;cursor:pointer;transition:.2s;display:flex;}
.rem-card:hover{border-color:#71717a;}
.rem-card img{width:100%;height:100%;object-fit:cover;}
.rem-fix-header{margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid #27272a;}
.rem-fix-title{font-size:24px;font-weight:700;color:#fff;margin:0 0 4px 0;}
.rem-fix-meta{color:#a1a1aa;font-size:13px;display:flex;align-items:center;margin-top:8px;}
.rem-sep{margin:0 8px;opacity:.5;}
.rem-over{position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px);animation:F .2s;}
.rem-box{position:relative;width:90%;max-width:450px;background:#0c0c0c;border:1px solid #27272a;border-radius:16px;padding:24px;display:flex;flex-direction:column;gap:20px;animation:Z .2s;}
@keyframes F{from{opacity:0}to{opacity:1}} @keyframes Z{from{transform:scale(.95)}to{transform:scale(1)}}
.rem-box-img{width:180px;aspect-ratio:2/3;margin:10px auto 0;border-radius:12px;overflow:hidden;border:1px solid #27272a;}
.rem-box-img img{width:100%;height:100%;object-fit:cover;}
.rem-info{text-align:center;display:flex;flex-direction:column;gap:4px;}
.rem-lnk-m{color:#fff;font-size:14px;font-weight:500;text-decoration:none;opacity:.9;display:inline-block;}
.rem-lnk-c{color:#fff;font-size:20px;font-weight:700;text-decoration:none;display:inline-block;}
.rem-acts{display:flex;justify-content:center;gap:12px;margin-top:5px;}
.rem-pill{height:36px;padding:0 16px;border-radius:99px;background:#27272a;color:#fff;border:none;display:flex;align-items:center;gap:6px;font-size:14px;font-weight:500;}
.rem-sub{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:5px;}
.rem-s-btn{height:36px;padding:0 16px;border-radius:99px;background:#27272a;color:#fff;text-decoration:none;font-size:14px;font-weight:500;display:flex;align-items:center;}
.rem-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;background:#27272a;color:#fff;border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;}
/* === КНОПКА ШЕСТЕРЁНКИ === */
#rem-cfg-btn {
position:fixed;bottom:20px;left:20px;z-index:999999;
width:40px;height:40px;border-radius:50%;
background:#18181b;border:1px solid #27272a;color:#a1a1aa;
display:flex;align-items:center;justify-content:center;
cursor:pointer;transition:.2s;box-shadow:0 4px 10px rgba(0,0,0,.5);
font-size:18px;
}
#rem-cfg-btn:hover{color:#fff;border-color:#71717a;transform:rotate(45deg);}
/* === ПАНЕЛЬ НАСТРОЕК === */
#rem-settings-panel {
position:fixed;bottom:70px;left:20px;z-index:999998;
width:300px;background:#18181b;border:1px solid #27272a;
border-radius:16px;padding:0;overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.7);
transform:scale(.95) translateY(10px);opacity:0;
transition:transform .2s, opacity .2s;
pointer-events:none;
}
#rem-settings-panel.open {
transform:scale(1) translateY(0);opacity:1;pointer-events:all;
}
.rem-panel-header {
padding:14px 16px 12px;border-bottom:1px solid #27272a;
display:flex;align-items:center;justify-content:space-between;
}
.rem-panel-title {
font-size:15px;font-weight:700;color:#fff;display:flex;align-items:center;gap:8px;
}
.rem-panel-title span.badge {
font-size:10px;background:#27272a;color:#71717a;
padding:2px 6px;border-radius:4px;font-weight:600;
}
.rem-panel-body { padding:8px 0; max-height:70vh; overflow-y:auto; scrollbar-width:thin; scrollbar-color:#3f3f46 transparent; }
.rem-panel-body::-webkit-scrollbar { width:4px; }
.rem-panel-body::-webkit-scrollbar-thumb { background:#3f3f46; border-radius:2px; }
.rem-section-label {
font-size:10px;font-weight:700;color:#52525b;text-transform:uppercase;
letter-spacing:.08em;padding:8px 16px 4px;
}
.rem-row {
display:flex;align-items:center;justify-content:space-between;
padding:9px 16px;cursor:default;transition:background .15s;
gap:10px;
}
.rem-row:hover{ background:rgba(255,255,255,.04); }
.rem-row-info { display:flex;flex-direction:column;gap:1px;flex:1;min-width:0; }
.rem-row-name { font-size:13px;color:#e4e4e7;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.rem-row-desc { font-size:11px;color:#71717a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
/* Toggle switch */
.rem-sw { position:relative;width:36px;height:20px;flex-shrink:0;cursor:pointer; }
.rem-sw input { opacity:0;width:0;height:0;position:absolute; }
.rem-sw-track {
position:absolute;inset:0;border-radius:10px;background:#3f3f46;
transition:background .2s;
}
.rem-sw input:checked + .rem-sw-track { background:#16a34a; }
.rem-sw-thumb {
position:absolute;width:14px;height:14px;border-radius:50%;background:#fff;
top:3px;left:3px;transition:left .2s;pointer-events:none;
}
.rem-sw input:checked ~ .rem-sw-thumb { left:19px; }
.rem-panel-footer {
border-top:1px solid #27272a;padding:10px 16px;
display:flex;gap:8px;flex-wrap:wrap;
}
.rem-action-btn {
flex:1;min-width:0;height:32px;border-radius:8px;border:1px solid #3f3f46;
background:transparent;color:#a1a1aa;font-size:11px;font-weight:600;
cursor:pointer;transition:all .15s;white-space:nowrap;
padding:0 4px;overflow:hidden;text-overflow:ellipsis;
}
.rem-action-btn:hover{ background:#27272a;color:#fff;border-color:#52525b; }
.rem-action-btn.danger:hover{ background:#450a0a;color:#f87171;border-color:#7f1d1d; }
/* === ПРОЧИЕ БЕЙДЖИ === */
.rem-profile-stat-badge {
margin-top:6px;display:inline-flex;align-items:center;justify-content:center;gap:6px;
background-color:#18181b;border:1px solid #27272a;padding:4px 10px;border-radius:6px;
color:#a1a1aa;font-size:12px;font-weight:600;width:fit-content;align-self:center;
}
.rem-profile-stat-badge strong{color:#22c55e;margin-left:4px;}
.rem-counting strong{color:#eab308;}
.rem-progress-box{width:100%;margin:15px 0;background:#18181b;border:1px solid #27272a;border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:8px;}
.rem-progress-header{display:flex;justify-content:space-between;align-items:center;color:#fff;font-size:14px;font-weight:600;}
.rem-progress-text span{color:#22c55e;}
.rem-progress-track{width:100%;height:8px;background:#27272a;border-radius:4px;overflow:hidden;margin-bottom:4px;}
.rem-progress-fill{height:100%;background:linear-gradient(90deg,#22c55e,#4ade80);border-radius:4px;width:0%;transition:width .5s ease-out;}
.rem-rank-stats{display:flex;flex-wrap:nowrap;overflow-x:auto;gap:6px;margin-top:8px;padding-bottom:2px;-webkit-overflow-scrolling:touch;scrollbar-width:none;}
.rem-rank-stats::-webkit-scrollbar{display:none;}
.rem-rank-badge{display:inline-flex;align-items:center;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:700;border:1px solid;background:rgba(0,0,0,.3);flex-shrink:0;white-space:nowrap;}
.rem-card-stats-row{display:flex;gap:8px;margin-top:10px;justify-content:center;width:100%;flex-wrap:wrap;}
.rem-stat-bubble{background:#18181b;border:1px solid #27272a;border-radius:10px;padding:6px 10px;display:flex;flex-direction:column;align-items:center;min-width:75px;transition:.2s;}
.rem-stat-bubble:hover{border-color:#3f3f46;}
.rem-stat-num{font-size:16px;font-weight:800;line-height:1;}
.rem-stat-lab{font-size:9px;color:#71717a;text-transform:uppercase;margin-top:4px;font-weight:600;}
.rem-stat-num.loading{color:#3f3f46;}
.rem-stat-bubble.owners .rem-stat-num{color:#22c55e;}
.rem-stat-bubble.wants .rem-stat-num{color:#3b82f6;}
.rem-stat-bubble.sales .rem-stat-num{color:#f59e0b;}
/* Кастомный фон */
#rem-custom-bg {
position:fixed;top:0;left:0;width:100%;height:100vh;
object-fit:cover;object-position:center center;
z-index:-9;pointer-events:none;
will-change:transform;transform:translateZ(0);
}
/* Слайдеры и инпуты в панели */
.rem-slider {
-webkit-appearance:none;appearance:none;
width:100%;height:4px;border-radius:2px;
background:#3f3f46;outline:none;cursor:pointer;
}
.rem-slider::-webkit-slider-thumb {
-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#22c55e;cursor:pointer;
}
.rem-slider::-moz-range-thumb {
width:14px;height:14px;border-radius:50%;background:#22c55e;cursor:pointer;border:none;
}
.rem-url-input {
width:100%;background:#0c0c0c;border:1px solid #3f3f46;border-radius:8px;
color:#e4e4e7;font-size:12px;padding:6px 10px;box-sizing:border-box;
outline:none;transition:border-color .15s;
}
.rem-url-input:focus { border-color:#22c55e; }
.rem-url-input::placeholder { color:#52525b; }
.rem-sub-row {
padding:4px 16px 8px;
display:flex;flex-direction:column;gap:6px;
}
.rem-sub-row-label {
font-size:11px;color:#71717a;
display:flex;justify-content:space-between;align-items:center;
}
.rem-upload-btn {
width:100%;height:30px;border-radius:8px;border:1px dashed #3f3f46;
background:transparent;color:#71717a;font-size:12px;font-weight:600;
cursor:pointer;transition:all .15s;
}
.rem-upload-btn:hover { border-color:#52525b;color:#a1a1aa;background:#27272a; }
.rem-select {
background:#0c0c0c;border:1px solid #3f3f46;border-radius:8px;
color:#e4e4e7;font-size:12px;padding:4px 8px;
outline:none;cursor:pointer;
}
`;
const styleEl = document.createElement('style');
styleEl.textContent = STYLES;
(document.head || document.documentElement).appendChild(styleEl);
/* ===== MANAGER ===== */
const Manager = {
covers: new Set(),
userId: null,
loaded: false,
cacheMap: new Map(),
onlineCache: new Map(),
onlineProcessing: new Set(),
async init() {
this.createUI();
this.userId = localStorage.getItem(MY_ID_KEY);
if (!this.userId) { this.promptId(); return; }
const cache = localStorage.getItem(MY_INV_KEY);
if (cache) {
const d = JSON.parse(cache);
this.covers = new Set(d.data);
this.loaded = true;
if (Date.now() - d.time > 1200000) {
this.sync(true); // Запускаем тихое обновление, если кэш старше 20 минут
}
} else {
await this.sync();
}
setInterval(() => {
this.sync(true);
}, 1200000); // 20 минут
},
getFilename(url) { if (!url) return null; return url.split('/').pop().split('?')[0]; },
async sync(silent = false) {
if (!this.userId) return;
if (this.syncing) return;
this.syncing = true;
if (!silent) this.showStatus("Обновление...", true);
let page = 1, run = true;
const temp = new Set();
while (run) {
const url = `https://api.remanga.org/api/v2/inventory/${this.userId}/?count=100&ordering=rank&page=${page}&type=cards`;
if (!silent) this.showStatus(`Загрузка стр. ${page} (Найдено: ${temp.size})...`, true);
const data = await this.req(url);
if (!data) break;
const list = data.results || [];
if (!list.length) break;
list.forEach(item => {
if (item.card && item.card.cover) {
if (item.card.cover.high) temp.add(this.getFilename(item.card.cover.high));
if (item.card.cover.mid) temp.add(this.getFilename(item.card.cover.mid));
}
});
if (!data.next) run = false;
else { page++; await new Promise(r => setTimeout(r, 100)); }
}
this.covers = temp;
this.loaded = true;
localStorage.setItem(MY_INV_KEY, JSON.stringify({ time: Date.now(), data: Array.from(temp) }));
if (!silent) {
this.showStatus(`Готово!`, true);
setTimeout(() => this.showStatus("", false), 2000);
}
scanVisual();
this.syncing = false;
},
req(url) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: "GET", url, headers: { "Accept": "application/json" },
onload: r => { if (r.status == 200) try { resolve(JSON.parse(r.responseText)) } catch { resolve(null) } else resolve(null) },
onerror: () => resolve(null)
});
});
},
async getUserInfo(uid) {
if (this.cacheMap.has(uid)) return this.cacheMap.get(uid);
const data = await this.req(`https://api.remanga.org/api/v2/users/${uid}/`);
if (data) this.cacheMap.set(uid, data);
return data;
},
has(filename) { return this.loaded && filename && this.covers.has(filename); },
promptId() {
const link = prompt("Remanga Fix: Вставьте ссылку на профиль:", "");
if (link) {
const m = link.match(/user\/(\d+)/);
if (m && m[1]) { localStorage.setItem(MY_ID_KEY, m[1]); this.userId = m[1]; this.sync(); }
}
},
showStatus(text, show) {
const el = document.getElementById('rem-loader-status');
if (!el) return;
if (show) { el.innerText = text; el.classList.add('active'); } else el.classList.remove('active');
},
createUI() {
if (document.getElementById('rem-cfg-btn')) return;
/* Применить кастомный фон если включён */
applyCustomBackground();
/* Применить тему */
applyTheme();
/* Кнопка шестерёнки */
const btn = document.createElement('div');
btn.id = 'rem-cfg-btn';
btn.innerHTML = '⚙️';
btn.title = 'Настройки расширения';
btn.onclick = (e) => { e.stopPropagation(); togglePanel(); };
document.body.appendChild(btn);
/* Статус загрузки */
const stat = document.createElement('div');
stat.id = 'rem-loader-status';
document.body.appendChild(stat);
/* Панель настроек */
document.body.appendChild(buildSettingsPanel());
/* Закрытие при клике вне */
document.addEventListener('click', (e) => {
const panel = document.getElementById('rem-settings-panel');
const cfgBtn = document.getElementById('rem-cfg-btn');
if (panel && !panel.contains(e.target) && e.target !== cfgBtn) {
panel.classList.remove('open');
}
});
}
};
/* ===== ПОСТРОЕНИЕ ПАНЕЛИ НАСТРОЕК ===== */
function makeSwitchRow(key, name, desc) {
const row = document.createElement('div');
row.className = 'rem-row';
const info = document.createElement('div');
info.className = 'rem-row-info';
info.innerHTML = `<div class="rem-row-name">${name}</div>${desc ? `<div class="rem-row-desc">${desc}</div>` : ''}`;
const label = document.createElement('label');
label.className = 'rem-sw';
label.title = cfg[key] ? 'Включено' : 'Выключено';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = cfg[key];
const track = document.createElement('div');
track.className = 'rem-sw-track';
const thumb = document.createElement('div');
thumb.className = 'rem-sw-thumb';
label.appendChild(input);
label.appendChild(track);
label.appendChild(thumb);
input.addEventListener('change', () => {
saveSetting(key, input.checked);
label.title = input.checked ? 'Включено' : 'Выключено';
});
row.appendChild(info);
row.appendChild(label);
return row;
}
function buildSettingsPanel() {
const panel = document.createElement('div');
panel.id = 'rem-settings-panel';
/* Шапка */
panel.innerHTML = `
<div class="rem-panel-header">
<div class="rem-panel-title">⚙️ Настройки <span class="badge" style="font-size:10px;color:#71717a">v5.2.0</span></div>
</div>
`;
const body = document.createElement('div');
body.className = 'rem-panel-body';
/* --- Раздел: Пользователи --- */
const lbl1 = document.createElement('div');
lbl1.className = 'rem-section-label';
lbl1.textContent = 'Пользователи';
body.appendChild(lbl1);
body.appendChild(makeSwitchRow('onlineStatus', '🟢 Онлайн-статус', 'Точка на аватарке пользователя'));
body.appendChild(makeSwitchRow('profileStats', '🎴 Статистика профиля', 'Кол-во созданных карт на профиле'));
/* --- Раздел: Карты --- */
const lbl2 = document.createElement('div');
lbl2.className = 'rem-section-label';
lbl2.textContent = 'Карты';
body.appendChild(lbl2);
body.appendChild(makeSwitchRow('ownBadge', '✅ Метка коллекции', 'Галочка на картах из вашей коллекции'));
body.appendChild(makeSwitchRow('cardStats', '📊 Статистика карты', 'Владельцы / Хотят / Продают в диалоге'));
body.appendChild(makeSwitchRow('titleProgress', '📈 Прогресс тайтла', 'Прогресс-бар на странице карт манги'));
/* --- Раздел: Фон сайта --- */
const lbl3 = document.createElement('div');
lbl3.className = 'rem-section-label';
lbl3.textContent = 'Фон сайта';
body.appendChild(lbl3);
/* Переключатель включения */
const bgToggleRow = makeSwitchRow('customBgEnabled', '🖼️ Кастомный фон', 'Своё видео, гифка или фото');
body.appendChild(bgToggleRow);
/* Блок настроек фона */
const bgSub = document.createElement('div');
bgSub.className = 'rem-sub-row';
bgSub.id = 'rem-bg-sub';
bgSub.style.display = cfg.customBgEnabled ? 'flex' : 'none';
/* URL ввод */
const urlLabel = document.createElement('div');
urlLabel.className = 'rem-sub-row-label';
urlLabel.textContent = 'URL (видео .webm/.mp4, гифка, фото)';
bgSub.appendChild(urlLabel);
const urlInput = document.createElement('input');
urlInput.type = 'text';
urlInput.className = 'rem-url-input';
urlInput.placeholder = 'https://... или вставьте ссылку';
urlInput.value = cfg.customBgUrl || '';
urlInput.addEventListener('change', () => {
saveSetting('customBgUrl', urlInput.value.trim());
applyCustomBackground();
});
bgSub.appendChild(urlInput);
/* Кнопка загрузки файла */
const uploadBtn = document.createElement('button');
uploadBtn.className = 'rem-upload-btn';
uploadBtn.textContent = '📂 Загрузить файл с компьютера';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*,video/*,.gif,.webm,.mp4,.webp';
fileInput.style.display = 'none';
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result;
saveSetting('customBgUrl', dataUrl);
urlInput.value = '(локальный файл: ' + file.name + ')';
applyCustomBackground();
};
reader.readAsDataURL(file);
});
uploadBtn.onclick = () => fileInput.click();
bgSub.appendChild(fileInput);
bgSub.appendChild(uploadBtn);
/* Слайдер прозрачности */
const opacLabel = document.createElement('div');
opacLabel.className = 'rem-sub-row-label';
const opacVal = document.createElement('span');
opacVal.textContent = cfg.customBgOpacity + '%';
opacLabel.innerHTML = 'Яркость фона: ';
opacLabel.appendChild(opacVal);
bgSub.appendChild(opacLabel);
const opacSlider = document.createElement('input');
opacSlider.type = 'range';
opacSlider.className = 'rem-slider';
opacSlider.min = 10; opacSlider.max = 100; opacSlider.step = 5;
opacSlider.value = cfg.customBgOpacity;
opacSlider.addEventListener('input', () => {
opacVal.textContent = opacSlider.value + '%';
saveSetting('customBgOpacity', Number(opacSlider.value));
applyCustomBackground();
});
bgSub.appendChild(opacSlider);
/* Слайдер блюра */
const blurLabel = document.createElement('div');
blurLabel.className = 'rem-sub-row-label';
const blurVal = document.createElement('span');
blurVal.textContent = cfg.customBgBlur + 'px';
blurLabel.innerHTML = 'Размытие: ';
blurLabel.appendChild(blurVal);
bgSub.appendChild(blurLabel);
const blurSlider = document.createElement('input');
blurSlider.type = 'range';
blurSlider.className = 'rem-slider';
blurSlider.min = 0; blurSlider.max = 20; blurSlider.step = 1;
blurSlider.value = cfg.customBgBlur;
blurSlider.addEventListener('input', () => {
blurVal.textContent = blurSlider.value + 'px';
saveSetting('customBgBlur', Number(blurSlider.value));
applyCustomBackground();
});
bgSub.appendChild(blurSlider);
/* Выбор масштабирования */
const fitLabel = document.createElement('div');
fitLabel.className = 'rem-sub-row-label';
fitLabel.textContent = 'Масштаб:';
const fitSelect = document.createElement('select');
fitSelect.className = 'rem-select';
[['cover', 'Заполнить'], ['contain', 'Вписать'], ['fill', 'Растянуть']].forEach(([v, t]) => {
const opt = document.createElement('option');
opt.value = v; opt.textContent = t;
if (cfg.customBgFit === v) opt.selected = true;
fitSelect.appendChild(opt);
});
fitSelect.addEventListener('change', () => {
saveSetting('customBgFit', fitSelect.value);
applyCustomBackground();
});
fitLabel.appendChild(fitSelect);
bgSub.appendChild(fitLabel);
/* Кнопка сброса фона */
const resetBgBtn = document.createElement('button');
resetBgBtn.className = 'rem-upload-btn';
resetBgBtn.style.borderStyle = 'solid';
resetBgBtn.textContent = '🗑️ Убрать фон';
resetBgBtn.onclick = () => {
saveSetting('customBgUrl', '');
saveSetting('customBgEnabled', false);
urlInput.value = '';
const sw = bgToggleRow.querySelector('input[type=checkbox]');
if (sw) sw.checked = false;
document.getElementById('rem-bg-sub').style.display = 'none';
applyCustomBackground();
};
bgSub.appendChild(resetBgBtn);
body.appendChild(bgSub);
/* Показ/скрытие настроек при переключении */
bgToggleRow.querySelector('input').addEventListener('change', function () {
document.getElementById('rem-bg-sub').style.display = this.checked ? 'flex' : 'none';
if (this.checked) applyCustomBackground();
else removeCustomBackground();
});
/* --- Раздел: Оформление --- */
const lblTheme = document.createElement('div');
lblTheme.className = 'rem-section-label';
lblTheme.textContent = 'Оформление сайта';
body.appendChild(lblTheme);
const themeRow = document.createElement('div');
themeRow.style.cssText = 'padding:4px 16px 8px;display:flex;flex-direction:column;gap:8px;';
const themeLabel = document.createElement('div');
themeLabel.style.cssText = 'font-size:11px;color:#71717a;';
themeLabel.textContent = 'Цвет кнопок (Основа):';
themeRow.appendChild(themeLabel);
/* Сетка цветовых пресетов (Для кнопок) */
const presetGrid = document.createElement('div');
presetGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;';
THEME_PRESETS.forEach(preset => {
const btn = document.createElement('button');
btn.title = preset.name;
btn.style.cssText = `
width:22px;height:22px;border-radius:50%;cursor:pointer;
border:2px solid ${cfg.themeButtonBg === preset.value ? '#fff' : 'transparent'};
background:${preset.value || '#3f3f46'};
transition:all .15s;outline:none;flex-shrink:0;
`;
if (!preset.value) {
btn.textContent = '✕';
btn.style.fontSize = '10px';
btn.style.color = '#a1a1aa';
}
btn.onclick = () => {
saveSetting('themeButtonBg', preset.value);
applyTheme();
presetGrid.querySelectorAll('button').forEach((b, i) => {
b.style.borderColor = THEME_PRESETS[i].value === preset.value ? '#fff' : 'transparent';
});
customBtnInput.value = preset.value || '#3b82f6';
};
presetGrid.appendChild(btn);
});
themeRow.appendChild(presetGrid);
/* Произвольный цвет кнопок */
const customBtnRow = document.createElement('div');
customBtnRow.style.cssText = 'display:flex;align-items:center;gap:8px;';
const customBtnLabel = document.createElement('span');
customBtnLabel.style.cssText = 'font-size:11px;color:#71717a;white-space:nowrap;width:100px;';
customBtnLabel.textContent = 'Свой цвет кнопок:';
const customBtnInput = document.createElement('input');
customBtnInput.type = 'color';
customBtnInput.value = cfg.themeButtonBg || '#3b82f6';
customBtnInput.style.cssText = 'width:32px;height:24px;border:1px solid #3f3f46;border-radius:6px;background:#0c0c0c;cursor:pointer;padding:2px;';
const customBtnReset = document.createElement('button');
customBtnReset.innerHTML = '✕';
customBtnReset.style.cssText = 'background:none;border:none;color:#a1a1aa;cursor:pointer;font-size:12px;outline:none;';
customBtnReset.title = 'Сбросить цвет кнопок';
customBtnReset.onclick = () => { saveSetting('themeButtonBg', ''); customBtnInput.value = '#3b82f6'; applyTheme(); presetGrid.querySelectorAll('button').forEach(b => b.style.borderColor = 'transparent'); };
customBtnInput.addEventListener('input', () => {
saveSetting('themeButtonBg', customBtnInput.value);
applyTheme();
presetGrid.querySelectorAll('button').forEach((b, i) => {
b.style.borderColor = THEME_PRESETS[i].value === customBtnInput.value ? '#fff' : 'transparent';
});
});
customBtnRow.appendChild(customBtnLabel);
customBtnRow.appendChild(customBtnInput);
customBtnRow.appendChild(customBtnReset);
themeRow.appendChild(customBtnRow);
/* Свой акцент (прогресс, тумблеры) */
const customAccentRow = document.createElement('div');
customAccentRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:4px;';
const customAccentLabel = document.createElement('span');
customAccentLabel.style.cssText = 'font-size:11px;color:#71717a;white-space:nowrap;width:100px;';
customAccentLabel.textContent = 'Цвет акцентов:';
const customAccentInput = document.createElement('input');
customAccentInput.type = 'color';
customAccentInput.value = cfg.themeAccent || '#3b82f6';
customAccentInput.style.cssText = 'width:32px;height:24px;border:1px solid #3f3f46;border-radius:6px;background:#0c0c0c;cursor:pointer;padding:2px;';
const customAccentReset = document.createElement('button');
customAccentReset.innerHTML = '✕';
customAccentReset.style.cssText = 'background:none;border:none;color:#a1a1aa;cursor:pointer;font-size:12px;outline:none;';
customAccentReset.title = 'Сбросить цвет акцентов';
customAccentReset.onclick = () => { saveSetting('themeAccent', ''); customAccentInput.value = '#3b82f6'; applyTheme(); };
customAccentInput.addEventListener('input', () => {
saveSetting('themeAccent', customAccentInput.value);
applyTheme();
});
customAccentRow.appendChild(customAccentLabel);
customAccentRow.appendChild(customAccentInput);
customAccentRow.appendChild(customAccentReset);
themeRow.appendChild(customAccentRow);
/* Имя профиля: Цвет */
const nameColorRow = document.createElement('div');
nameColorRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:4px;';
const nameColorLabel = document.createElement('span');
nameColorLabel.style.cssText = 'font-size:11px;color:#71717a;white-space:nowrap;width:100px;';
nameColorLabel.textContent = 'Цвет имени:';
const nameColorInput = document.createElement('input');
nameColorInput.type = 'color';
nameColorInput.value = cfg.themeNameColor || '#ffffff';
nameColorInput.style.cssText = 'width:32px;height:24px;border:1px solid #3f3f46;border-radius:6px;background:#0c0c0c;cursor:pointer;padding:2px;';
const nameColorReset = document.createElement('button');
nameColorReset.innerHTML = '✕';
nameColorReset.style.cssText = 'background:none;border:none;color:#a1a1aa;cursor:pointer;font-size:12px;outline:none;';
nameColorReset.title = 'Сбросить цвет имени';
nameColorReset.onclick = () => { saveSetting('themeNameColor', ''); nameColorInput.value = '#ffffff'; applyTheme(); };
nameColorInput.addEventListener('input', () => { saveSetting('themeNameColor', nameColorInput.value); applyTheme(); });
nameColorRow.appendChild(nameColorLabel);
nameColorRow.appendChild(nameColorInput);
nameColorRow.appendChild(nameColorReset);
themeRow.appendChild(nameColorRow);
/* Имя профиля: Шрифт */
const nameFontRow = document.createElement('div');
nameFontRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:4px;';
const nameFontLabel = document.createElement('span');
nameFontLabel.style.cssText = 'font-size:11px;color:#71717a;white-space:nowrap;width:100px;';
nameFontLabel.textContent = 'Шрифт имени:';
const nameFontInput = document.createElement('select');
nameFontInput.style.cssText = 'flex:1;height:24px;border:1px solid #3f3f46;border-radius:6px;background:#0c0c0c;color:#fff;padding:0 6px;font-size:12px;outline:none;cursor:pointer;';
const fonts = [
{ v: '', t: 'По умолчанию' },
{ v: 'Comic Sans MS', t: 'Comic Sans' },
{ v: 'Caveat', t: 'Caveat (Рукописный)' },
{ v: 'Comfortaa', t: 'Comfortaa (Округлый)' },
{ v: 'Lobster', t: 'Lobster (Объемный)' },
{ v: 'Pacifico', t: 'Pacifico (Винтаж)' },
{ v: 'Oswald', t: 'Oswald (Строгий)' },
{ v: 'Press Start 2P', t: 'Пиксельный (8-bit)' },
{ v: 'Marmelad', t: 'Marmelad (Плавный)' },
{ v: 'Russo One', t: 'Russo One (Жирный/Квадратный)' },
{ v: 'Jura', t: 'Jura (Техно)' },
{ v: 'Marck Script', t: 'Marck Script (Каллиграфия)' },
{ v: 'Philosopher', t: 'Philosopher (Изящный)' },
{ v: 'Amatic SC', t: 'Amatic SC (Рисованный узкий)' },
{ v: 'Neucha', t: 'Neucha (Веселый)' },
{ v: 'Underdog', t: 'Underdog (Необычный)' }
];
fonts.forEach(f => {
const opt = document.createElement('option');
opt.value = opt.textContent = f.v;
opt.textContent = f.t;
if (cfg.themeNameFont === f.v) opt.selected = true;
nameFontInput.appendChild(opt);
});
const nameFontReset = document.createElement('button');
nameFontReset.innerHTML = '✕';
nameFontReset.style.cssText = 'background:none;border:none;color:#a1a1aa;cursor:pointer;font-size:12px;outline:none;';
nameFontReset.title = 'Сбросить шрифт имени';
nameFontReset.onclick = () => { saveSetting('themeNameFont', ''); nameFontInput.value = ''; applyTheme(); };
nameFontInput.addEventListener('change', () => { saveSetting('themeNameFont', nameFontInput.value); applyTheme(); });
nameFontRow.appendChild(nameFontLabel);
nameFontRow.appendChild(nameFontInput);
nameFontRow.appendChild(nameFontReset);
themeRow.appendChild(nameFontRow);
/* Фон меню и панелей */
const menuBgRow = document.createElement('div');
menuBgRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:4px;';
const menuBgLabel = document.createElement('span');
menuBgLabel.style.cssText = 'font-size:11px;color:#71717a;white-space:nowrap;flex:1;';
menuBgLabel.textContent = 'Фон меню и карточек:';
const menuBgInput = document.createElement('input');
menuBgInput.type = 'color';
menuBgInput.value = cfg.themeMenuBg || '#18181b';
menuBgInput.style.cssText = 'width:32px;height:24px;border:1px solid #3f3f46;border-radius:6px;background:#0c0c0c;cursor:pointer;padding:2px;';
const menuBgReset = document.createElement('button');
menuBgReset.innerHTML = '✕';
menuBgReset.style.cssText = 'background:none;border:none;color:#a1a1aa;cursor:pointer;font-size:12px;outline:none;';
menuBgReset.title = 'Сбросить фон меню';
menuBgReset.onclick = () => { saveSetting('themeMenuBg', ''); menuBgInput.value = '#18181b'; applyTheme(); };
menuBgInput.addEventListener('input', () => { saveSetting('themeMenuBg', menuBgInput.value); applyTheme(); });
menuBgRow.appendChild(menuBgLabel);
menuBgRow.appendChild(menuBgInput);
menuBgRow.appendChild(menuBgReset);
themeRow.appendChild(menuBgRow);
/* Фон сайта */
const siteBgRow = document.createElement('div');
siteBgRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:4px;';
const siteBgLabel = document.createElement('span');
siteBgLabel.style.cssText = 'font-size:11px;color:#71717a;white-space:nowrap;flex:1;';
siteBgLabel.textContent = 'Фон сайта (без картинки):';
const siteBgInput = document.createElement('input');
siteBgInput.type = 'color';
siteBgInput.value = cfg.themeSiteBg || '#09090b';
siteBgInput.style.cssText = 'width:32px;height:24px;border:1px solid #3f3f46;border-radius:6px;background:#0c0c0c;cursor:pointer;padding:2px;';
const siteBgReset = document.createElement('button');
siteBgReset.innerHTML = '✕';
siteBgReset.style.cssText = 'background:none;border:none;color:#a1a1aa;cursor:pointer;font-size:12px;outline:none;';
siteBgReset.title = 'Сбросить фон сайта';
siteBgReset.onclick = () => { saveSetting('themeSiteBg', ''); siteBgInput.value = '#09090b'; applyTheme(); };
siteBgInput.addEventListener('input', () => { saveSetting('themeSiteBg', siteBgInput.value); applyTheme(); });
siteBgRow.appendChild(siteBgLabel);
siteBgRow.appendChild(siteBgInput);
siteBgRow.appendChild(siteBgReset);
themeRow.appendChild(siteBgRow);
body.appendChild(themeRow);
/* --- Раздел: FIX режим --- */
const lbl4 = document.createElement('div');
lbl4.className = 'rem-section-label';
lbl4.textContent = 'Исправления';
body.appendChild(lbl4);
body.appendChild(makeSwitchRow('fixMode', '🔧 FIX: переход на тайтл', 'Перейти на карты тайтла вместо простой ссылки'));
panel.appendChild(body);
/* Подвал с кнопками действий */
const footer = document.createElement('div');
footer.className = 'rem-panel-footer';
const btnRefresh = document.createElement('button');
btnRefresh.className = 'rem-action-btn';
btnRefresh.textContent = '🔄 Обновить';
btnRefresh.title = 'Принудительно перезагрузить инвентарь';
btnRefresh.onclick = () => {
localStorage.removeItem(MY_INV_KEY);
Manager.loaded = false;
Manager.covers = new Set();
Manager.sync();
document.getElementById('rem-settings-panel')?.classList.remove('open');
};
const btnProfile = document.createElement('button');
btnProfile.className = 'rem-action-btn';
btnProfile.textContent = '👤 Профиль';
btnProfile.title = 'Ввести ссылку на другой профиль';
btnProfile.onclick = () => {
localStorage.removeItem(MY_INV_KEY);
localStorage.removeItem(MY_ID_KEY);
document.getElementById('rem-settings-panel')?.classList.remove('open');
Manager.userId = null;
Manager.loaded = false;
Manager.covers = new Set();
Manager.promptId();
};
const btnReset = document.createElement('button');
btnReset.className = 'rem-action-btn danger';
btnReset.textContent = '🗑️ Сброс';
btnReset.title = 'Удалить все данные расширения и перезагрузить страницу';
btnReset.onclick = () => {
if (confirm('Сбросить все данные расширения?')) {
localStorage.removeItem(MY_INV_KEY);
localStorage.removeItem(MY_ID_KEY);
location.reload();
}
};
footer.appendChild(btnRefresh);
footer.appendChild(btnProfile);
footer.appendChild(btnReset);
panel.appendChild(footer);
return panel;
}
function togglePanel() {
const panel = document.getElementById('rem-settings-panel');
if (!panel) return;
panel.classList.toggle('open');
}
/* ===== КАСТОМНЫЙ ФОН ===== */
function applyCustomBackground() {
if (!cfg.customBgEnabled || !cfg.customBgUrl) { removeCustomBackground(); return; }
const url = cfg.customBgUrl;
const opacity = (cfg.customBgOpacity ?? 80) / 100;
const blur = cfg.customBgBlur ?? 0;
const fit = cfg.customBgFit || 'cover';
removeCustomBackground();
const isVideo = /\.(webm|mp4|ogg|ogv)(\?.*)?$/.test(url) || url.startsWith('data:video');
/* CSS-оверрайд скрывает оригинальный фон сайта и делает html/body прозрачным
чтобы наш элемент был виден сквозь них */
let bgStyle = document.getElementById('rem-bg-override');
if (!bgStyle) {
bgStyle = document.createElement('style');
bgStyle.id = 'rem-bg-override';
(document.head || document.documentElement).appendChild(bgStyle);
}
bgStyle.textContent = `
/* Очищаем основные фоны, но не трогаем шапку и карточки */
body, #__next,
[data-theme="light"] body, [data-theme="dark"] body,
[data-sentry-element="AppLayoutRoot"],
[data-sentry-component="AppLayoutRoot"],
[data-sentry-element="AppLayoutContent"],
[data-sentry-element="EntityLayoutRoot"] {
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
}
/* Убираем псевдоэлементы с фоном из корня, чтобы был виден наш */
.cs-layout-root::before {
display: none !important;
background: none !important;
}
/* Фикс прозрачности шапки - восстанавливаем дефолтный фон */
[data-sentry-component="Header"], header {
background-color: var(--chakra-colors-gray-800, #18181b) !important;
backdrop-filter: none !important;
}
[data-theme="light"] [data-sentry-component="Header"],
[data-theme="light"] header {
background-color: var(--chakra-colors-white, #ffffff) !important;
}
/* Опускаем фоны сайта, чтобы наш кастомный был выше (z-index -1) */
[data-sentry-component="WallpaperBackground"],
.custom-background-video, video.custom-background-video {
z-index: -2 !important;
}
`;
let el;
if (isVideo) {
el = document.createElement('video');
el.src = url;
el.autoplay = true;
el.loop = true;
el.muted = true;
el.playsInline = true;
el.play().catch(() => { });
} else {
el = document.createElement('img');
el.src = url;
el.onerror = () => console.warn('REM BG: не удалось загрузить фон:', url.substring(0, 80));
}
el.id = 'rem-custom-bg';
el.style.cssText = [
'position:fixed',
'top:0', 'left:0',
'width:100%', 'height:100vh',
`object-fit:${fit}`,
'object-position:center center',
'z-index:-1', /* выше чем -9, но ниже контента страницы */
'pointer-events:none',
'will-change:transform',
'transform:translateZ(0)',
`opacity:${opacity}`,
`filter:${blur > 0 ? 'blur(' + blur + 'px)' : 'none'}`,
'transition:opacity .3s',
].join(';');
document.body.insertBefore(el, document.body.firstChild);
}
function removeCustomBackground() {
const old = document.getElementById('rem-custom-bg');
if (old) old.remove();
const bgStyle = document.getElementById('rem-bg-override');
if (bgStyle) bgStyle.remove();
}
/* ===== ТЕМА / АКЦЕНТНЫЙ ЦВЕТ ===== */
function hexToHsl(hex) {
let r = parseInt(hex.slice(1, 3), 16) / 255,
g = parseInt(hex.slice(3, 5), 16) / 255,
b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; } else {
const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; }
h /= 6;
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
function applyTheme() {
let themeEl = document.getElementById('rem-theme-override');
if (!themeEl) {
themeEl = document.createElement('style');
themeEl.id = 'rem-theme-override';
(document.head || document.documentElement).appendChild(themeEl);
}
let css = '';
if (cfg.themeNameFont && cfg.themeNameFont !== '') {
if (cfg.themeNameFont !== 'Comic Sans MS') {
const fontNameUrl = cfg.themeNameFont.replace(/ /g, '+');
css += `@import url('https://fonts.googleapis.com/css2?family=${fontNameUrl}&display=swap');\n`;
}
}
if (cfg.themeAccent) {
const accent = cfg.themeAccent;
let darker = accent;
const darkerMatch = accent.match(/#([0-9a-f]{6})/i);
if (darkerMatch) {
const dr = Math.max(0, parseInt(darkerMatch[1].slice(0, 2), 16) - 30),
dg = Math.max(0, parseInt(darkerMatch[1].slice(2, 4), 16) - 30),
db = Math.max(0, parseInt(darkerMatch[1].slice(4, 6), 16) - 30);
darker = '#' + [dr, dg, db].map(v => v.toString(16).padStart(2, '0')).join('');
}
css += `
input:checked + .rem-sw-track { background: ${accent} !important; }
.rem-progress-fill { background: linear-gradient(90deg, ${accent}, ${darker}) !important; }
`;
}
if (cfg.themeButtonBg) {
const btnBg = cfg.themeButtonBg;
let darker = btnBg;
const darkerMatch = btnBg.match(/#([0-9a-f]{6})/i);
if (darkerMatch) {
const dr = Math.max(0, parseInt(darkerMatch[1].slice(0, 2), 16) - 30),
dg = Math.max(0, parseInt(darkerMatch[1].slice(2, 4), 16) - 30),
db = Math.max(0, parseInt(darkerMatch[1].slice(4, 6), 16) - 30);
darker = '#' + [dr, dg, db].map(v => v.toString(16).padStart(2, '0')).join('');
}
css += `
.bg-primary,
[data-state="active"][class*="bg-primary"],
[data-state="open"][class*="bg-primary"],
[aria-selected="true"][class*="bg-primary"] {
background-color: ${btnBg} !important;
border-color: ${btnBg} !important;
color: #fff !important;
}
.bg-primary:hover,
[data-state="active"][class*="bg-primary"]:hover,
[data-state="open"][class*="bg-primary"]:hover,
[aria-selected="true"][class*="bg-primary"]:hover {
background-color: ${darker} !important;
border-color: ${darker} !important;
}
`;
}
if (cfg.themeNameColor || cfg.themeNameFont) {
css += `
.cs-layout-title-text, .cs-layout-title .cs-text {
${cfg.themeNameColor ? `color: ${cfg.themeNameColor} !important;` : ''}
${cfg.themeNameFont ? `font-family: "${cfg.themeNameFont}", sans-serif !important;` : ''}
}
`;
}
if (cfg.themeMenuBg) {
const mBg = hexToHsl(cfg.themeMenuBg);
css += `
:root {
--popover: ${mBg} !important;
--card: ${mBg} !important;
--secondary: ${mBg} !important;
--muted: ${mBg} !important;
}
.bg-popover, .bg-secondary, .bg-card, .bg-muted, .cs-account-menu, [data-radix-menu-content] {
background-color: ${cfg.themeMenuBg} !important;
}
.rem-settings-panel, .rem-box, .rem-progress-box {
background-color: ${cfg.themeMenuBg} !important;
border-color: rgba(255,255,255,0.1) !important;
}
`;
}
if (cfg.themeSiteBg && !cfg.customBgEnabled) {
const sBg = hexToHsl(cfg.themeSiteBg);
css += `
:root {
--background: ${sBg} !important;
}
body, #__next, .bg-background {
background-color: ${cfg.themeSiteBg} !important;
}
`;
}
themeEl.textContent = css;
}
function removeTheme() {
const el = document.getElementById('rem-theme-override');
if (el) el.textContent = '';
}
/* ===== ВИЗУАЛЬНЫЙ СКАН (метки коллекции + ранг) ===== */
function createCheck() {
const div = document.createElement('div');
div.className = 'rem-own-badge';
div.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
return div;
}
function scanVisual() {
if (!Manager.loaded) return;
if (location.pathname.match(/\/user\/\d+\/(about|inventory|create\/exchange)/)) return;
document.querySelectorAll('img[src*="/media/card-item/"]:not([data-rem-checked])').forEach(img => {
img.setAttribute('data-rem-checked', '1');
const fname = Manager.getFilename(img.src);
const have = Manager.has(fname);
const wrapper = img.parentElement;
if (!wrapper) return;
if (window.getComputedStyle(wrapper).position === 'static') wrapper.style.position = 'relative';
if (cfg.ownBadge && have && !wrapper.querySelector('.rem-own-badge')) {
wrapper.appendChild(createCheck());
}
});
}
/* ===== ОНЛАЙН-СТАТУС ===== */
async function checkOnline() {
if (!cfg.onlineStatus) return;
const candidates = document.querySelectorAll('a[href*="/user/"]:not([data-rem-online])');
for (const el of candidates) {
if (el.textContent.includes("Показать") || el.closest('.cs-comments-section, [data-sentry-component="ActivityItemCard"]')) {
el.setAttribute('data-rem-online', 'skip'); continue;
}
const m = el.getAttribute('href').match(/\/user\/(\d+)/);
if (!m) { el.setAttribute('data-rem-online', 'skip'); continue; }
const uid = m[1], av = el.querySelector('[data-slot="avatar"], .relative.shrink-0');
if (!av) { el.setAttribute('data-rem-online', 'skip'); continue; }
el.setAttribute('data-rem-online', '1');
if (Manager.onlineCache.has(uid)) {
const c = Manager.onlineCache.get(uid);
if (Date.now() - c.time < 600000) { drawDot(av, c.online); continue; }
}
if (Manager.onlineProcessing.has(uid)) continue;
Manager.onlineProcessing.add(uid);
Manager.req(`https://api.remanga.org/api/v2/users/${uid}/`).then(data => {
const status = data ? !!data.is_online : false;
Manager.onlineCache.set(uid, { online: status, time: Date.now() });
Manager.onlineProcessing.delete(uid);
drawDot(av, status);
});
}
}
function drawDot(container, isOnline) {
if (container.querySelector('.rem-online-dot')) return;
const dot = document.createElement('div');
dot.className = `rem-online-dot ${isOnline ? 'online' : 'offline'}`;
if (window.getComputedStyle(container).position === 'static') container.style.position = 'relative';
container.appendChild(dot);
}
/* ===== СТАТИСТИКА КАРТЫ В ДИАЛОГЕ ===== */
async function injectCardStats(dialog, cardId) {
if (!cfg.cardStats) return;
if (dialog.querySelector('.rem-card-stats-row')) return;
const row = document.createElement('div');
row.className = 'rem-card-stats-row';
row.innerHTML = `<div class="rem-stat-bubble owners"><span class="rem-stat-num loading">...</span><span class="rem-stat-lab">Владельцы</span></div><div class="rem-stat-bubble wants"><span class="rem-stat-num loading">...</span><span class="rem-stat-lab">Хотят</span></div><div class="rem-stat-bubble sales"><span class="rem-stat-num loading">...</span><span class="rem-stat-lab">Продают</span></div>`;
const target = dialog.querySelector('.flex.flex-wrap.items-center.justify-center.gap-3');
if (target) target.parentNode.insertBefore(row, target);
const fetchCount = async (url) => {
let total = 0, page = 1;
while (true) {
const data = await Manager.req(`${url}${url.includes('?') ? '&' : '?'}page=${page}`);
if (!data || !data.results) break;
total += data.results.length;
if (!data.next || page >= 40) break;
page++;
await new Promise(r => setTimeout(r, 100));
}
return total;
};
Promise.all([
fetchCount(`https://api.remanga.org/api/v2/users/have-card/${cardId}/`),
fetchCount(`https://api.remanga.org/api/v2/inventory/wishes/${cardId}/?wish_type=1`),
fetchCount(`https://api.remanga.org/api/v2/inventory/wishes/${cardId}/?wish_type=2`),
]).then(([o, w, s]) => {
const oN = row.querySelector('.owners .rem-stat-num'),
wN = row.querySelector('.wants .rem-stat-num'),
sN = row.querySelector('.sales .rem-stat-num');
if (oN) { oN.innerText = o; oN.classList.remove('loading'); }
if (wN) { wN.innerText = w; wN.classList.remove('loading'); }
if (sN) { sN.innerText = s; sN.classList.remove('loading'); }
});
}
/* ===== ПРОГРЕСС-БАР ТАЙТЛА ===== */
function buildProgressHtml(owned, total, rankStats) {
let percentNum = 0, percentStr = "0%";
if (total > 0 && owned > 0) {
const raw = (owned / total) * 100;
if (owned === total) { percentNum = 100; percentStr = "100%"; }
else if (raw < 1) { percentStr = raw.toFixed(1) + "%"; percentNum = raw; }
else if (raw > 99) { percentStr = raw.toFixed(1) + "%"; percentNum = raw; }
else { percentNum = Math.floor(raw); percentStr = percentNum + "%"; }
}
let badgesHtml = '';
if (rankStats) {
RANK_ORDER.forEach(rankKey => {
if (rankStats[rankKey] && rankStats[rankKey].total > 0) {
const r = RANK_MAP[rankKey], st = rankStats[rankKey];
badgesHtml += `<div class="rem-rank-badge" style="color:${r.color};border-color:${r.color};">${r.name}: <span style="color:#fff;margin-left:3px;">${st.owned} / ${st.total}</span></div>`;
}
});
}
return `
<div class="rem-progress-header">
<div class="rem-progress-text">Собрано карт: <span>${owned}</span> / ${total}</div>
<div id="rem-percent">${percentStr}</div>
</div>
<div class="rem-progress-track"><div class="rem-progress-fill" style="width:${percentNum}%"></div></div>
<div class="rem-rank-stats">${badgesHtml}</div>
`;
}
function createProgressBox() {
const box = document.createElement('div');
box.id = 'rem-progress-box';
box.className = 'rem-progress-box';
box.innerHTML = buildProgressHtml(0, 0, null);
return box;
}
function updateProgressBox(box, owned, total, rankStats) {
const tmp = document.createElement('div');
tmp.innerHTML = buildProgressHtml(owned, total, rankStats);
box.querySelector('.rem-progress-header').innerHTML = tmp.querySelector('.rem-progress-header').innerHTML;
box.querySelector('.rem-progress-fill').style.width = tmp.querySelector('.rem-progress-fill').style.width;
box.querySelector('.rem-rank-stats').innerHTML = tmp.querySelector('.rem-rank-stats').innerHTML;
}
async function injectTitleProgress() {
if (!cfg.titleProgress) return;
const match = location.pathname.match(/^\/manga\/([\w-]+)\/cards/);
if (!match) {
const oldBox = document.getElementById('rem-progress-box');
if (oldBox) oldBox.remove();
isCalculating = false; lastSlug = '';
return;
}
if (!Manager.loaded || isCalculating) return;
const grid = document.querySelector('.grid.gap-3');
if (!grid) return;
const slug = match[1];
if (lastSlug !== slug) {
const oldBox = document.getElementById('rem-progress-box');
if (oldBox) oldBox.remove();
lastSlug = slug;
}
if (document.getElementById('rem-progress-box')) return;
isCalculating = true;
const box = createProgressBox();
grid.parentNode.insertBefore(box, grid);
try {
const titleData = await Manager.req(`https://api.remanga.org/api/titles/${slug}/`);
if (!titleData || !titleData.content || !titleData.content.id) { isCalculating = false; return; }
const titleId = titleData.content.id;
let totalCards = 0, ownedCards = 0, rankStats = {}, page = 1, run = true;
while (run) {
if (!location.pathname.includes(slug)) { isCalculating = false; return; }
const data = await Manager.req(`https://api.remanga.org/api/inventory/${titleId}/cards/?count=100&ordering=rank&page=${page}`);
if (!data) { run = false; break; }
const list = data.results || data.content || [];
if (!list.length) { run = false; break; }
list.forEach(item => {
totalCards++;
const c = item.card || item;
const rank = c.rank || 'unknown';
if (!rankStats[rank]) rankStats[rank] = { total: 0, owned: 0 };
rankStats[rank].total++;
if (c.cover) {
const h = Manager.getFilename(c.cover.high);
const m = Manager.getFilename(c.cover.mid);
if ((h && Manager.has(h)) || (m && Manager.has(m))) {
ownedCards++;
rankStats[rank].owned++;
}
}
});
if (!data.next) run = false; else page++;
await new Promise(r => setTimeout(r, 100));
}
const finalBox = document.getElementById('rem-progress-box');
if (finalBox) updateProgressBox(finalBox, ownedCards, totalCards, rankStats);
} catch (e) { console.error(e); }
isCalculating = false;
}
/* ===== FIX РЕЖИМ / ДИАЛОГ ===== */
function scanForDialog() {
const dialog = document.querySelector('[role="dialog"]');
if (!dialog) return;
const cardLink = dialog.querySelector('a[href^="/card/"]');
if (cardLink && !dialog.dataset.statsInjected) {
const cid = cardLink.getAttribute('href').match(/card\/(\d+)/)?.[1];
if (cid) { dialog.dataset.statsInjected = cid; injectCardStats(dialog, cid); }
}
const mangaLink = dialog.querySelector('a[href^="/manga/"]');
if (mangaLink && !mangaLink.dataset.fixHook) {
mangaLink.dataset.fixHook = "true";
mangaLink.dataset.origHref = mangaLink.getAttribute('href');
const isActive = cfg.fixMode;
const btn = document.createElement('span');
btn.className = `rem-toggle ${isActive ? 'on' : 'off'}`;
btn.innerText = `FIX: ${isActive ? 'ON' : 'OFF'}`;
if (mangaLink.nextSibling) mangaLink.parentNode.insertBefore(btn, mangaLink.nextSibling);
else mangaLink.parentNode.appendChild(btn);
btn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const newState = !cfg.fixMode;
saveSetting('fixMode', newState);
btn.className = `rem-toggle ${newState ? 'on' : 'off'}`;
btn.innerText = `FIX: ${newState ? 'ON' : 'OFF'}`;
if (!newState) mangaLink.href = mangaLink.dataset.origHref;
});
mangaLink.addEventListener('click', async (e) => {
if (cfg.fixMode) {
e.preventDefault(); e.stopPropagation();
if (!cardLink) return;
const m = cardLink.getAttribute('href').match(/card\/(\d+)/);
if (!m) return;
btn.innerText = "⏳";
const cardData = await Manager.req(`https://api.remanga.org/api/inventory/cards/${m[1]}/`);
if (cardData && cardData.title && cardData.title.id) {
const titleId = cardData.title.id;
const titleName = cardData.title.main_name || "Тайтл";
const slug = mangaLink.dataset.origHref.split('/')[2];
btn.innerText = "FIX: ON";
window.location.href = `https://remanga.org/manga/${slug}/cards?fix_id=${titleId}&fix_name=${encodeURIComponent(titleName)}`;
} else { btn.innerText = "ERR"; }
}
});
}
}
/* ===== runFix ===== */
async function runFix(fid, rawName) {
if (activeObserver) { activeObserver.disconnect(); activeObserver = null; }
document.documentElement.classList.add('rem-checking');
const l = document.createElement('div'); l.id = 'rem-loader'; l.innerText = "Загрузка данных (0%)...";
if (!document.body) await new Promise(r => addEventListener('DOMContentLoaded', r));
document.body.appendChild(l);
const slug = location.pathname.split('/')[2];
const status = await Manager.req(`https://api.remanga.org/api/titles/${slug}/`);
l.remove(); document.documentElement.classList.remove('rem-checking');
if (status && status.content) {
const url = new URL(window.location);
url.searchParams.delete('fix_id'); url.searchParams.delete('fix_name');
window.history.replaceState({}, '', url);
return;
}
const tName = rawName ? decodeURIComponent(rawName) : "Title";
let main = document.querySelector('main');
while (!main) { await new Promise(r => setTimeout(r, 50)); main = document.querySelector('main'); }
document.body.classList.add('rem-active');
const box = document.createElement('div');
box.id = 'rem-inject'; box.className = "container mx-auto px-4 py-6";
box.innerHTML = `<div style="height:50vh;display:flex;align-items:center;justify-content:center;color:#888;">Загрузка карт...</div>`;
main.appendChild(box);
activeObserver = new MutationObserver(() => {
if (!document.body.classList.contains('rem-active')) document.body.classList.add('rem-active');
if (!document.getElementById('rem-inject')) { const m = document.querySelector('main'); if (m) m.appendChild(box); }
});
activeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
activeObserver.observe(main, { childList: true });
let allCards = [], page = 1, run = true;
while (run) {
const data = await Manager.req(`https://api.remanga.org/api/inventory/${fid}/cards/?count=100&ordering=rank&page=${page}`);
if (!data) { run = false; break; }
const list = data.results || data.content || [];
if (!list.length) { run = false; break; }
allCards = allCards.concat(list);
if (!data.next) run = false; else page++;
await new Promise(r => setTimeout(r, 100));
}
if (!allCards.length) { box.innerHTML = `<div style="text-align:center;padding:50px">Пусто</div>`; return; }
let ownedInTitle = 0, rankStats = {};
const grid = document.createElement('div');
grid.className = 'rem-grid';
allCards.forEach(item => {
const c = item.card || item;
const rank = c.rank || 'unknown';
if (!rankStats[rank]) rankStats[rank] = { total: 0, owned: 0 };
rankStats[rank].total++;
let have = false;
if (c.cover) {
const h = Manager.getFilename(c.cover.high);
const m = Manager.getFilename(c.cover.mid);
if ((h && Manager.has(h)) || (m && Manager.has(m))) have = true;
}
if (have) { ownedInTitle++; rankStats[rank].owned++; }
const img = c.cover?.high ? `https://remanga.org${c.cover.high}` : (c.cover?.mid ? `https://remanga.org${c.cover.mid}` : null);
const el = document.createElement('div');
el.className = 'rem-card';
el.innerHTML = img ? `<img src="${img}" loading="lazy">` : '';
el.onclick = () => showModal(c, tName);
if (have && cfg.ownBadge) {
if (window.getComputedStyle(el).position === 'static') el.style.position = 'relative';
el.appendChild(createCheck());
}
grid.appendChild(el);
});
const progressBox = createProgressBox();
updateProgressBox(progressBox, ownedInTitle, allCards.length, rankStats);
progressBox.style.marginBottom = '20px';
box.innerHTML = `
<div class="rem-fix-header">
<h1 class="rem-fix-title">${tName}</h1>
<div class="rem-fix-meta">
<span>ID: ${fid}</span><span class="rem-sep">|</span>
<span>${allCards.length} шт</span><span class="rem-sep">|</span>
<span style="color:#22c55e;">● Fix Active</span>
</div>
</div>
`;
box.appendChild(progressBox);
box.appendChild(grid);
}
/* ===== НАВИГАЦИЯ ===== */
function checkNavigation() {
const p = new URLSearchParams(location.search);
if (!p.get('fix_id')) {
if (activeObserver) { activeObserver.disconnect(); activeObserver = null; }
if (document.body.classList.contains('rem-active')) document.body.classList.remove('rem-active');
const inj = document.getElementById('rem-inject'); if (inj) inj.remove();
const load = document.getElementById('rem-loader'); if (load) load.remove();
document.documentElement.classList.remove('rem-checking');
} else if (!document.body.classList.contains('rem-active') && !document.getElementById('rem-loader')) {
runFix(p.get('fix_id'), p.get('fix_name'));
}
}
/* ===== МОДАЛЬНОЕ ОКНО КАРТЫ ===== */
function getAuthorInfo(c) {
const u = c.author || c.user || c.upload_user || c.owner || c.publisher || c.creator;
if (u && (u.username || u.name)) return { name: u.username || u.name, link: u.id ? `/user/${u.id}/about` : '#' };
return { name: "Неизвестен", link: "#" };
}
function showModal(c, tName) {
const img = c.cover?.high ? `https://remanga.org${c.cover.high}` : '';
const name = c.name || c.character?.name || "?";
const cid = c.character?.id || 0;
const uInfo = getAuthorInfo(c);
const likes = c.likes_count || 0;
const cLink = c.id ? `/card/${c.id}` : '#';
const over = document.createElement('div'); over.className = 'rem-over';
over.onclick = e => { if (e.target === over) over.remove(); };
let titleUrl = `/manga/${location.pathname.split('/')[2]}`;
const p = new URLSearchParams(location.search);
if (p.get('fix_id')) titleUrl += `/cards?fix_id=${p.get('fix_id')}&fix_name=${p.get('fix_name')}`;
else { const slug = c.title ? c.title.dir : '#'; if (slug !== '#') titleUrl = `/manga/${slug}`; }
over.innerHTML = `
<div class="rem-box">
<button class="rem-close" id="x"><svg width="20" height="20" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5" fill="none"><path d="M6 14L14 6M14 14L6 6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<div class="rem-box-img"><img src="${img}"></div>
<div class="rem-info">
<div><a href="${titleUrl}" class="rem-lnk-m" target="_blank">${tName} ↗</a>${p.get('fix_id') ? '<span class="rem-toggle on" style="font-size:10px;padding:2px 4px;margin-left:5px">FIX: ON</span>' : ''}</div>
<a href="/character/${cid}" class="rem-lnk-c" target="_blank">${name} ↗</a>
</div>
<div class="rem-acts"><button class="rem-pill">Like ${likes}</button></div>
<div class="rem-sub"><a href="${uInfo.link}" target="_blank" class="rem-s-btn">Автор: ${uInfo.name}</a><a href="${cLink}" target="_blank" class="rem-s-btn">Пользователи</a></div>
</div>`;
document.body.appendChild(over);
document.getElementById('x').onclick = () => over.remove();
}
/* ===== СТАТИСТИКА ПРОФИЛЯ ===== */
async function injectStats() {
if (!cfg.profileStats) return;
const m = location.pathname.match(/^\/user\/(\d+)/); if (!m) return;
const uid = m[1];
let cont = document.querySelector('.cs-layout-avatar-container');
if (!cont) { const f = document.querySelectorAll('.flex.flex-row.gap-2.max-sm\\:w-full'); if (f.length) cont = f[0].firstElementChild; }
if (!cont || cont.parentElement.querySelector('.rem-profile-stat-badge')) return;
const badge = document.createElement('div');
badge.className = 'rem-profile-stat-badge rem-counting';
badge.innerHTML = `🎴 Создал карт: <strong>...</strong>`;
const col = cont.parentElement;
const guild = cont.nextElementSibling;
if (guild && !guild.classList.contains('mt-auto')) {
if (guild.nextSibling) col.insertBefore(badge, guild.nextSibling); else col.appendChild(badge);
} else {
if (cont.nextSibling) col.insertBefore(badge, cont.nextSibling); else col.appendChild(badge);
}
let verb = "Создал";
try {
const userRes = await Manager.req(`https://api.remanga.org/api/v2/users/${uid}/`);
if (userRes) {
let s = undefined;
if (userRes.sex !== undefined) s = userRes.sex;
else if (userRes.content && userRes.content.sex !== undefined) s = userRes.content.sex;
else if (userRes.user && userRes.user.sex !== undefined) s = userRes.user.sex;
if (s !== undefined) {
const sex = Number(s);
if (sex === 0) verb = "Создало";
if (sex === 1) verb = "Создал";
if (sex === 2) verb = "Создала";
}
}
} catch (e) { }
let total = 0, page = 1, run = true;
while (run) {
badge.innerHTML = `🎴 ${verb} карт: <strong>${total}...</strong>`;
const r = await Manager.req(`https://api.remanga.org/api/inventory/catalog/?author=${uid}&count=30&ordering=-rank&page=${page}`);
if (!r) break;
const list = r.results || [];
if (!list.length) break;
total += list.length;
if (!r.next) run = false; else { page++; await new Promise(r => setTimeout(r, 150)); }
}
badge.classList.remove('rem-counting');
badge.innerHTML = `🎴 ${verb} карт: <strong>${total}</strong>`;
}
/* ===== СТАРТ ===== */
const p = new URLSearchParams(location.search);
if (p.get('fix_id')) {
const run = () => runFix(p.get('fix_id'), p.get('fix_name'));
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', run); else run();
}
window.addEventListener('load', () => Manager.init());
setInterval(() => {
checkNavigation();
scanVisual();
scanForDialog();
injectTitleProgress();
if (cfg.onlineStatus) checkOnline();
if (location.pathname.startsWith('/user/')) injectStats();
}, 400);
})();