Habr Visor

Hide habs from habr

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Habr Visor
// @match        https://habr.com/*
// @grant        GM_addStyle
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @description  Hide habs from habr
// @author       You
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tampermonkey.net
// @license      MIT
// @run-at       document-end
// ==/UserScript==

const KEY = 'habr-visor-data';
const STYLE_ID = 'v-visor-styles';
const ICON = `<svg viewBox="0 0 512 512" style="width:20px;height:20px;color:var(--color-text-second,#999);pointer-events:none"><circle cx="256" cy="256" r="64" fill="currentColor"/><path d="M490.84 238.6c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.66 96c-42.52 0-84.33 12.15-124.27 36.11c-40.73 24.43-77.63 60.12-109.68 106.07a31.92 31.92 0 0 0-.64 35.54c26.41 41.33 60.4 76.14 98.28 100.65C162 402 207.9 416 255.66 416c46.71 0 93.81-14.43 136.2-41.72c38.46-24.77 72.72-59.66 99.08-100.92a32.2 32.2 0 0 0-.1-34.76zM256 352a96 96 0 1 1 96-96a96.11 96.11 0 0 1-96 96z" fill="currentColor"/></svg>`;

GM_addStyle(`
    #vBtn { cursor: pointer; padding: 8px; display: flex; align-items: center; border-radius: 4px; position: relative; margin-right: 8px; align-self: center; z-index: 1000; }
    #vBtn:hover { background: var(--color-background-field, rgba(128,128,128,0.1)); }
    .v-pop {
        position: absolute; top: 48px; right: 0; width: 320px; padding: 20px; border-radius: 12px;
        box-shadow: 0 12px 40px rgba(0,0,0,0.5); display: none; flex-direction: column;
        background: var(--color-background-card, #fff); border: 1px solid var(--color-border-elements, #ddd);
        color: var(--color-text-main, #333); z-index: 10001; font-size: 16px;
    }
    @media (prefers-color-scheme: dark) {
        .v-pop { background: #1c1c1d; border-color: #333; color: #eee; }
        .v-in { background: #2d2d2e!important; border-color: #444!important; color: #fff!important; }
    }
    .v-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; max-height: 150px; overflow-y: auto; }
    .v-tag { background: var(--color-primary-main, #5d83b3); color: #fff!important; padding: 4px 10px; border-radius: 6px; font-size: 14px; display: flex; align-items: center; gap: 8px; line-height: 1.2; }
    .v-tag b { cursor: pointer; font-size: 18px; line-height: 1; }
    .v-in { width: 100%; padding: 12px; border: 1px solid var(--color-border-elements); border-radius: 6px; outline: none; margin-bottom: 14px; box-sizing: border-box; font-size: 16px; }
    .v-footer { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: bold; }
    .v-clear { text-decoration: underline; cursor: pointer; opacity: 0.6; font-size: 14px; font-weight: normal; }
`);

let state = { tags: [], show: false };
let lastCss = "";

try {
    const s = localStorage.getItem(KEY);
    if (s) state = JSON.parse(s);
} catch (e) {
    console.error("Visor Error:", e);
}

const updatePageStyles = () => {
    const activeTags = state.tags.map(t => t.toLowerCase());
    const blockedIds = [];

    const articles = document.querySelectorAll('article');

    for (const art of articles) {
        const hubs = Array.from(art.querySelectorAll('.tm-publication-hub__link span, .tm-article-snippet__hubs-item'))
        .map(s => s.innerText.trim().toLowerCase());

        const shouldBlock = activeTags.some(t => hubs.some(h => h.includes(t)));

        if (shouldBlock) {
            const id = art.id || art.getAttribute('data-aid');
            if (id) blockedIds.push(`[id="${id}"]`);
        }
    };

    const currentCount = blockedIds.length;
    const cssContent = currentCount > 0
        ? (state.show
            ? `${blockedIds.join(', ')} { opacity: 0.45 !important; filter: grayscale(1) !important; }`
            : `${blockedIds.join(', ')} { display: none !important; }`)
        : "";

    if (cssContent !== lastCss) {
        let styleTag = document.getElementById(STYLE_ID);
        if (!styleTag) {
            styleTag = document.createElement('style');
            styleTag.id = STYLE_ID;
            document.head.appendChild(styleTag);
        }
        styleTag.textContent = cssContent;
        lastCss = cssContent;
    }

    const counter = document.getElementById('vCount');
    if (counter) counter.innerText = currentCount;
};

const save = () => {
    localStorage.setItem(KEY, JSON.stringify(state));
    renderTags();
    updatePageStyles();
};

const renderTags = () => {
    const cont = document.querySelector('.v-tags');
    if (!cont) return;
    cont.innerHTML = state.tags.map((t, i) => `<span class="v-tag">${t}<b data-idx="${i}">×</b></span>`).join('');
    cont.querySelectorAll('b').forEach(b => {
        b.onclick = (e) => {
            e.stopPropagation();
            state.tags.splice(parseInt(b.dataset.idx, 10), 1);
            save();
        };
    });
};

const inject = () => {
    const header = document.querySelector('.tm-header__container');
    if (!header || document.getElementById('vBtn')) return;

    const btn = document.createElement('div');
    btn.id = 'vBtn';
    btn.innerHTML = `${ICON}<div class="v-pop" id="vPop">
        <div class="v-footer"><span>Фильтры хабов</span><span class="v-clear" id="vClear">Очистить всё</span></div>
        <div class="v-tags"></div>
        <input class="v-in" placeholder="Название хаба + Enter...">
        <label style="display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none">
            <input type="checkbox" id="vS" ${state.show ? 'checked' : ''}> Показать скрытое (<span id="vCount">0</span>)
        </label>
    </div>`;

    header.appendChild(btn);

    btn.onclick = (e) => {
        if (e.target.closest('.v-pop')) return;
        const p = document.getElementById('vPop');
        p.style.display = p.style.display === 'flex' ? 'none' : 'flex';
    };

    btn.querySelector('#vClear').onclick = (e) => { e.stopPropagation(); state.tags = []; save(); };
    btn.querySelector('#vS').onchange = (e) => { state.show = e.target.checked; save(); };

    const input = btn.querySelector('.v-in');
    input.onkeydown = (e) => {
        if (e.key === 'Enter' && input.value.trim()) {
            const val = input.value.trim();
            if (!state.tags.includes(val)) state.tags.push(val);
            input.value = '';
            save();
        }
    };
    renderTags();
    updatePageStyles();
};

let timer;
const observer = new MutationObserver(() => {
    clearTimeout(timer);
    timer = setTimeout(() => {
        inject();
        updatePageStyles();
    }, 300);
});

observer.observe(document.body, { childList: true, subtree: true });
inject();
updatePageStyles();