Adds a custom dropdown to filter HF models and datasets by date. Persists selection across page reloads.
// ==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();
})();