// /store/: instant search and filters over the cards the build put in the page (every catalog item, // every app), kept in the address (?q=&kind=&app=&category=) so a search can be shared and the // header's Themes / Plugins links land on a filtered Store. Without JavaScript the page is the full // list. No libraries. import { initCopy, initProgress } from './common'; import { t } from './i18n'; import { initInstall } from './install'; type State = { q: string; kind: string; app: string; category: string }; const KEYS = ['q', 'kind', 'app', 'category'] as const; const LABEL: Record = { all: t('site.js.store.everything'), theme: t('site.js.store.themes'), plugin: t('site.js.store.plugins') }; const grid = document.querySelector('[data-store-grid]'); const cards = Array.from(grid?.querySelectorAll('.gal-card') ?? []); const search = document.querySelector('[data-store-search]'); const form = document.querySelector('[data-store-bar]'); const shelves = document.querySelector('[data-store-shelves]'); const heading = document.querySelector('[data-store-heading]'); const counter = document.querySelector('[data-store-count]'); const empty = document.querySelector('[data-store-empty]'); const emptyTitle = document.querySelector('[data-store-empty-title]'); const buttons = (group: 'kind' | 'app' | 'category') => Array.from(document.querySelectorAll(`[data-store-bar] button[data-${group}]`)); const known = (group: 'kind' | 'app' | 'category', value: string | null) => (value && buttons(group).some(button => button.dataset[group] === value) ? value : 'all'); function read(): State { const params = new URLSearchParams(window.location.search); return { q: (params.get('q') ?? '').slice(0, 80), kind: known('kind', params.get('kind')), app: known('app', params.get('app')), category: known('category', params.get('category')) }; } let state = read(); function write(): void { const params = new URLSearchParams(); for (const key of KEYS) if (state[key] && state[key] !== 'all') params.set(key, state[key]); const query = params.toString(); window.history.replaceState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`); } function apply(): void { // A category belongs to one kind: leaving that kind drops it. const chosen = buttons('category').find(button => button.dataset.category === state.category); if (state.kind !== 'all' && chosen?.dataset.for && chosen.dataset.for !== state.kind) state.category = 'all'; const words = state.q.toLowerCase().split(/\s+/).filter(Boolean); let shown = 0; for (const card of cards) { const visible = (state.kind === 'all' || card.dataset.kind === state.kind) && (state.app === 'all' || (card.dataset.apps ?? '').split(' ').includes(state.app)) && (state.category === 'all' || card.dataset.category === state.category) && words.every(word => (card.dataset.search ?? '').includes(word)); card.hidden = !visible; if (visible) shown += 1; } for (const group of ['kind', 'app', 'category'] as const) { for (const button of buttons(group)) { button.setAttribute('aria-pressed', String(button.dataset[group] === state[group])); if (group === 'category' && button.dataset.for) button.hidden = state.kind !== 'all' && button.dataset.for !== state.kind; } } if (search && search.value !== state.q) search.value = state.q; const filtered = Boolean(words.length) || state.kind !== 'all' || state.app !== 'all' || state.category !== 'all'; if (shelves) shelves.hidden = filtered; if (heading) heading.textContent = words.length ? t('site.js.store.results') : LABEL[state.kind] ?? LABEL.all!; if (counter) counter.textContent = filtered ? t('site.js.store.shown', { shown, total: cards.length }) : t('site.js.store.count', { count: cards.length }); if (grid) grid.hidden = shown === 0; if (empty) empty.hidden = shown > 0; if (emptyTitle) emptyTitle.textContent = state.q.trim() ? t('site.js.store.emptyQuery', { query: state.q.trim() }) : t('site.js.store.emptyFilters'); write(); } function reset(): void { state = { q: '', kind: 'all', app: 'all', category: 'all' }; apply(); search?.focus(); } const toBrowse = () => document.getElementById('browse')?.scrollIntoView({ block: 'start', behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); form?.addEventListener('submit', event => event.preventDefault()); search?.addEventListener('input', () => { state.q = search.value; apply(); }); search?.addEventListener('keydown', event => { if (event.key === 'Escape' && search.value) { event.preventDefault(); state.q = ''; apply(); } }); form?.addEventListener('click', event => { const button = (event.target as Element | null)?.closest?.('button[data-kind], button[data-app], button[data-category]'); if (!button) return; for (const group of ['kind', 'app', 'category'] as const) if (button.dataset[group]) state[group] = button.dataset[group]!; apply(); }); document.querySelector('[data-store-reset]')?.addEventListener('click', reset); // The header's Themes / Plugins links filter this page instead of reloading it. document.addEventListener('click', event => { const link = (event.target as Element | null)?.closest?.('a[data-store-link]'); if (!link || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); state = { q: '', kind: known('kind', link.dataset.storeLink ?? null), app: 'all', category: 'all' }; apply(); toBrowse(); }); // "/" goes to the search box, like the apps' own palettes. document.addEventListener('keydown', event => { const target = event.target as HTMLElement | null; if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey || target?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')) return; event.preventDefault(); search?.focus(); search?.select(); }); apply(); // Arriving with a search or a filter (a shared link, /store/?kind=theme): start at the results. if (window.location.search && !window.location.hash) requestAnimationFrame(() => document.getElementById('browse')?.scrollIntoView({ block: 'start' })); initProgress(); initCopy(); initInstall();