HuggingFace Model & Dataset Date Filter

Adds a custom dropdown to filter HF models and datasets by date. Persists selection across page reloads.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         HuggingFace Model & Dataset Date Filter
// @description  Adds a custom dropdown to filter HF models and datasets by date. Persists selection across page reloads.
// @match        *://huggingface.co/models*
// @match        *://huggingface.co/datasets*
// @icon         https://huggingface.co/favicon.ico
// @grant        none
// @license      MIT
// @version      1.0
// @namespace    https://greasyfork.org/users/1457508
// ==/UserScript==

(function () {
    'use strict';

    // ─── Constants ────────────────────────────────────────────────────────────

    const isDatasets = location.pathname.startsWith('/datasets');
    const STORAGE_KEY_VAL   = isDatasets ? 'hf_filter_date_value_datasets'  : 'hf_filter_date_value_models';
    const STORAGE_KEY_LABEL = isDatasets ? 'hf_filter_date_label_datasets'  : 'hf_filter_date_label_models';

    const DAY = 24 * 60 * 60 * 1000;
    const TIME_INTERVALS = [
        { label: 'Past Day',        value:   1 * DAY    },
        { label: 'Past 3 Days',     value:   3 * DAY    },
        { label: 'Past Week',       value:   7 * DAY    },
        { label: 'Past Month',      value:  30 * DAY    },
        { label: 'Past 3 Months',   value:  91 * DAY    },
        { label: 'Past 6 Months',   value: 183 * DAY    },
        { label: 'Past Year',       value: 365 * DAY    },
        { label: 'All Time',        value: 0            },
    ];

    // SVG icons as constants to avoid repetition and typos
    const ICON_CLOCK = `
        <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"
             fill="none" stroke="currentColor" stroke-width="2"
             stroke-linecap="round" stroke-linejoin="round"
             class="mr-1.5 opacity-70" aria-hidden="true">
            <circle cx="12" cy="12" r="10"/>
            <polyline points="12 6 12 12 16 14"/>
        </svg>`;

    // ─── State ────────────────────────────────────────────────────────────────

    let filterTimer   = null;
    let isFiltering   = false;   // guard against observer ↔ filter feedback
    let docClickBound = false;   // ensure document listener is added only once

    // ─── Core filter logic ────────────────────────────────────────────────────

    function getCutoff() {
        const wrapper = document.getElementById('hf-time-filter-wrapper');
        return wrapper ? parseInt(wrapper.dataset.value, 10) : 0;
    }

    function filterModels() {
        const cutoff = getCutoff();
        const cards  = document.querySelectorAll('article.overview-card-wrapper');

        isFiltering = true;

        if (cutoff === 0) {
            cards.forEach(c => { c.style.display = ''; });
        } else {
            const now = Date.now();
            cards.forEach(card => {
                const timeEl    = card.querySelector('time[datetime]');
                const modelDate = timeEl ? Date.parse(timeEl.getAttribute('datetime')) : NaN;
                card.style.display = (!timeEl || isNaN(modelDate) || (now - modelDate > cutoff)) ? 'none' : '';
            });
        }

        // Yield to the browser before re-enabling the observer guard so that
        // the style changes we just made don't immediately re-trigger filtering.
        requestAnimationFrame(() => { isFiltering = false; });
    }

    function scheduleFilter() {
        clearTimeout(filterTimer);
        filterTimer = setTimeout(filterModels, 50);
    }

    // ─── UI injection ─────────────────────────────────────────────────────────

    function injectUI() {
        if (document.getElementById('hf-time-filter-wrapper')) return;

        // Find the Sort button — use trim() to be resilient to whitespace changes
        const sortBtn = Array.from(document.querySelectorAll('button'))
            .find(b => b.textContent.trim().startsWith('Sort:'));
        if (!sortBtn) return;

        const container = sortBtn.parentElement?.parentElement;
        if (!container) return;

        // Mirror the Sort button's classes (minus layout helpers we override)
        const mirroredClasses = Array.from(sortBtn.classList)
            .filter(c => c !== 'w-full')
            .join(' ');

        const savedValue = localStorage.getItem(STORAGE_KEY_VAL)   ?? '0';
        const savedLabel = localStorage.getItem(STORAGE_KEY_LABEL) ?? 'Time Filter: Off';

        // ── Build wrapper ──
        const wrapper = document.createElement('div');
        wrapper.id        = 'hf-time-filter-wrapper';
        wrapper.className = 'relative inline-block ml-2 text-sm';
        wrapper.dataset.value = savedValue;

        // ── Build trigger button ──
        const btn = document.createElement('button');
        btn.id        = 'hf-time-filter-btn';
        btn.type      = 'button';
        btn.className = mirroredClasses;
        btn.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding-right:0.75rem;';
        btn.setAttribute('aria-haspopup', 'listbox');
        btn.setAttribute('aria-expanded',  'false');
        btn.setAttribute('aria-controls',  'hf-time-filter-menu');

        btn.innerHTML = `
            <span style="display:flex;align-items:center;">
                ${ICON_CLOCK}
                <span id="hf-time-filter-text" class="whitespace-nowrap">${savedLabel}</span>
            </span>`;

        // ── Build dropdown menu ──
        const menu = document.createElement('div');
        menu.id        = 'hf-time-filter-menu';
        menu.role      = 'listbox';
        menu.className = 'hidden absolute top-full z-20 mt-1 w-auto min-w-full max-w-xs overflow-hidden rounded-xl border border-gray-100 bg-white shadow-lg left-0 dark:border-gray-800 dark:bg-gray-950';

        const ul = document.createElement('ul');
        ul.className = 'm-0 min-w-full';
        ul.setAttribute('role', 'presentation');

        TIME_INTERVALS.forEach(interval => {
            const isActive = String(interval.value) === savedValue;

            const li = document.createElement('li');
            li.setAttribute('role', 'option');
            li.setAttribute('aria-selected', String(isActive));

            const optBtn = document.createElement('button');
            optBtn.type              = 'button';
            optBtn.className         = 'hf-time-option flex w-full cursor-pointer items-center whitespace-nowrap px-3 py-1.5 text-left hover:bg-gray-50 dark:hover:bg-gray-800 leading-tight text-gray-700 dark:text-gray-200';
            optBtn.dataset.value     = String(interval.value);
            optBtn.dataset.label     = interval.label;
            // Highlight the active option
            if (isActive) optBtn.style.fontWeight = '600';

            optBtn.innerHTML = `<span class="truncate">${interval.label}</span>`;
            li.appendChild(optBtn);
            ul.appendChild(li);
        });

        menu.appendChild(ul);
        wrapper.appendChild(btn);
        wrapper.appendChild(menu);
        container.appendChild(wrapper);

        // ── Events ──

        // Toggle open/close
        btn.addEventListener('click', e => {
            e.stopPropagation();
            const isHidden = menu.classList.toggle('hidden');
            btn.setAttribute('aria-expanded', String(!isHidden));
        });

        // Option selected
        ul.addEventListener('click', e => {
            const optBtn = e.target.closest('.hf-time-option');
            if (!optBtn) return;

            const val   = optBtn.dataset.value;
            const label = optBtn.dataset.label;

            // Update state
            wrapper.dataset.value = val;
            document.getElementById('hf-time-filter-text').textContent = label;
            localStorage.setItem(STORAGE_KEY_VAL,   val);
            localStorage.setItem(STORAGE_KEY_LABEL, label);

            // Update active styling
            ul.querySelectorAll('.hf-time-option').forEach(b => {
                b.style.fontWeight = (b.dataset.value === val) ? '600' : '';
                b.closest('li').setAttribute('aria-selected', String(b.dataset.value === val));
            });

            closeMenu();
            filterModels();
        });

        // Close on Escape
        document.addEventListener('keydown', e => {
            if (e.key === 'Escape') closeMenu();
        });

        // Close on outside click (registered once globally)
        if (!docClickBound) {
            docClickBound = true;
            document.addEventListener('click', e => {
                const w = document.getElementById('hf-time-filter-wrapper');
                if (w && !w.contains(e.target)) closeMenu();
            });
        }

        // Apply saved filter immediately if active
        if (savedValue !== '0') filterModels();
    }

    function closeMenu() {
        const menu = document.getElementById('hf-time-filter-menu');
        const btn  = document.getElementById('hf-time-filter-btn');
        if (menu) menu.classList.add('hidden');
        if (btn)  btn.setAttribute('aria-expanded', 'false');
    }

    // ─── MutationObserver ─────────────────────────────────────────────────────

    const observer = new MutationObserver(() => {
        // Re-inject the UI if HF's own navigation wiped it
        if (!document.getElementById('hf-time-filter-wrapper')) {
            injectUI();
        }

        // Re-filter newly loaded cards, but skip when we caused the mutations
        if (!isFiltering) {
            scheduleFilter();
        }
    });

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

    // ─── Bootstrap ────────────────────────────────────────────────────────────

    injectUI();

})();