-
${act.activity_name || t('feed.noName')}
-
${act.athlete_name}
- ${statsHtml}
-
-
- `;
-
- // Open activity on Strava when clicking anywhere on the card
- const activityUrl = `https://www.strava.com/activities/${act.activity_id}`;
- card.addEventListener('click', (e) => {
- // Don't navigate when the kudos button was clicked
- if (e.target.closest('.feed-kudo-btn')) return;
- window.open(activityUrl, '_blank', 'noopener,noreferrer');
- });
-
- // Kudos button handler
- const kudosBtn = card.querySelector('.feed-kudo-btn');
- if (kudosBtn) {
- kudosBtn.addEventListener('click', async (e) => {
- e.stopPropagation();
- kudosBtn.disabled = true;
- kudosBtn.textContent = t('feed.kudo.giving');
- try {
- const res = await fetchJson(`/api/kudos/${act.activity_id}`, { method: 'POST' });
- if (res.ok) {
- // Keep in-memory activity in sync for future re-renders
- act.has_kudoed = true;
- act.give_kudos = false;
- act.reason = 'already';
-
- // Update card state class immediately
- card.className = 'feed-card feed-state-kudoed';
-
- // Update badge to "done" and remove button
- const badge = card.querySelector('.feed-kudo-badge');
- if (badge) {
- badge.className = 'feed-kudo-badge feed-kudo-done';
- badge.textContent = t('feed.kudo.done');
- }
-
- // Update decision label
- const decisionEl = card.querySelector('.feed-decision');
- if (decisionEl) {
- decisionEl.className = 'feed-decision feed-decision-skip';
- decisionEl.textContent = t('feed.decision.skip', { reason: t('reason.already') });
- }
-
- kudosBtn.remove();
- } else {
- kudosBtn.disabled = false;
- kudosBtn.textContent = t('feed.kudo.give');
- }
- } catch {
- kudosBtn.disabled = false;
- kudosBtn.textContent = t('feed.kudo.give');
- }
- });
- }
-
- container.appendChild(card);
- });
-}
-
-function initFeedTab() {
- const btn = $('btn-refresh-feed');
- if (btn) btn.addEventListener('click', () => loadFeed(true));
-
- // Status filter buttons
- const statusGroup = document.getElementById('feed-filter-status');
- if (statusGroup) {
- statusGroup.addEventListener('click', (e) => {
- const target = e.target.closest('.feed-filter-btn');
- if (!target) return;
- statusGroup.querySelectorAll('.feed-filter-btn').forEach(b => b.classList.remove('active'));
- target.classList.add('active');
- feedFilter.status = target.dataset.status;
- renderFeed();
- });
- }
-
- // Live text search
- const textInput = $('feed-filter-text');
- if (textInput) {
- textInput.addEventListener('input', () => {
- feedFilter.text = textInput.value;
- renderFeed();
- });
- }
-
- // Sport type filter
- const sportSel = $('feed-filter-sport');
- if (sportSel) {
- sportSel.addEventListener('change', () => {
- feedFilter.sport = sportSel.value;
- renderFeed();
- });
- }
-}
-
-// ── Run buttons ───────────────────────────────────────────────────────────────
-
-/**
- * Fire a run request and hand off spinner ownership to pollStatus().
- *
- * The spinner is shown immediately and is NOT cleared in the finally-block —
- * pollStatus() clears it once a new lastRun.finished_at appears (i.e. when
- * the background run actually completes). On error (e.g. 409 already running)
- * the spinner is cleared right away since there is nothing to wait for.
- */
-async function startRun(btn, url) {
- if (runningButton) return; // double-click guard
- if ($('globalDryRun')?.checked) toast(t('toast.dryRunHint'), 'info');
- runningButton = btn;
- runStartStamp = currentLastRunStamp;
- setButtonLoading(btn, true);
- $('btn-run').disabled = true;
- try {
- await fetchJson(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: '{}',
- });
- // Switch to the Log tab — this starts the 3-second poller which will
- // eventually detect the new finished_at and clear the spinner.
- document.querySelector('.tab[data-tab="log"]').click();
- } catch (err) {
- toast(err.message, 'error');
- // No run was started — restore the button immediately.
- setButtonLoading(btn, false);
- runningButton = null;
- $('btn-run').disabled = false;
- }
-}
-
-function initRunButtons() {
- const btnRun = $('btn-run');
- btnRun.addEventListener('click', () => {
- const url = $('globalDryRun')?.checked ? '/api/run?dryRun=1' : '/api/run';
- startRun(btnRun, url);
- });
-}
-
-// ── Password reveal ───────────────────────────────────────────────────────────
-
-function initRevealButtons() {
- document.querySelectorAll('[data-reveal]').forEach(btn => {
- btn.addEventListener('click', () => {
- const input = $(btn.dataset.reveal);
- if (!input) return;
- const hidden = input.type === 'password';
- input.type = hidden ? 'text' : 'password';
- btn.textContent = hidden ? '🙈' : '👁';
- });
- });
-}
-
-// ── Init ──────────────────────────────────────────────────────────────────────
-
-async function init() {
- try {
- [sportTypes, sportCategories] = await Promise.all([
- fetchJson('/api/sport-types'),
- fetchJson('/api/sport-categories'),
- ]);
- } catch {
- sportTypes = [];
- sportCategories = {};
- }
-
- // Apply static translations for the initial language
- applyStaticTranslations();
-
- initLangSelect();
- initTabs();
- initConfigTab();
- initSettingsTab();
- initFeedTab();
- initRunButtons();
- initRevealButtons();
- initAthleteSearchModal();
-
- await Promise.allSettled([
- loadConfig().catch(err => toast(t('toast.config.loadError', { msg: err.message }), 'error')),
- loadSettings().catch(err => toast(t('toast.settings.loadError', { msg: err.message }), 'error')),
- ]);
- _autoSaveEnabled = true; // Enable auto-save only after initial data is fully loaded
-
- await pollStatus();
- setInterval(pollStatus, 10000);
-}
-
-init().catch(err => console.error('[init]', err));
diff --git a/src/kudosy/static/athletes.js b/src/kudosy/static/athletes.js
new file mode 100644
index 0000000..a54aa52
--- /dev/null
+++ b/src/kudosy/static/athletes.js
@@ -0,0 +1,215 @@
+// ── Kudosy UI — athletes.js ──────────────────────────────────────────────────
+// Athlete managed-list rows and the athlete-search modal.
+
+import { $, toast, makeRemoveBtn } from './dom.js';
+import { fetchJson } from './api.js';
+import { t } from './i18n.js';
+import { state } from './state.js';
+
+// ── Athlete managed-list helpers ─────────────────────────────────────────────
+// Each athlete row has: avatar, name/id display, Allow/Deny switch, remove btn.
+// The switch state determines which list (allowAthletes / ignoreAthletes) the ID
+// goes into when the config is saved.
+
+/**
+ * Add an athlete to the unified management list.
+ * @param {HTMLElement} listEl - the