init: GitHub/Gitea contribution heatmap apps

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 21:57:49 +08:00
commit 2d1c7fc59b
46 changed files with 17315 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitHub 贡献热力图</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="widget">
<!-- Header -->
<div class="header" data-tauri-drag-region>
<div class="title-area" data-tauri-drag-region>
<svg class="platform-icon" viewBox="0 0 24 24" fill="currentColor" data-tauri-drag-region>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span class="username" id="username-display">shumengya</span>
<span class="separator"></span>
<span class="stats" id="stats-display">加载中...</span>
</div>
<div class="header-right">
<div class="legend">
<span class="legend-label"></span>
<div class="legend-cell" data-level="0"></div>
<div class="legend-cell" data-level="1"></div>
<div class="legend-cell" data-level="2"></div>
<div class="legend-cell" data-level="3"></div>
<div class="legend-cell" data-level="4"></div>
<span class="legend-label"></span>
</div>
</div>
</div>
<!-- Heatmap -->
<div class="heatmap-area" id="heatmap-area">
<div class="status-msg" id="status-loading">
<div class="spinner"></div>
<span>正在获取贡献数据...</span>
</div>
<div class="status-msg error" id="status-error" style="display:none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span id="error-text">加载失败</span>
</div>
<canvas id="heatmap-canvas"></canvas>
</div>
</div>
<!-- Settings Modal -->
<div class="modal-overlay" id="settings-modal">
<div class="modal" id="settings-panel">
<div class="modal-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
GitHub 设置
</div>
<div class="form-group">
<label>主题</label>
<select id="input-theme">
<option value="dark">深色</option>
<option value="light">浅色</option>
</select>
</div>
<div class="form-group">
<label>GitHub 用户名</label>
<input type="text" id="input-username" placeholder="例如: shumengya" autocomplete="off">
<span class="form-error" id="form-error">请填写 GitHub 用户名</span>
</div>
<div class="form-group">
<label>Personal Access Token</label>
<input type="password" id="input-token" placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" autocomplete="off">
<span class="form-hint">需要 read:user 权限 · Settings → Developer settings → Tokens</span>
</div>
<div class="modal-footer">
<button class="btn" id="btn-cancel">取消</button>
<button class="btn primary" id="btn-save">保存并刷新</button>
</div>
</div>
</div>
<!-- Tooltip -->
<div class="tooltip" id="tooltip"></div>
<script type="module" src="main.js"></script>
</body>
</html>

391
github/frontend/main.js Normal file
View File

@@ -0,0 +1,391 @@
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();
})();

397
github/frontend/style.css Normal file
View File

@@ -0,0 +1,397 @@
:root {
--bg-widget: #0d1117;
--border: #30363d;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 2px 8px rgba(0, 0, 0, 0.4);
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--separator: #30363d;
--btn-icon: #7d8590;
--btn-hover-bg: #21262d;
--btn-hover-text: #e6edf3;
--accent: #388bfd;
--overlay: rgba(0, 0, 0, 0.82);
--modal-bg: #161b22;
--modal-footer-bg: #161b22;
--input-bg: #0d1117;
--input-border: #30363d;
--input-text: #e6edf3;
--label-text: #c9d1d9;
--hint-text: #8b949e;
--input-focus: #388bfd;
--input-focus-shadow: rgba(56, 139, 253, 0.2);
--btn-bg: #21262d;
--btn-text: #e6edf3;
--btn-hover: #30363d;
--btn-primary-bg: #238636;
--btn-primary-border: #2ea043;
--btn-primary-hover: #2ea043;
--tooltip-bg: #1c2128;
--tooltip-border: #444c56;
--tooltip-text: #e6edf3;
--error: #f85149;
--spinner-track: #30363d;
--status-text: #8b949e;
--platform-icon: #e6edf3;
--hm-0: #161b22;
--hm-0-border: #30363d;
--hm-1: #0e4429;
--hm-2: #006d32;
--hm-3: #26a641;
--hm-4: #39d353;
}
body.theme-light {
--bg-widget: #ffffff;
--border: #d0d7de;
--shadow: 0 4px 16px rgba(140, 149, 159, 0.22), 0 1px 4px rgba(27, 31, 36, 0.08);
--text-primary: #1f2328;
--text-secondary: #656d76;
--separator: #d0d7de;
--btn-icon: #656d76;
--btn-hover-bg: #f3f4f6;
--btn-hover-text: #1f2328;
--accent: #0969da;
--overlay: rgba(27, 31, 36, 0.4);
--modal-bg: #ffffff;
--modal-footer-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #d0d7de;
--input-text: #1f2328;
--label-text: #1f2328;
--hint-text: #656d76;
--input-focus-shadow: rgba(9, 105, 218, 0.25);
--btn-bg: #f6f8fa;
--btn-text: #1f2328;
--btn-hover: #eaeef2;
--tooltip-bg: #ffffff;
--tooltip-border: #d0d7de;
--tooltip-text: #1f2328;
--error: #cf222e;
--spinner-track: #d0d7de;
--status-text: #656d76;
--platform-icon: #24292f;
--hm-0: #ebedf0;
--hm-0-border: #d0d7de;
--hm-1: #9be9a8;
--hm-2: #40c463;
--hm-3: #30a14e;
--hm-4: #216e39;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
font-family: "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
user-select: none;
overflow: hidden;
width: 100vw;
height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.widget {
background: var(--bg-widget);
border: 1px solid var(--border);
border-radius: 10px;
padding: 4px 5px 3px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 0;
box-shadow: var(--shadow);
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
min-height: 20px;
cursor: move;
-webkit-app-region: drag;
}
.title-area {
display: flex;
align-items: center;
gap: 6px;
cursor: move;
-webkit-app-region: drag;
min-width: 0;
flex: 1;
}
.platform-icon {
width: 16px;
height: 16px;
color: var(--platform-icon);
flex-shrink: 0;
shape-rendering: geometricPrecision;
}
.username {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.1;
}
.stats {
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
line-height: 1.1;
}
.separator {
color: var(--separator);
margin: 0 2px;
}
.header-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.legend {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
cursor: move;
-webkit-app-region: drag;
}
.heatmap-area {
flex: 0 0 auto;
width: 100%;
line-height: 0;
position: relative;
cursor: move;
-webkit-app-region: drag;
}
#heatmap-canvas {
display: none;
cursor: crosshair;
-webkit-app-region: no-drag;
vertical-align: top;
}
.status-msg {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--status-text);
font-size: 11px;
font-weight: 600;
}
.status-msg.error {
color: var(--error);
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid var(--spinner-track);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.legend-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.legend-cell {
width: 8px;
height: 8px;
border-radius: 2px;
}
.legend-cell[data-level="0"] {
background: var(--hm-0);
border: 1px solid var(--hm-0-border);
}
.legend-cell[data-level="1"] { background: var(--hm-1); }
.legend-cell[data-level="2"] { background: var(--hm-2); }
.legend-cell[data-level="3"] { background: var(--hm-3); }
.legend-cell[data-level="4"] { background: var(--hm-4); }
.modal-overlay {
position: fixed;
inset: 0;
background: var(--overlay);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(4px);
-webkit-app-region: no-drag;
padding: 8px;
}
.modal-overlay.visible {
display: flex;
}
body.settings-open .widget {
pointer-events: none;
}
.modal {
background: var(--modal-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px 14px;
width: min(320px, calc(100vw - 16px));
max-height: calc(100vh - 16px);
overflow-y: auto;
box-shadow: var(--shadow);
-webkit-app-region: no-drag;
transition: background 0.2s, border-color 0.2s;
}
.modal-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 12px;
color: var(--label-text);
margin-bottom: 5px;
font-weight: 600;
}
.form-group input,
.form-group select {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 8px 10px;
color: var(--input-text);
font-size: 13px;
font-weight: 600;
outline: none;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
-webkit-app-region: no-drag;
}
.form-group input:focus,
.form-group select:focus {
border-color: var(--input-focus);
box-shadow: 0 0 0 2px var(--input-focus-shadow);
}
.form-hint {
font-size: 10px;
font-weight: 600;
color: var(--hint-text);
margin-top: 4px;
display: block;
line-height: 1.4;
}
.form-error {
display: none;
margin-top: 6px;
font-size: 11px;
font-weight: 600;
color: var(--error);
}
.form-error.visible {
display: block;
}
.modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
background: var(--modal-footer-bg);
}
.btn {
padding: 7px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border);
background: var(--btn-bg);
color: var(--btn-text);
transition: background 0.12s;
font-family: inherit;
-webkit-app-region: no-drag;
}
.btn:hover {
background: var(--btn-hover);
}
.btn.primary {
background: var(--btn-primary-bg);
border-color: var(--btn-primary-border);
color: #fff;
}
.btn.primary:hover {
background: var(--btn-primary-hover);
}
.tooltip {
position: fixed;
background: var(--tooltip-bg);
border: 1px solid var(--tooltip-border);
border-radius: 6px;
padding: 5px 9px;
font-size: 11px;
font-weight: 600;
color: var(--tooltip-text);
pointer-events: none;
display: none;
z-index: 200;
white-space: nowrap;
box-shadow: var(--shadow);
}

247
github/package-lock.json generated Normal file
View File

@@ -0,0 +1,247 @@
{
"name": "github-heatmap",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "github-heatmap",
"version": "1.0.0",
"devDependencies": {
"@tauri-apps/cli": "^2"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.3.tgz",
"integrity": "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.3",
"@tauri-apps/cli-darwin-x64": "2.11.3",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.3",
"@tauri-apps/cli-linux-arm64-musl": "2.11.3",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.3",
"@tauri-apps/cli-linux-x64-gnu": "2.11.3",
"@tauri-apps/cli-linux-x64-musl": "2.11.3",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.3",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.3",
"@tauri-apps/cli-win32-x64-msvc": "2.11.3"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.3.tgz",
"integrity": "sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.3.tgz",
"integrity": "sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.3.tgz",
"integrity": "sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.3.tgz",
"integrity": "sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.3.tgz",
"integrity": "sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.3.tgz",
"integrity": "sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.3.tgz",
"integrity": "sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.3.tgz",
"integrity": "sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.3.tgz",
"integrity": "sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.3.tgz",
"integrity": "sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.3.tgz",
"integrity": "sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

12
github/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "github-heatmap",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tauri dev",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}

View File

@@ -0,0 +1,26 @@
[package]
name = "github-heatmap"
version = "1.0.0"
edition = "2021"
[lib]
name = "github_heatmap_lib"
crate-type = ["lib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
[profile.dev]
incremental = true
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,23 @@
{
"identifier": "default",
"description": "Default capabilities for GitHub HeatMap widget",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"core:window:allow-set-always-on-top",
"core:window:allow-is-always-on-top",
"core:window:allow-set-position",
"core:window:allow-inner-position",
"core:window:allow-outer-position",
"core:window:allow-start-dragging",
"core:window:allow-set-size",
"core:window:allow-inner-size",
"core:event:allow-listen",
"core:event:allow-unlisten"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for GitHub HeatMap widget","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","core:window:allow-unminimize","core:window:allow-set-always-on-top","core:window:allow-is-always-on-top","core:window:allow-set-position","core:window:allow-inner-position","core:window:allow-outer-position","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-inner-size","core:event:allow-listen","core:event:allow-unlisten"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

226
github/src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,226 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
const TRAY_TOOLTIP: &str = "GitHub 贡献热力图";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub username: String,
pub token: String,
#[serde(default)]
pub window_x: Option<i32>,
#[serde(default)]
pub window_y: Option<i32>,
#[serde(default = "default_theme")]
pub theme: String,
}
fn default_theme() -> String {
"dark".to_string()
}
impl Default for Config {
fn default() -> Self {
Config {
username: "shumengya".to_string(),
token: String::new(),
window_x: None,
window_y: None,
theme: default_theme(),
}
}
}
fn data_dir() -> PathBuf {
std::env::current_exe()
.unwrap_or_default()
.parent()
.unwrap_or(std::path::Path::new("."))
.join("data")
}
#[tauri::command]
fn load_config() -> Config {
let path = data_dir().join("github_config.json");
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
#[tauri::command]
fn save_config(config: Config) -> Result<(), String> {
let dir = data_dir();
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
fs::write(dir.join("github_config.json"), content).map_err(|e| e.to_string())
}
#[tauri::command]
fn save_window_pos(x: i32, y: i32) -> Result<(), String> {
let path = data_dir().join("github_config.json");
let mut config: Config = fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
config.window_x = Some(x);
config.window_y = Some(y);
let dir = data_dir();
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
fs::write(dir.join("github_config.json"), content).map_err(|e| e.to_string())
}
#[tauri::command]
async fn fetch_github_contributions(
username: String,
token: String,
) -> Result<serde_json::Value, String> {
let query = r#"query($userName: String!) {
user(login: $userName) {
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
contributionCount
date
weekday
}
}
}
}
}
}"#;
let body = serde_json::json!({
"query": query,
"variables": { "userName": username }
});
let client = reqwest::Client::builder()
.user_agent("GitHeatMap-Widget/1.0")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.post("https://api.github.com/graphql")
.header("Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
resp.json::<serde_json::Value>()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
fn hide_app(window: tauri::Window) {
let _ = window.hide();
}
fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn hide_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
fn setup_tray(app: &mut tauri::App) -> tauri::Result<()> {
let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
let refresh_i = MenuItem::with_id(app, "refresh", "刷新", true, None::<&str>)?;
let pin_i = MenuItem::with_id(app, "pin", "置顶", true, None::<&str>)?;
let theme_i = MenuItem::with_id(app, "theme", "切换主题", true, None::<&str>)?;
let settings_i = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?;
let hide_i = MenuItem::with_id(app, "hide", "隐藏窗口", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[
&show_i,
&refresh_i,
&pin_i,
&theme_i,
&settings_i,
&hide_i,
&sep,
&quit_i,
],
)?;
let icon = app
.default_window_icon()
.cloned()
.expect("default window icon missing");
TrayIconBuilder::with_id("main-tray")
.icon(icon)
.tooltip(TRAY_TOOLTIP)
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main_window(app),
"hide" => hide_main_window(app),
"quit" => app.exit(0),
"refresh" | "pin" | "theme" | "settings" => {
show_main_window(app);
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("tray-action", event.id().as_ref());
}
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
pub fn run() {
tauri::Builder::default()
.setup(|app| {
setup_tray(app)?;
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![
load_config,
save_config,
save_window_pos,
fetch_github_contributions,
hide_app,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
github_heatmap_lib::run()
}

View File

@@ -0,0 +1,39 @@
{
"productName": "GitHub HeatMap",
"version": "1.0.0",
"identifier": "top.shumengya.github-heatmap",
"build": {
"frontendDist": "../frontend"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "GitHub 贡献热力图",
"width": 600,
"height": 108,
"resizable": false,
"decorations": false,
"transparent": true,
"alwaysOnTop": false,
"center": true,
"skipTaskbar": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.ico"
]
}
}