init: GitHub/Gitea contribution heatmap apps
Co-authored-by: Cursor <cursoragent@cursor.com>
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
target/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.claude/
|
||||||
4813
Cargo.lock
generated
Normal file
6
Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"github/src-tauri",
|
||||||
|
"gitea/src-tauri",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
92
build.bat
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo GitHeatMap Build
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
where npm >nul 2>&1
|
||||||
|
if errorlevel 1 goto :no_npm
|
||||||
|
|
||||||
|
where cargo >nul 2>&1
|
||||||
|
if errorlevel 1 goto :no_cargo
|
||||||
|
|
||||||
|
if exist "%~dp0generate-icons.ps1" goto :gen_icons
|
||||||
|
goto :build_gitea
|
||||||
|
|
||||||
|
:gen_icons
|
||||||
|
echo [1/4] Generating icons...
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0generate-icons.ps1"
|
||||||
|
if errorlevel 1 goto :icon_fail
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:build_gitea
|
||||||
|
echo [2/4] Building Gitea HeatMap...
|
||||||
|
cd /d "%~dp0gitea"
|
||||||
|
if errorlevel 1 goto :dir_fail
|
||||||
|
if not exist "node_modules\" goto :gitea_install
|
||||||
|
goto :gitea_build
|
||||||
|
|
||||||
|
:gitea_install
|
||||||
|
echo npm install...
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 goto :gitea_fail
|
||||||
|
|
||||||
|
:gitea_build
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 goto :gitea_fail
|
||||||
|
echo gitea OK
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [3/4] Building GitHub HeatMap...
|
||||||
|
cd /d "%~dp0github"
|
||||||
|
if errorlevel 1 goto :dir_fail
|
||||||
|
if not exist "node_modules\" goto :github_install
|
||||||
|
goto :github_build
|
||||||
|
|
||||||
|
:github_install
|
||||||
|
echo npm install...
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 goto :github_fail
|
||||||
|
|
||||||
|
:github_build
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 goto :github_fail
|
||||||
|
echo github OK
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [4/4] Done
|
||||||
|
echo.
|
||||||
|
echo Output: %~dp0target\release\
|
||||||
|
echo gitea-heatmap.exe
|
||||||
|
echo github-heatmap.exe
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:no_npm
|
||||||
|
echo ERROR: npm not found. Install Node.js first.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:no_cargo
|
||||||
|
echo ERROR: cargo not found. Install Rust first.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:icon_fail
|
||||||
|
echo ERROR: icon generation failed.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:dir_fail
|
||||||
|
echo ERROR: project directory not found.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:gitea_fail
|
||||||
|
echo ERROR: Gitea build failed.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:github_fail
|
||||||
|
echo ERROR: GitHub build failed.
|
||||||
|
exit /b 1
|
||||||
250
generate-icons.ps1
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# Generate icons for GitHub HeatMap and Gitea HeatMap widgets
|
||||||
|
# Run once before building: .\generate-icons.ps1
|
||||||
|
|
||||||
|
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Drawing
|
||||||
|
|
||||||
|
$GITHUB_PATH = "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"
|
||||||
|
|
||||||
|
$GITEA_TEABAG = "M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"
|
||||||
|
$GITEA_BODY = "M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"
|
||||||
|
$GITEA_DETAIL = "M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8C343.2,346.5,335,363.3,326.8,380.1z"
|
||||||
|
|
||||||
|
$ICON_SIZES = @(16, 24, 32, 48, 64, 128, 256)
|
||||||
|
|
||||||
|
function Get-Brush([string]$hex) {
|
||||||
|
[System.Windows.Media.BrushConverter]::new().ConvertFromString($hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-AsPng($rtb, [string]$path) {
|
||||||
|
$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder
|
||||||
|
$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($rtb))
|
||||||
|
$fs = [System.IO.File]::OpenWrite($path)
|
||||||
|
$enc.Save($fs)
|
||||||
|
$fs.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rtb-ToBitmap($rtb) {
|
||||||
|
$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($rtb))
|
||||||
|
$enc.Save($ms)
|
||||||
|
$ms.Position = 0
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap($ms)
|
||||||
|
$ms.Dispose()
|
||||||
|
return $bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resize-Bitmap([System.Drawing.Bitmap]$source, [int]$size) {
|
||||||
|
$dest = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($dest)
|
||||||
|
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||||
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
|
||||||
|
$g.Clear([System.Drawing.Color]::Transparent)
|
||||||
|
$g.DrawImage($source, 0, 0, $size, $size)
|
||||||
|
$g.Dispose()
|
||||||
|
return $dest
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-BitmapToIcoDib([System.Drawing.Bitmap]$bitmap) {
|
||||||
|
$size = $bitmap.Width
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$bw = New-Object System.IO.BinaryWriter($ms)
|
||||||
|
|
||||||
|
$bw.Write([int32]40)
|
||||||
|
$bw.Write([int32]$size)
|
||||||
|
$bw.Write([int32]($size * 2))
|
||||||
|
$bw.Write([int16]1)
|
||||||
|
$bw.Write([int16]32)
|
||||||
|
$bw.Write([int32]0)
|
||||||
|
$bw.Write([int32]0)
|
||||||
|
$bw.Write([int32]0)
|
||||||
|
$bw.Write([int32]0)
|
||||||
|
$bw.Write([int32]0)
|
||||||
|
$bw.Write([int32]0)
|
||||||
|
|
||||||
|
for ($y = $size - 1; $y -ge 0; $y--) {
|
||||||
|
for ($x = 0; $x -lt $size; $x++) {
|
||||||
|
$c = $bitmap.GetPixel($x, $y)
|
||||||
|
$bw.Write([byte]$c.B)
|
||||||
|
$bw.Write([byte]$c.G)
|
||||||
|
$bw.Write([byte]$c.R)
|
||||||
|
$bw.Write([byte]$c.A)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowBytes = [Math]::Ceiling($size / 8.0)
|
||||||
|
$rowBytes = ($rowBytes + 3) -band (-bnot 3)
|
||||||
|
for ($y = 0; $y -lt $size; $y++) {
|
||||||
|
for ($xb = 0; $xb -lt $rowBytes; $xb++) {
|
||||||
|
$val = 0
|
||||||
|
for ($bit = 0; $bit -lt 8; $bit++) {
|
||||||
|
$x = ($xb * 8) + $bit
|
||||||
|
if ($x -lt $size) {
|
||||||
|
$a = $bitmap.GetPixel($x, $size - 1 - $y).A
|
||||||
|
if ($a -lt 128) { $val = $val -bor (1 -shl (7 - $bit)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bw.Write([byte]$val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bw.Close()
|
||||||
|
return $ms.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-MultiResIco([string]$icoPath, [System.Drawing.Bitmap]$master) {
|
||||||
|
$entries = @()
|
||||||
|
foreach ($size in $ICON_SIZES) {
|
||||||
|
$scaled = Resize-Bitmap $master $size
|
||||||
|
$entries += @{
|
||||||
|
Size = $size
|
||||||
|
Data = (Convert-BitmapToIcoDib $scaled)
|
||||||
|
Bitmap = $scaled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ms = New-Object System.IO.MemoryStream
|
||||||
|
$bw = New-Object System.IO.BinaryWriter($ms)
|
||||||
|
$bw.Write([uint16]0)
|
||||||
|
$bw.Write([uint16]1)
|
||||||
|
$bw.Write([uint16]$entries.Count)
|
||||||
|
|
||||||
|
$offset = 6 + (16 * $entries.Count)
|
||||||
|
foreach ($entry in $entries) {
|
||||||
|
$size = $entry.Size
|
||||||
|
if ($size -ge 256) {
|
||||||
|
$bw.Write([byte]0)
|
||||||
|
$bw.Write([byte]0)
|
||||||
|
} else {
|
||||||
|
$bw.Write([byte]$size)
|
||||||
|
$bw.Write([byte]$size)
|
||||||
|
}
|
||||||
|
$bw.Write([byte]0)
|
||||||
|
$bw.Write([byte]0)
|
||||||
|
$bw.Write([uint16]1)
|
||||||
|
$bw.Write([uint16]32)
|
||||||
|
$bw.Write([uint32]$entry.Data.Length)
|
||||||
|
$bw.Write([uint32]$offset)
|
||||||
|
$offset += $entry.Data.Length
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entry in $entries) {
|
||||||
|
$bw.Write($entry.Data, 0, $entry.Data.Length)
|
||||||
|
$entry.Bitmap.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$bw.Close()
|
||||||
|
[System.IO.File]::WriteAllBytes($icoPath, $ms.ToArray())
|
||||||
|
$ms.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Render-RtbAt([int]$px, [scriptblock]$drawBlock) {
|
||||||
|
$dv = New-Object System.Windows.Media.DrawingVisual
|
||||||
|
[System.Windows.Media.RenderOptions]::SetBitmapScalingMode($dv, [System.Windows.Media.BitmapScalingMode]::HighQuality)
|
||||||
|
[System.Windows.Media.RenderOptions]::SetEdgeMode($dv, [System.Windows.Media.EdgeMode]::Aliased)
|
||||||
|
|
||||||
|
$ctx = $dv.RenderOpen()
|
||||||
|
& $drawBlock $ctx $px
|
||||||
|
$ctx.Close()
|
||||||
|
|
||||||
|
$rtb = New-Object System.Windows.Media.Imaging.RenderTargetBitmap(
|
||||||
|
$px, $px, 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32)
|
||||||
|
$rtb.Render($dv)
|
||||||
|
$rtb
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-IconCornerRadius([int]$px) {
|
||||||
|
return [Math]::Max(2, [Math]::Round($px * 0.22))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Draw-IconBackground($ctx, [int]$px, $bgBrush) {
|
||||||
|
$radius = Get-IconCornerRadius $px
|
||||||
|
$bg = New-Object System.Windows.Media.RectangleGeometry(
|
||||||
|
[System.Windows.Rect]::new(0, 0, $px, $px), $radius, $radius)
|
||||||
|
$ctx.DrawGeometry($bgBrush, $null, $bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Export-IconSet([string]$outDir, [scriptblock]$drawBlock) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
|
||||||
|
|
||||||
|
$masterRtb = Render-RtbAt 256 $drawBlock
|
||||||
|
$masterBmp = Rtb-ToBitmap $masterRtb
|
||||||
|
|
||||||
|
Save-AsPng $masterRtb "$outDir\icon.png"
|
||||||
|
Write-Host " $outDir\icon.png"
|
||||||
|
|
||||||
|
foreach ($entry in @(@{px=32;suf="32x32"}, @{px=128;suf="128x128"}, @{px=256;suf="128x128@2x"})) {
|
||||||
|
$scaled = Resize-Bitmap $masterBmp $entry.px
|
||||||
|
$scaled.Save("$outDir\$($entry.suf).png", [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$scaled.Dispose()
|
||||||
|
Write-Host " $outDir\$($entry.suf).png"
|
||||||
|
}
|
||||||
|
|
||||||
|
Save-MultiResIco "$outDir\icon.ico" $masterBmp
|
||||||
|
Write-Host " $outDir\icon.ico (multi-size BMP)"
|
||||||
|
|
||||||
|
$masterBmp.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Render-GithubIcon([string]$outDir) {
|
||||||
|
$bgBrush = Get-Brush "#0d1117"
|
||||||
|
$white = [System.Windows.Media.Brushes]::White
|
||||||
|
|
||||||
|
$draw = {
|
||||||
|
param($ctx, [int]$px)
|
||||||
|
Draw-IconBackground $ctx $px $bgBrush
|
||||||
|
|
||||||
|
$pad = $px * 0.18
|
||||||
|
$scale = ($px - 2 * $pad) / 24.0
|
||||||
|
$xf = New-Object System.Windows.Media.TransformGroup
|
||||||
|
$xf.Children.Add((New-Object System.Windows.Media.ScaleTransform($scale, $scale)))
|
||||||
|
$xf.Children.Add((New-Object System.Windows.Media.TranslateTransform($pad, $pad)))
|
||||||
|
|
||||||
|
$ctx.PushTransform($xf)
|
||||||
|
$ctx.DrawGeometry($white, $null, [System.Windows.Media.Geometry]::Parse($GITHUB_PATH))
|
||||||
|
$ctx.Pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-IconSet $outDir $draw
|
||||||
|
}
|
||||||
|
|
||||||
|
function Render-GiteaIcon([string]$outDir) {
|
||||||
|
$bgBrush = Get-Brush "#609926"
|
||||||
|
$white = [System.Windows.Media.Brushes]::White
|
||||||
|
$paths = @($GITEA_BODY, $GITEA_DETAIL, $GITEA_TEABAG)
|
||||||
|
|
||||||
|
$draw = {
|
||||||
|
param($ctx, [int]$px)
|
||||||
|
Draw-IconBackground $ctx $px $bgBrush
|
||||||
|
|
||||||
|
$pad = $px * 0.14
|
||||||
|
$scale = ($px - 2 * $pad) / 640.0
|
||||||
|
$xf = New-Object System.Windows.Media.TransformGroup
|
||||||
|
$xf.Children.Add((New-Object System.Windows.Media.ScaleTransform($scale, $scale)))
|
||||||
|
$xf.Children.Add((New-Object System.Windows.Media.TranslateTransform($pad, $pad)))
|
||||||
|
|
||||||
|
$ctx.PushTransform($xf)
|
||||||
|
foreach ($pathStr in $paths) {
|
||||||
|
$ctx.DrawGeometry($white, $null, [System.Windows.Media.Geometry]::Parse($pathStr))
|
||||||
|
}
|
||||||
|
$ctx.Pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-IconSet $outDir $draw
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Generating GitHub HeatMap icons..."
|
||||||
|
Render-GithubIcon ".\github\src-tauri\icons"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Generating Gitea HeatMap icons..."
|
||||||
|
Render-GiteaIcon ".\gitea\src-tauri\icons"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. Rebuild apps to refresh shortcut icons:"
|
||||||
|
Write-Host " .\build.bat"
|
||||||
96
gitea/frontend/index.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gitea 贡献热力图</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>
|
||||||
|
<!-- Gitea official logo -->
|
||||||
|
<svg class="platform-icon" viewBox="0 0 640 640" aria-hidden="true" data-tauri-drag-region>
|
||||||
|
<path fill="#fff" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
|
||||||
|
<path fill="#609926" d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
|
||||||
|
<path fill="#609926" d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8C343.2,346.5,335,363.3,326.8,380.1z"/>
|
||||||
|
</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>
|
||||||
|
Gitea 设置
|
||||||
|
</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>Gitea 服务器地址</label>
|
||||||
|
<input type="text" id="input-server" placeholder="https://git.shumengya.top" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" id="input-username" placeholder="例如: shumengya" autocomplete="off">
|
||||||
|
<span class="form-error" id="form-error">请填写服务器地址和用户名</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Access Token(可选)</label>
|
||||||
|
<input type="password" id="input-token" placeholder="留空则访问公开数据" autocomplete="off">
|
||||||
|
<span class="form-hint">私有实例或需要认证时填写 · Settings → Applications → Generate Token</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>
|
||||||
442
gitea/frontend/main.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
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();
|
||||||
|
})();
|
||||||
397
gitea/frontend/style.css
Normal 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: #609926;
|
||||||
|
--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: #609926;
|
||||||
|
--input-focus-shadow: rgba(96, 153, 38, 0.2);
|
||||||
|
--btn-bg: #21262d;
|
||||||
|
--btn-text: #e6edf3;
|
||||||
|
--btn-hover: #30363d;
|
||||||
|
--btn-primary-bg: #4a7c1f;
|
||||||
|
--btn-primary-border: #609926;
|
||||||
|
--btn-primary-hover: #609926;
|
||||||
|
--tooltip-bg: #1c2128;
|
||||||
|
--tooltip-border: #444c56;
|
||||||
|
--tooltip-text: #e6edf3;
|
||||||
|
--error: #f85149;
|
||||||
|
--spinner-track: #30363d;
|
||||||
|
--status-text: #8b949e;
|
||||||
|
--hm-0: #161b22;
|
||||||
|
--hm-0-border: #30363d;
|
||||||
|
--hm-1: #1a3a08;
|
||||||
|
--hm-2: #2d6a0a;
|
||||||
|
--hm-3: #4a9113;
|
||||||
|
--hm-4: #609926;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
--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(96, 153, 38, 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;
|
||||||
|
--hm-0: #ebedf0;
|
||||||
|
--hm-0-border: #d0d7de;
|
||||||
|
--hm-1: #d5eec0;
|
||||||
|
--hm-2: #a8d973;
|
||||||
|
--hm-3: #73b340;
|
||||||
|
--hm-4: #609926;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
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;
|
||||||
|
flex-shrink: 0;
|
||||||
|
shape-rendering: geometricPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.theme-light .platform-icon path[fill="#fff"] {
|
||||||
|
fill: #24292f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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
gitea/package-lock.json
generated
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea-heatmap",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "gitea-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
gitea/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea-heatmap",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"build": "tauri build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
gitea/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "gitea-heatmap"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "gitea_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"
|
||||||
3
gitea/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
23
gitea/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capabilities for Gitea 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
gitea/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
gitea/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capabilities for Gitea 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"]}}
|
||||||
2292
gitea/src-tauri/gen/schemas/desktop-schema.json
Normal file
2292
gitea/src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
gitea/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
gitea/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
gitea/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
gitea/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
gitea/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
221
gitea/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
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 = "Gitea 贡献热力图";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub server: String,
|
||||||
|
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 {
|
||||||
|
server: "https://git.shumengya.top".to_string(),
|
||||||
|
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("gitea_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("gitea_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("gitea_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("gitea_config.json"), content).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_gitea_contributions(
|
||||||
|
server: String,
|
||||||
|
username: String,
|
||||||
|
token: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/users/{}/heatmap",
|
||||||
|
server.trim_end_matches('/'),
|
||||||
|
username
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("GitHeatMap-Widget/1.0")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut req = client.get(&url).header("Accept", "application/json");
|
||||||
|
|
||||||
|
if !token.is_empty() {
|
||||||
|
req = req.header("Authorization", format!("token {}", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req.send().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"HTTP {}: {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.status().canonical_reason().unwrap_or("Unknown")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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_gitea_contributions,
|
||||||
|
hide_app,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
5
gitea/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
gitea_heatmap_lib::run()
|
||||||
|
}
|
||||||
39
gitea/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"productName": "Gitea HeatMap",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"identifier": "top.shumengya.gitea-heatmap",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../frontend"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Gitea 贡献热力图",
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
89
github/frontend/index.html
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
github/src-tauri/Cargo.toml
Normal 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"
|
||||||
3
github/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
23
github/src-tauri/capabilities/default.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
github/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
github/src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||||
2292
github/src-tauri/gen/schemas/desktop-schema.json
Normal file
2292
github/src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
github/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
github/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
github/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
github/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
github/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
226
github/src-tauri/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
5
github/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
github_heatmap_lib::run()
|
||||||
|
}
|
||||||
39
github/src-tauri/tauri.conf.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "GitHeatMap",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||