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

443 lines
15 KiB
JavaScript

const { invoke } = window.__TAURI__.core;
const { getCurrentWindow } = window.__TAURI__.window;
const GAP = 1;
const WINDOW_WIDTH = 600;
const LAYOUT_WEEKS = 53;
const SETTINGS_HEIGHT = 430;
// Gitea-themed green palette (matches CSS variables)
let config = { server: 'https://git.shumengya.top', 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.weeks, lastHeatmapData.totalContributions);
}
}
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 = '加载失败';
}
/**
* Gitea heatmap API returns: [{timestamp: unix_sec, contributions: n}, ...]
* Build a full 52-week calendar grid from this sparse data.
*/
function buildWeeksFromHeatmap(items) {
// Build date → count map
const contribMap = new Map();
let totalContributions = 0;
for (const item of items) {
const date = new Date(item.timestamp * 1000);
const dateStr = localDateStr(date);
const count = item.contributions ?? 0;
contribMap.set(dateStr, (contribMap.get(dateStr) || 0) + count);
totalContributions += count;
}
const today = new Date();
const todayStr = localDateStr(today);
// Start from 52 weeks ago, aligned to Sunday
const start = new Date(today);
start.setDate(start.getDate() - 364);
const dow = start.getDay();
if (dow !== 0) start.setDate(start.getDate() - dow);
const weeks = [];
const cur = new Date(start);
while (true) {
const week = [];
for (let d = 0; d < 7; d++) {
const dateStr = localDateStr(cur);
week.push({
date: dateStr,
count: contribMap.get(dateStr) || 0,
weekday: d,
isFuture: dateStr > todayStr,
});
cur.setDate(cur.getDate() + 1);
}
weeks.push(week);
if (localDateStr(cur) > todayStr) break;
if (weeks.length >= LAYOUT_WEEKS) break;
}
return { weeks, totalContributions };
}
function renderHeatmap(weeks, totalContributions) {
lastHeatmapData = { weeks, totalContributions };
document.getElementById('status-loading').style.display = 'none';
document.getElementById('status-error').style.display = 'none';
document.getElementById('stats-display').textContent =
`最近一年贡献 ${totalContributions.toLocaleString()}`;
const canvas = document.getElementById('heatmap-canvas');
canvas.style.display = 'block';
const ctx = canvas.getContext('2d');
const displayWeeks = 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
cellHitMap = [];
const colors = getHeatmapColors();
displayWeeks.forEach((week, wi) => {
week.forEach((day) => {
if (day.isFuture) return;
const x = LEFT + wi * STEP;
const y = TOP + day.weekday * STEP;
const color = colors[levelOf(day.count)];
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(x, y, CELL, CELL, radius);
ctx.fill();
cellHitMap.push({ x, y, date: day.date, count: day.count });
});
});
// 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() {
showLoading();
try {
const result = await invoke('fetch_gitea_contributions', {
server: config.server,
username: config.username,
token: config.token,
});
if (!Array.isArray(result)) {
showError('API 返回格式异常,请检查服务器地址');
return;
}
const { weeks, totalContributions } = buildWeeksFromHeatmap(result);
renderHeatmap(weeks, totalContributions);
} catch (e) {
const msg = String(e);
if (msg.includes('401') || msg.includes('403')) {
showError('认证失败,请在设置中填写有效的 Access Token');
} else if (msg.includes('404')) {
showError(`用户 "${config.username}" 不存在`);
} else if (msg.includes('connection') || msg.includes('connect')) {
showError('无法连接到服务器: ' + config.server);
} else {
showError('请求失败: ' + msg);
}
}
}
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-server').value = config.server;
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-server').focus();
}
async function closeSettingsModal() {
clearFormError();
document.getElementById('settings-modal').classList.remove('visible');
document.body.classList.remove('settings-open');
await ensureWidgetSize();
}
async function saveSettings() {
const server = document.getElementById('input-server').value.trim().replace(/\/$/, '');
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 (!server || !username) {
showFormError();
document.getElementById(!server ? 'input-server' : 'input-username').focus();
return;
}
const newConfig = { ...config, server, 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-server', '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.weeks, lastHeatmapData.totalContributions);
});
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();
})();