Interface : recherche, filtres, liste, réglages, thème et export CSV
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
707962d5e4
commit
de88cd7b06
1 file changed
+674
+674
@@ -0,0 +1,674 @@
|
|||||||
|
'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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.7 5.6 6.1.8-4.5 4.3 1.1 6.1L12 16.9l-5.4 2.9 1.1-6.1-4.5-4.3 6.1-.8z"/></svg>';
|
||||||
|
const ICON_FORK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V7M12 12v5"/></svg>';
|
||||||
|
const ICON_CLOCK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||||
|
const ICON_EXT = '<svg viewBox="0 0 24 24" aria-hidden="true" width="14" height="14"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>';
|
||||||
|
|
||||||
|
function rowHTML(r, i) {
|
||||||
|
const open = state.open.has(r.fullName);
|
||||||
|
const badges = (r.archived ? `<span class="badge archived">${esc(t('archived'))}</span>` : '')
|
||||||
|
+ (r.fork ? `<span class="badge">${esc(t('fork'))}</span>` : '');
|
||||||
|
const topics = r.topics.length
|
||||||
|
? `<div class="topics">${r.topics.map((x) => `<button type="button" class="topic" data-topic="${esc(x)}" title="${esc(t('filterTopic'))}">${esc(x)}</button>`).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
const lang = r.language
|
||||||
|
? `<span class="lang" data-act="lang" title="${esc(t('filterLang'))}" style="cursor:pointer"><i class="lang-dot" style="--c:${LANGS[r.language] || 'var(--muted)'}"></i>${esc(r.language)}</span>`
|
||||||
|
: '<span></span>';
|
||||||
|
return `<div class="row${open ? ' open' : ''}" tabindex="0" data-i="${i}">
|
||||||
|
<img class="av" src="${esc(r.avatar)}${r.avatar.includes('?') ? '&' : '?'}s=72" alt="" loading="lazy">
|
||||||
|
<div class="main">
|
||||||
|
<div class="name"><a href="${esc(r.url)}" target="_blank" rel="noopener"><span class="owner">${esc(r.owner)}/</span>${esc(r.name)}</a>${badges}</div>
|
||||||
|
${r.description ? `<p class="desc">${esc(r.description)}</p>` : ''}
|
||||||
|
${topics}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
${lang}
|
||||||
|
<span class="stars" title="${esc(fmtNum(r.stars))} ${esc(t('fStars'))}">${ICON_STAR}${fmtCompact(r.stars)}</span>
|
||||||
|
<span title="${esc(fmtNum(r.forks))} ${esc(t('fForks'))}">${ICON_FORK}${fmtCompact(r.forks)}</span>
|
||||||
|
<span title="${esc(t('fPushed'))} ${esc(fmtDate(r.pushedAt))}">${ICON_CLOCK}${esc(fmtAgo(r.pushedAt))}</span>
|
||||||
|
</div>
|
||||||
|
${open ? detailsHTML(r) : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailsHTML(r) {
|
||||||
|
const clone = r.url + '.git';
|
||||||
|
return `<div class="details">
|
||||||
|
<dl>
|
||||||
|
<dt>${esc(t('fFull'))}</dt><dd>${esc(r.fullName)}</dd>
|
||||||
|
<dt>${esc(t('fStars'))}</dt><dd>${esc(fmtNum(r.stars))}</dd>
|
||||||
|
<dt>${esc(t('fForks'))}</dt><dd>${esc(fmtNum(r.forks))}</dd>
|
||||||
|
<dt>${esc(t('fIssues'))}</dt><dd>${esc(fmtNum(r.issues))}</dd>
|
||||||
|
<dt>${esc(t('fLicense'))}</dt><dd>${esc(r.license || t('noLicense'))}</dd>
|
||||||
|
<dt>${esc(t('fCreated'))}</dt><dd>${esc(fmtDate(r.createdAt))}</dd>
|
||||||
|
<dt>${esc(t('fPushed'))}</dt><dd>${esc(fmtDate(r.pushedAt))} (${esc(fmtAgo(r.pushedAt))})</dd>
|
||||||
|
<dt>${esc(t('fClone'))}</dt><dd>${esc(clone)}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="acts">
|
||||||
|
<a class="btn" href="${esc(r.url)}" target="_blank" rel="noopener">${ICON_EXT}${esc(t('openGh'))}</a>
|
||||||
|
${r.homepage ? `<a class="btn" href="${esc(r.homepage)}" target="_blank" rel="noopener">${ICON_EXT}${esc(t('homepage'))}</a>` : ''}
|
||||||
|
<button class="btn" type="button" data-copy="${esc(clone)}">${esc(t('copyClone'))}</button>
|
||||||
|
<button class="btn" type="button" data-copy="${esc(r.fullName)}">${esc(t('copyName'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
Reference in new issue
Block a user