const { invoke } = window.__TAURI__.core; const { getCurrentWindow } = window.__TAURI__.window; const GAP = 1; const WINDOW_WIDTH = 600; const LAYOUT_WEEKS = 53; const SETTINGS_HEIGHT = 350; let config = { username: 'shumengya', token: '', window_x: null, window_y: null, theme: 'dark' }; let isPinned = false; let moveTimer = null; let cellHitMap = []; let lastHeatmapData = null; function getHeatmapColors() { const style = getComputedStyle(document.body); return [0, 1, 2, 3, 4].map((level) => style.getPropertyValue(`--hm-${level}`).trim()); } function computeHeatmapLayout() { const area = document.getElementById('heatmap-area'); const widget = document.querySelector('.widget'); const ws = widget ? getComputedStyle(widget) : null; const padX = ws ? parseFloat(ws.paddingLeft) + parseFloat(ws.paddingRight) : 10; const availW = (area && area.clientWidth > 0) ? area.clientWidth : (WINDOW_WIDTH - padX); const LEFT = 0; const TOP = 0; const gridW = availW; let step = Math.floor((gridW + GAP) / LAYOUT_WEEKS); if (step < GAP + 2) step = GAP + 2; const cell = step - GAP; const cssW = availW; const cssH = 7 * step - GAP; return { LEFT, TOP, STEP: step, CELL: cell, cssW, cssH, radius: Math.max(1, Math.round(cell * 0.22)), }; } function computeWidgetHeight() { const { cssH } = computeHeatmapLayout(); const header = document.querySelector('.header'); const widget = document.querySelector('.widget'); const ws = widget ? getComputedStyle(widget) : null; const padY = ws ? parseFloat(ws.paddingTop) + parseFloat(ws.paddingBottom) : 7; const borderY = ws ? parseFloat(ws.borderTopWidth) + parseFloat(ws.borderBottomWidth) : 2; const headerH = header ? header.offsetHeight : 20; return Math.ceil(borderY + padY + headerH + cssH); } async function ensureWidgetSize() { await setWindowHeight(computeWidgetHeight()); } function applyTheme(theme, rerender = true) { config.theme = theme === 'light' ? 'light' : 'dark'; document.body.classList.toggle('theme-light', config.theme === 'light'); const select = document.getElementById('input-theme'); if (select) select.value = config.theme; if (rerender && lastHeatmapData) { renderHeatmap(lastHeatmapData); } } async function persistTheme() { try { await invoke('save_config', { config }); } catch (_) {} } async function toggleTheme() { applyTheme(config.theme === 'light' ? 'dark' : 'light'); await persistTheme(); } function levelOf(n) { if (n === 0) return 0; if (n <= 3) return 1; if (n <= 8) return 2; if (n <= 15) return 3; return 4; } function localDateStr(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function showLoading() { document.getElementById('status-loading').style.display = 'flex'; document.getElementById('status-error').style.display = 'none'; document.getElementById('heatmap-canvas').style.display = 'none'; document.getElementById('stats-display').textContent = '加载中...'; } function showError(msg) { document.getElementById('status-loading').style.display = 'none'; document.getElementById('status-error').style.display = 'flex'; document.getElementById('error-text').textContent = msg; document.getElementById('heatmap-canvas').style.display = 'none'; document.getElementById('stats-display').textContent = '加载失败'; } function renderHeatmap(calendar) { lastHeatmapData = calendar; document.getElementById('status-loading').style.display = 'none'; document.getElementById('status-error').style.display = 'none'; const total = calendar.totalContributions; document.getElementById('stats-display').textContent = `最近一年贡献 ${total.toLocaleString()} 次`; const canvas = document.getElementById('heatmap-canvas'); canvas.style.display = 'block'; const ctx = canvas.getContext('2d'); const weeks = calendar.weeks.slice(0, LAYOUT_WEEKS); const { LEFT, TOP, STEP, CELL, cssW, cssH, radius } = computeHeatmapLayout(); const dpr = window.devicePixelRatio || 1; canvas.style.width = `${cssW}px`; canvas.style.height = `${cssH}px`; document.getElementById('heatmap-area').style.minHeight = `${cssH}px`; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); // Draw cells and build hit map cellHitMap = []; const colors = getHeatmapColors(); weeks.forEach((week, wi) => { week.contributionDays.forEach((day) => { const x = LEFT + wi * STEP; const y = TOP + day.weekday * STEP; const color = colors[levelOf(day.contributionCount)]; ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, CELL, CELL, radius); ctx.fill(); cellHitMap.push({ x, y, date: day.date, count: day.contributionCount }); }); }); // Tooltip const tooltip = document.getElementById('tooltip'); canvas.onmousemove = (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const hit = cellHitMap.find(c => mx >= c.x && mx < c.x + CELL && my >= c.y && my < c.y + CELL); if (hit) { const dateObj = new Date(hit.date + 'T12:00:00'); const fmt = dateObj.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }); tooltip.textContent = hit.count === 0 ? `${fmt} · 无贡献` : `${fmt} · ${hit.count} 次贡献`; tooltip.style.display = 'block'; tooltip.style.left = `${e.clientX + 12}px`; tooltip.style.top = `${e.clientY - 34}px`; } else { tooltip.style.display = 'none'; } }; canvas.onmouseleave = () => { tooltip.style.display = 'none'; }; ensureWidgetSize().catch(() => {}); } async function fetchData() { if (!config.token) { showError('请点击 ⚙ 设置 GitHub Token(需 read:user 权限)'); return; } showLoading(); try { const result = await invoke('fetch_github_contributions', { username: config.username, token: config.token, }); if (result.errors?.length) { showError(result.errors[0].message); return; } const calendar = result?.data?.user?.contributionsCollection?.contributionCalendar; if (!calendar) { const msg = result?.data?.user ? '无法获取贡献日历数据' : '用户不存在或 Token 权限不足'; showError(msg); return; } renderHeatmap(calendar); } catch (e) { showError('请求失败: ' + String(e)); } } // Window position persistence async function initWindowPos() { if (config.window_x != null && config.window_y != null) { try { const win = getCurrentWindow(); await win.setPosition({ type: 'Physical', x: config.window_x, y: config.window_y }); } catch (_) {} } } async function setWindowHeight(height) { try { await getCurrentWindow().setSize({ type: 'Logical', width: WINDOW_WIDTH, height }); } catch (_) {} } function clearFormError() { document.getElementById('form-error').classList.remove('visible'); } function showFormError() { document.getElementById('form-error').classList.add('visible'); } async function openSettingsModal() { clearFormError(); document.getElementById('input-username').value = config.username; document.getElementById('input-token').value = config.token; document.getElementById('input-theme').value = config.theme === 'light' ? 'light' : 'dark'; await setWindowHeight(SETTINGS_HEIGHT); document.body.classList.add('settings-open'); document.querySelector('.widget').classList.add('mouse-over'); document.getElementById('settings-modal').classList.add('visible'); document.getElementById('input-username').focus(); } async function closeSettingsModal() { clearFormError(); document.getElementById('settings-modal').classList.remove('visible'); document.body.classList.remove('settings-open'); await ensureWidgetSize(); } async function saveSettings() { const username = document.getElementById('input-username').value.trim(); const token = document.getElementById('input-token').value.trim(); const theme = document.getElementById('input-theme').value === 'light' ? 'light' : 'dark'; if (!username) { showFormError(); document.getElementById('input-username').focus(); return; } const newConfig = { ...config, username, token, theme }; try { await invoke('save_config', { config: newConfig }); config = newConfig; applyTheme(config.theme); document.getElementById('username-display').textContent = config.username; await closeSettingsModal(); await fetchData(); } catch (e) { alert('保存失败: ' + e); } } // Settings modal async function togglePin() { isPinned = !isPinned; try { await getCurrentWindow().setAlwaysOnTop(isPinned); } catch (_) {} } async function initTrayActions() { try { const { listen } = window.__TAURI__.event; await listen('tray-action', async ({ payload }) => { switch (payload) { case 'refresh': await fetchData(); break; case 'pin': await togglePin(); break; case 'theme': await toggleTheme(); break; case 'settings': await openSettingsModal(); break; } }); } catch (_) {} } document.getElementById('btn-cancel').addEventListener('click', () => { closeSettingsModal().catch(() => {}); }); document.getElementById('settings-modal').addEventListener('click', (e) => { if (e.target === document.getElementById('settings-modal')) { closeSettingsModal().catch(() => {}); } }); document.getElementById('settings-panel').addEventListener('click', (e) => { e.stopPropagation(); }); document.getElementById('btn-save').addEventListener('click', () => { saveSettings().catch(() => {}); }); document.getElementById('settings-modal').addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closeSettingsModal().catch(() => {}); } else if (e.key === 'Enter' && e.target.tagName === 'INPUT') { e.preventDefault(); saveSettings().catch(() => {}); } }); ['input-username', 'input-token'].forEach((id) => { document.getElementById(id).addEventListener('input', clearFormError); }); function initHeatmapResize() { const area = document.getElementById('heatmap-area'); if (!area || typeof ResizeObserver === 'undefined') return; let lastWidth = 0; const observer = new ResizeObserver(() => { const w = area.clientWidth; if (!w || w === lastWidth || !lastHeatmapData) return; lastWidth = w; renderHeatmap(lastHeatmapData); }); observer.observe(area); } // Window drag (canvas blocks native drag-region; use startDragging as fallback) function initWindowDrag() { document.querySelector('.widget').addEventListener('mousedown', (e) => { if (e.button !== 0) return; if (document.getElementById('settings-modal').classList.contains('visible')) return; if (e.target.closest('input') || e.target.closest('button')) return; getCurrentWindow().startDragging().catch(() => {}); }); } // Save window position on move (async () => { try { const win = getCurrentWindow(); await win.listen('tauri://move', (e) => { clearTimeout(moveTimer); moveTimer = setTimeout(() => { const { x, y } = e.payload; invoke('save_window_pos', { x, y }).catch(() => {}); }, 600); }); } catch (_) {} })(); // Auto refresh every 30 minutes setInterval(fetchData, 30 * 60 * 1000); // Init (async () => { config = await invoke('load_config').catch(() => config); if (!config.theme) config.theme = 'dark'; applyTheme(config.theme, false); document.getElementById('username-display').textContent = config.username; initWindowDrag(); initHeatmapResize(); await initTrayActions(); await ensureWidgetSize(); await initWindowPos(); await fetchData(); })();