'use strict'; /* ================= Translations ================= */ const I18N = { en: { locale: 'en-US', searchAria: 'Search repositories', searchPh: 'Search… log viewer user:grafana NOT electron (press /)', inAria: 'Search in', inAll: 'Everywhere', inName: 'Name', inDesc: 'Description', inReadme: 'README', settings: 'Settings', theme: 'Light / dark theme', close: 'Close', languageAria: 'Language', langAll: 'All languages', starsAria: 'Stars', starsAll: 'Any stars', pushedAria: 'Last push', pushedAll: 'Any activity', pushed1m: 'Pushed < 1 month', pushed6m: 'Pushed < 6 months', pushed1y: 'Pushed < 1 year', pushed2y: 'Pushed < 2 years', createdAria: 'Creation date', createdAll: 'Any creation date', created1m: 'Created < 1 month', created1y: 'Created < 1 year', created5y: 'Created < 5 years', licenseAria: 'License', licenseAll: 'Any license', topicPh: 'topic', topicAria: 'Topic', goodFirst: 'Good first issues', goodFirstTitle: 'Only repositories with issues labeled "good first issue"', withForks: 'Forks', withForksTitle: 'Include forks in the results', withArchived: 'Archived', withArchivedTitle: 'Include archived (read-only) repositories', reset: 'Reset filters', sortAria: 'Sort', sortBest: 'Best match', sortStars: 'Most stars', sortForks: 'Most forks', sortUpdated: 'Recently updated', sortHelp: 'Help wanted issues', export: 'CSV', exportTitle: 'Download the loaded results as CSV', exportNone: 'Nothing to export', exportDone: (f) => `Downloaded: ${f}`, more: 'Load more', moreN: (n) => `Load ${n} more`, emptyTitle: 'No matching repositories.', emptyHint: 'Loosen a filter or change the search words.', count: ({ shown, total }) => `${shown} of ${total} ${total === 1 ? 'repository' : 'repositories'}`, countCapped: 'GitHub only returns the first 1,000 results: refine the search to see the others.', fork: 'fork', archived: 'archived', updated: (s) => `updated ${s}`, openGh: 'Open on GitHub', homepage: 'Website', copyClone: 'Copy clone URL', copyName: 'Copy name', copied: 'Copied', filterLang: 'Click to filter on this language', filterTopic: 'Click to filter on this topic', fFull: 'repository', fCreated: 'created', fPushed: 'last push', fLicense: 'license', fIssues: 'open issues', fForks: 'forks', fStars: 'stars', fClone: 'clone', noLicense: 'none', searchError: 'Search error: ', unreachable: 'server unreachable', connToken: 'GitHub · token', connNoToken: 'GitHub · no token', quota: ({ r, l }) => `${r}/${l} searches left`, quotaReset: (s) => `reset in ${s}s`, ghQueryTitle: 'Query sent to GitHub: click to open it on github.com', err_bad_param: 'Invalid filter value', err_too_far: 'GitHub only returns the first 1,000 results', err_rate_limited: 'GitHub search quota exhausted: wait a minute (or set GITHUB_TOKEN on the server)', err_bad_query: 'GitHub rejected the query', err_github_token: 'GitHub refused the token (GITHUB_TOKEN)', err_github_unreachable: 'Cannot reach GitHub', language: 'Language', tabLocale: 'Localization', tabInterface: 'Interface', themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark', themeHelp: 'System follows the light/dark preference of your computer or phone.', listTitle: 'Result list', density: 'Density', compact: 'Compact', comfortable: 'Comfortable', perPage: 'Results per page', tokenTitle: 'Access token', tokenOn: 'A GitHub token is configured on the server.', tokenOff: 'No GitHub token: searches are anonymous and limited to 10 per minute.', tokenHelp: 'Set GITHUB_TOKEN on the server (a fine-grained token with no permission is enough for public repositories). It raises the quota to 30 searches per minute.', quotaTitle: 'Search quota', quotaUnknown: 'Unknown until the first search.', quotaLine: ({ r, l, s }) => `${r} of ${l} searches left, reset in ${s} s.`, quotaFull: ({ l }) => `${l} searches available.`, quotaHelp: 'Identical searches are served from a 5 minute cache on the server and do not use the quota.', syntaxTitle: 'Search syntax', syntaxHelp: 'The search field accepts GitHub qualifiers, combined with the filters above the list:', synUser: 'repositories of a user', synOrg: 'repositories of an organization', synTopics: 'several topics at once', synPhrase: 'exact phrase', synNot: 'exclude a word', synSize: 'size in KB', }, fr: { locale: 'fr-FR', searchAria: 'Rechercher des dépôts', searchPh: 'Rechercher… log viewer user:grafana NOT electron (touche /)', inAria: 'Chercher dans', inAll: 'Partout', inName: 'Nom', inDesc: 'Description', inReadme: 'README', settings: 'Paramètres', theme: 'Thème clair / sombre', close: 'Fermer', languageAria: 'Langage', langAll: 'Tous les langages', starsAria: 'Étoiles', starsAll: 'Toutes étoiles', pushedAria: 'Dernier push', pushedAll: 'Toute activité', pushed1m: 'Push < 1 mois', pushed6m: 'Push < 6 mois', pushed1y: 'Push < 1 an', pushed2y: 'Push < 2 ans', createdAria: 'Date de création', createdAll: 'Toute date de création', created1m: 'Créé < 1 mois', created1y: 'Créé < 1 an', created5y: 'Créé < 5 ans', licenseAria: 'Licence', licenseAll: 'Toute licence', topicPh: 'topic', topicAria: 'Topic', goodFirst: 'Good first issues', goodFirstTitle: 'Seulement les dépôts avec des issues « good first issue »', withForks: 'Forks', withForksTitle: 'Inclure les forks dans les résultats', withArchived: 'Archivés', withArchivedTitle: 'Inclure les dépôts archivés (lecture seule)', reset: 'Réinitialiser les filtres', sortAria: 'Tri', sortBest: 'Pertinence', sortStars: 'Plus d\'étoiles', sortForks: 'Plus de forks', sortUpdated: 'Mis à jour récemment', sortHelp: 'Issues « help wanted »', export: 'CSV', exportTitle: 'Télécharger les résultats chargés en CSV', exportNone: 'Rien à exporter', exportDone: (f) => `Téléchargé : ${f}`, more: 'Charger la suite', moreN: (n) => `Charger ${n} de plus`, emptyTitle: 'Aucun dépôt ne correspond.', emptyHint: 'Assouplissez un filtre ou changez les mots recherchés.', count: ({ shown, total }) => `${shown} sur ${total} dépôt${total > 1 ? 's' : ''}`, countCapped: 'GitHub ne renvoie que les 1 000 premiers résultats : affinez la recherche pour voir les autres.', fork: 'fork', archived: 'archivé', updated: (s) => `mis à jour ${s}`, openGh: 'Ouvrir sur GitHub', homepage: 'Site web', copyClone: 'Copier l\'URL de clone', copyName: 'Copier le nom', copied: 'Copié', filterLang: 'Cliquer pour filtrer sur ce langage', filterTopic: 'Cliquer pour filtrer sur ce topic', fFull: 'dépôt', fCreated: 'créé', fPushed: 'dernier push', fLicense: 'licence', fIssues: 'issues ouvertes', fForks: 'forks', fStars: 'étoiles', fClone: 'clone', noLicense: 'aucune', searchError: 'Erreur de recherche : ', unreachable: 'serveur injoignable', connToken: 'GitHub · jeton', connNoToken: 'GitHub · sans jeton', quota: ({ r, l }) => `${r}/${l} recherches restantes`, quotaReset: (s) => `remise à zéro dans ${s} s`, ghQueryTitle: 'Requête envoyée à GitHub : cliquer pour l\'ouvrir sur github.com', err_bad_param: 'Valeur de filtre invalide', err_too_far: 'GitHub ne renvoie que les 1 000 premiers résultats', err_rate_limited: 'Quota de recherche GitHub épuisé : attendez une minute (ou définissez GITHUB_TOKEN sur le serveur)', err_bad_query: 'GitHub a refusé la requête', err_github_token: 'GitHub a refusé le jeton (GITHUB_TOKEN)', err_github_unreachable: 'GitHub est injoignable', language: 'Langue', tabLocale: 'Localisation', tabInterface: 'Interface', themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre', themeHelp: 'Système suit la préférence clair/sombre de votre ordinateur ou téléphone.', listTitle: 'Liste des résultats', density: 'Densité', compact: 'Compacte', comfortable: 'Confortable', perPage: 'Résultats par page', tokenTitle: 'Jeton d\'accès', tokenOn: 'Un jeton GitHub est configuré sur le serveur.', tokenOff: 'Pas de jeton GitHub : les recherches sont anonymes et limitées à 10 par minute.', tokenHelp: 'Définissez GITHUB_TOKEN sur le serveur (un jeton « fine-grained » sans aucune permission suffit pour les dépôts publics). Le quota passe à 30 recherches par minute.', quotaTitle: 'Quota de recherche', quotaUnknown: 'Inconnu avant la première recherche.', quotaLine: ({ r, l, s }) => `${r} recherches restantes sur ${l}, remise à zéro dans ${s} s.`, quotaFull: ({ l }) => `${l} recherches disponibles.`, quotaHelp: 'Les recherches identiques sont servies par un cache de 5 minutes sur le serveur et ne consomment pas de quota.', syntaxTitle: 'Syntaxe de recherche', syntaxHelp: 'Le champ de recherche accepte les qualificatifs GitHub, combinés aux filtres au-dessus de la liste :', synUser: 'dépôts d\'un utilisateur', synOrg: 'dépôts d\'une organisation', synTopics: 'plusieurs topics à la fois', synPhrase: 'expression exacte', synNot: 'exclure un mot', synSize: 'taille en Ko', }, }; /* ================= Utilities ================= */ const $ = (sel) => document.querySelector(sel); const store = { get(k, def) { try { const v = localStorage.getItem('searchgit.' + k); return v === null ? def : v; } catch { return def; } }, set(k, v) { try { localStorage.setItem('searchgit.' + k, v); } catch { /* storage unavailable */ } }, }; const defaultLang = (navigator.language || '').toLowerCase().startsWith('fr') ? 'fr' : 'en'; let lang = I18N[store.get('lang', defaultLang)] ? store.get('lang', defaultLang) : 'en'; function t(key, arg) { const v = I18N[lang][key] ?? I18N.en[key] ?? key; return typeof v === 'function' ? v(arg) : v; } const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]); const debounce = (fn, ms) => { let tm; return (...a) => { clearTimeout(tm); tm = setTimeout(() => fn(...a), ms); }; }; const fmtNum = (n) => Number(n || 0).toLocaleString(t('locale')); const fmtCompact = (n) => new Intl.NumberFormat(t('locale'), { notation: 'compact', maximumFractionDigits: 1 }).format(n || 0); const fmtDate = (s) => new Date(s).toLocaleDateString(t('locale'), { year: 'numeric', month: 'short', day: 'numeric' }); function fmtAgo(s) { const sec = (new Date(s) - Date.now()) / 1000; const rtf = new Intl.RelativeTimeFormat(t('locale'), { numeric: 'auto' }); const steps = [[60, 'second'], [3600, 'minute'], [86400, 'hour'], [2592000, 'day'], [31536000, 'month'], [Infinity, 'year']]; const div = { second: 1, minute: 60, hour: 3600, day: 86400, month: 2592000, year: 31536000 }; for (const [limit, unit] of steps) { if (Math.abs(sec) < limit) return rtf.format(Math.round(sec / div[unit]), unit); } return ''; } async function api(url) { const res = await fetch(url); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ } if (!res.ok) throw apiError(res, text, data); return data; } // Known error codes are translated; otherwise the server message is shown. function apiError(res, text, data) { let msg = (data && data.error) || text || res.statusText; if (data && data.code && I18N[lang]['err_' + data.code]) { msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : ''); } return new Error(msg); } function toast(msg) { const el = $('#toast'); const dlg = $('#settingsDlg'); (dlg.open ? dlg : document.body).append(el); el.textContent = msg; el.hidden = false; clearTimeout(toast.tm); toast.tm = setTimeout(() => { el.hidden = true; }, 1800); } async function copy(text) { try { await navigator.clipboard.writeText(text); toast(t('copied')); } catch { /* clipboard refused */ } } /* ================= Languages (GitHub linguist colors) ================= */ const LANGS = { 'Go': '#00ADD8', 'Rust': '#dea584', 'Python': '#3572A5', 'JavaScript': '#f1e05a', 'TypeScript': '#3178c6', 'Java': '#b07219', 'Kotlin': '#A97BFF', 'C': '#555555', 'C++': '#f34b7d', 'C#': '#178600', 'PHP': '#4F5D95', 'Ruby': '#701516', 'Swift': '#F05138', 'Dart': '#00B4AB', 'Scala': '#c22d40', 'Elixir': '#6e4a7e', 'Haskell': '#5e5086', 'Lua': '#000080', 'Zig': '#ec915c', 'Nim': '#ffc200', 'Shell': '#89e051', 'PowerShell': '#012456', 'HTML': '#e34c26', 'CSS': '#663399', 'Vue': '#41b883', 'Svelte': '#ff3e00', 'Jupyter Notebook': '#DA5B0B', 'R': '#198CE7', 'Julia': '#a270ba', 'OCaml': '#ef7a08', 'Clojure': '#db5855', 'Erlang': '#B83998', 'Perl': '#0298c3', 'Nix': '#7e7eff', 'HCL': '#844FBA', 'Dockerfile': '#384d54', 'Vim Script': '#199f4b', 'Emacs Lisp': '#c065db', }; { const sel = $('#language'); for (const name of Object.keys(LANGS).sort((a, b) => a.localeCompare(b))) sel.add(new Option(name, name)); } /* ================= State and filters ================= */ const FILTERS = ['q', 'in', 'language', 'stars', 'pushed', 'created', 'license', 'topic', 'sort']; const TOGGLES = ['goodfirst', 'forks', 'archived']; const state = { items: [], total: 0, page: 1, perPage: Number(store.get('perPage', '30')) || 30, reqId: 0, open: new Set(), // fullName of expanded rows token: null, rate: null, query: '', }; function readFilters() { const f = {}; for (const k of FILTERS) f[k] = $('#' + k).value.trim(); for (const k of TOGGLES) f[k] = $('#' + k).getAttribute('aria-pressed') === 'true'; return f; } function writeFilters(f) { for (const k of FILTERS) { const el = $('#' + k); let v = f[k] || ''; if (el.tagName === 'SELECT' && v && ![...el.options].some((o) => o.value === v)) { el.add(new Option(v, v)); // e.g. a language missing from the list } el.value = v; } for (const k of TOGGLES) $('#' + k).setAttribute('aria-pressed', String(!!f[k])); markSet(); } // Highlight the filters that are not at their default value. function markSet() { let any = false; for (const k of FILTERS) { if (k === 'q' || k === 'sort') continue; const el = $('#' + k); const on = !!el.value; el.classList.toggle('set', on); any ||= on; } any ||= TOGGLES.some((k) => $('#' + k).getAttribute('aria-pressed') === 'true'); $('#resetBtn').hidden = !any; } function params(f, page) { const p = new URLSearchParams(); for (const k of FILTERS) if (f[k]) p.set(k, f[k]); for (const k of TOGGLES) if (f[k]) p.set(k, '1'); if (page) { p.set('page', page); p.set('per_page', state.perPage); } return p; } // Filters live in the URL so a search can be bookmarked or shared. function syncURL(f) { const qs = params(f).toString(); history.replaceState(null, '', qs ? '?' + qs : location.pathname); store.set('filters', qs); } function filtersFromURL() { const src = location.search ? new URLSearchParams(location.search) : new URLSearchParams(store.get('filters', '')); const f = {}; for (const k of FILTERS) f[k] = src.get(k) || ''; for (const k of TOGGLES) f[k] = src.get(k) === '1'; return f; } /* ================= Search ================= */ async function search(more = false) { const f = readFilters(); if (!more) { syncURL(f); state.page = 1; } const page = more ? state.page + 1 : 1; const id = ++state.reqId; document.body.classList.add('busy'); $('#moreBtn').disabled = true; try { const res = await api('/api/search?' + params(f, page)); if (id !== state.reqId) return; setError(''); state.page = page; state.total = res.total; state.query = res.query; if (res.rate && res.rate.limit) state.rate = res.rate; if (more) { const known = new Set(state.items.map((r) => r.fullName)); state.items.push(...res.items.filter((r) => !known.has(r.fullName))); } else { state.items = res.items; state.open.clear(); window.scrollTo({ top: 0 }); } renderList(); renderStatus(); } catch (e) { if (id !== state.reqId) return; setError(t('searchError') + e.message); if (!more) { state.items = []; state.total = 0; renderList(); } } finally { if (id === state.reqId) document.body.classList.remove('busy'); $('#moreBtn').disabled = false; } } function setError(msg) { $('#error').textContent = msg; $('#error').hidden = !msg; } /* ================= Rendering ================= */ const ICON_STAR = ''; const ICON_FORK = ''; const ICON_CLOCK = ''; const ICON_EXT = ''; function rowHTML(r, i) { const open = state.open.has(r.fullName); const badges = (r.archived ? `${esc(t('archived'))}` : '') + (r.fork ? `${esc(t('fork'))}` : ''); const topics = r.topics.length ? `
${r.topics.map((x) => ``).join('')}
` : ''; const lang = r.language ? `${esc(r.language)}` : ''; return `
${r.description ? `

${esc(r.description)}

` : ''} ${topics}
${lang} ${ICON_STAR}${fmtCompact(r.stars)} ${ICON_FORK}${fmtCompact(r.forks)} ${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))}
${open ? detailsHTML(r) : ''}
`; } function detailsHTML(r) { const clone = r.url + '.git'; return `
${esc(t('fFull'))}
${esc(r.fullName)}
${esc(t('fStars'))}
${esc(fmtNum(r.stars))}
${esc(t('fForks'))}
${esc(fmtNum(r.forks))}
${esc(t('fIssues'))}
${esc(fmtNum(r.issues))}
${esc(t('fLicense'))}
${esc(r.license || t('noLicense'))}
${esc(t('fCreated'))}
${esc(fmtDate(r.createdAt))}
${esc(t('fPushed'))}
${esc(fmtDate(r.pushedAt))} (${esc(fmtAgo(r.pushedAt))})
${esc(t('fClone'))}
${esc(clone)}
${ICON_EXT}${esc(t('openGh'))} ${r.homepage ? `${ICON_EXT}${esc(t('homepage'))}` : ''}
`; } function renderList() { $('#list').innerHTML = state.items.map(rowHTML).join(''); $('#empty').hidden = state.items.length > 0 || !$('#error').hidden; const reachable = Math.min(state.total, 1000); const left = reachable - state.items.length; $('#more').hidden = left <= 0; $('#moreBtn').textContent = t('moreN', fmtNum(Math.min(left, state.perPage))); const c = $('#count'); c.textContent = state.items.length || state.total ? t('count', { shown: fmtNum(state.items.length), total: fmtNum(state.total) }) : ''; c.title = state.total > 1000 ? t('countCapped') : ''; } // Last known quota; once the reset time has passed, GitHub has refilled it. function currentRate() { const r = state.rate; if (!r || !r.limit) return null; const s = Math.round((new Date(r.reset) - Date.now()) / 1000); return s > 0 ? { ...r, s } : { ...r, remaining: r.limit, s: 0 }; } function renderStatus() { const conn = $('#conn'); if (state.token === null) { conn.className = 'conn ko'; conn.textContent = t('unreachable'); } else { const r = currentRate(); let txt = t(state.token ? 'connToken' : 'connNoToken'); let cls = state.token ? 'ok' : 'warn'; if (r) { txt += ' · ' + t('quota', { r: r.remaining, l: r.limit }); if (r.remaining === 0) { cls = 'ko'; txt += ' · ' + t('quotaReset', r.s); } } conn.className = 'conn ' + cls; conn.textContent = txt; } const a = $('#ghQuery'); a.hidden = !state.query; if (state.query) { a.textContent = state.query; a.title = t('ghQueryTitle'); a.href = 'https://github.com/search?type=repositories&q=' + encodeURIComponent(state.query); } renderGitHubSettings(); } async function loadStatus() { try { const s = await api('/api/status'); state.token = s.token; if (s.rate && s.rate.limit) state.rate = s.rate; } catch { state.token = null; } renderStatus(); } /* ================= List interactions ================= */ $('#list').addEventListener('click', (ev) => { const rowEl = ev.target.closest('.row'); if (!rowEl) return; const r = state.items[Number(rowEl.dataset.i)]; const topic = ev.target.closest('[data-topic]'); if (topic) { $('#topic').value = topic.dataset.topic; onFilterChange(); return; } if (ev.target.closest('[data-act="lang"]')) { writeFilters({ ...readFilters(), language: r.language }); onFilterChange(); return; } const cp = ev.target.closest('[data-copy]'); if (cp) { copy(cp.dataset.copy); return; } if (ev.target.closest('a, .details')) return; toggleRow(rowEl, r); }); $('#list').addEventListener('keydown', (ev) => { if (ev.key !== 'Enter' || !ev.target.classList.contains('row')) return; toggleRow(ev.target, state.items[Number(ev.target.dataset.i)]); }); function toggleRow(rowEl, r) { if (state.open.has(r.fullName)) state.open.delete(r.fullName); else state.open.add(r.fullName); const i = Number(rowEl.dataset.i); rowEl.outerHTML = rowHTML(r, i); $(`#list .row[data-i="${i}"]`).focus({ preventScroll: true }); } /* ================= Filter events ================= */ const onFilterChange = () => { markSet(); search(); }; const onTyping = debounce(onFilterChange, 500); // spare the GitHub quota while typing for (const k of FILTERS) { const el = $('#' + k); el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', el.tagName === 'SELECT' ? onFilterChange : onTyping); } for (const k of TOGGLES) { $('#' + k).addEventListener('click', (ev) => { const b = ev.currentTarget; b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true')); onFilterChange(); }); } $('#searchForm').addEventListener('submit', (ev) => { ev.preventDefault(); onFilterChange(); }); $('#topic').addEventListener('keydown', (ev) => { if (ev.key === 'Enter') onFilterChange(); }); $('#resetBtn').addEventListener('click', () => { const f = readFilters(); writeFilters({ q: f.q, sort: f.sort }); onFilterChange(); }); $('#moreBtn').addEventListener('click', () => search(true)); document.addEventListener('keydown', (ev) => { if (ev.key === '/' && !ev.target.closest('input, select, textarea') && !$('#settingsDlg').open) { ev.preventDefault(); $('#q').focus(); $('#q').select(); } }); /* ================= CSV export ================= */ $('#exportBtn').addEventListener('click', () => { if (!state.items.length) { toast(t('exportNone')); return; } // French Excel expects semicolons. const sep = lang === 'fr' ? ';' : ','; const cell = (v) => { const s = String(v ?? ''); return /["\n\r;,]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const head = ['repository', 'url', 'description', 'language', 'stars', 'forks', 'open_issues', 'license', 'topics', 'archived', 'fork', 'created', 'pushed']; const lines = [head.join(sep)]; for (const r of state.items) { lines.push([r.fullName, r.url, r.description, r.language, r.stars, r.forks, r.issues, r.license, r.topics.join(' '), r.archived, r.fork, r.createdAt, r.pushedAt].map(cell).join(sep)); } const name = 'searchgit-' + new Date().toISOString().slice(0, 10) + '.csv'; const blob = new Blob(['' + lines.join('\r\n') + '\r\n'], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); toast(t('exportDone', name)); }); /* ================= Language ================= */ function applyLang() { document.documentElement.lang = lang; for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle); for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria)); for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh); for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang)); } function setLang(next) { if (next === lang || !I18N[next]) return; lang = next; store.set('lang', lang); applyLang(); renderList(); renderStatus(); } $('#langSwitch').addEventListener('click', (ev) => { const b = ev.target.closest('[data-lang]'); if (b) setLang(b.dataset.lang); }); /* ================= Theme ================= */ function effectiveTheme() { const th = document.documentElement.dataset.theme; if (th) return th; return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } // mode: 'system' (follow the OS), 'light' or 'dark'. function setTheme(mode) { if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode; else { delete document.documentElement.dataset.theme; mode = 'system'; } store.set('theme', mode); renderInterface(); } $('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark')); $('#themeSwitch').addEventListener('click', (ev) => { const b = ev.target.closest('[data-theme-opt]'); if (b) setTheme(b.dataset.themeOpt); }); /* ================= Interface settings ================= */ function applyDensity(d) { if (d !== 'compact') d = 'comfortable'; document.documentElement.dataset.density = d; store.set('density', d); } function renderInterface() { const mode = document.documentElement.dataset.theme || 'system'; for (const b of document.querySelectorAll('#themeSwitch [data-theme-opt]')) b.setAttribute('aria-checked', String(b.dataset.themeOpt === mode)); for (const b of document.querySelectorAll('#densitySwitch [data-density]')) b.setAttribute('aria-checked', String(b.dataset.density === document.documentElement.dataset.density)); for (const b of document.querySelectorAll('#perPageSwitch [data-per-page]')) b.setAttribute('aria-checked', String(Number(b.dataset.perPage) === state.perPage)); } $('#densitySwitch').addEventListener('click', (ev) => { const b = ev.target.closest('[data-density]'); if (!b) return; applyDensity(b.dataset.density); renderInterface(); }); $('#perPageSwitch').addEventListener('click', (ev) => { const b = ev.target.closest('[data-per-page]'); if (!b) return; state.perPage = Number(b.dataset.perPage); store.set('perPage', String(state.perPage)); renderInterface(); search(); }); function renderGitHubSettings() { const tok = $('#tokenStatus'); tok.className = 'state-line ' + (state.token ? 'good' : 'warn'); tok.textContent = t(state.token ? 'tokenOn' : 'tokenOff'); const q = $('#quotaStatus'); const r = currentRate(); if (r) { q.className = 'state-line ' + (r.remaining > 0 ? 'good' : 'bad'); q.textContent = t(r.s ? 'quotaLine' : 'quotaFull', { r: r.remaining, l: r.limit, s: r.s }); } else { q.className = 'state-line'; q.textContent = t('quotaUnknown'); } } /* ================= Settings dialog ================= */ function showSettingsTab(name) { const tabs = [...document.querySelectorAll('#setNav [data-tab]')]; if (!tabs.some((b) => b.dataset.tab === name)) name = 'locale'; for (const b of tabs) b.setAttribute('aria-selected', String(b.dataset.tab === name)); for (const p of document.querySelectorAll('.set-panel')) p.hidden = p.dataset.panel !== name; store.set('settingsTab', name); } $('#setNav').addEventListener('click', (ev) => { const b = ev.target.closest('[data-tab]'); if (b) showSettingsTab(b.dataset.tab); }); $('#settingsBtn').addEventListener('click', () => { renderInterface(); loadStatus(); showSettingsTab(store.get('settingsTab', 'locale')); $('#settingsDlg').showModal(); }); $('#closeSettings').addEventListener('click', () => $('#settingsDlg').close()); $('#settingsDlg').addEventListener('click', (ev) => { if (ev.target === ev.currentTarget) ev.currentTarget.close(); }); /* ================= Startup ================= */ applyDensity(store.get('density', 'comfortable')); applyLang(); renderInterface(); writeFilters(filtersFromURL()); loadStatus(); search(); setInterval(() => { if (!document.hidden) renderStatus(); }, 5000); // reset countdown