Files
git-heatmap/github/frontend/main.js
2026-06-23 21:57:49 +08:00

392 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
})();