Initial profile setup
Some checks failed
Update Gitea Profile / update-profile (push) Has been cancelled
Some checks failed
Update Gitea Profile / update-profile (push) Has been cancelled
This commit is contained in:
parent
3a9c404b55
commit
c9b2a96efa
65
.gitea/workflows/update-profile.yml
Normal file
65
.gitea/workflows/update-profile.yml
Normal file
@ -0,0 +1,65 @@
|
||||
name: Update Gitea Profile
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Täglich um 00:00 Uhr UTC
|
||||
- cron: '0 0 * * *'
|
||||
# Erlaubt das manuelle Ausführen über das Gitea Web-Interface
|
||||
workflow_dispatch:
|
||||
# Optional: Auch ausführen, wenn manuelle Änderungen an Code oder README gepusht werden
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'heatmap.svg'
|
||||
- 'README.md'
|
||||
|
||||
jobs:
|
||||
update-profile:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Berechtigungen für das Schreiben ins Repository
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: 📥 Repository auschecken
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Nutzt standardmäßig das automatische Gitea-Token oder ein benutzerdefiniertes Secret
|
||||
token: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: ⚙️ .NET 10 SDK einrichten
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: 🚀 C# Profile-Manager ausführen
|
||||
env:
|
||||
GITEA_API_URL: 'https://git.webcore-consulting.dev/api/v1'
|
||||
GITEA_USERNAME: 'NickiCloud'
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
dotnet run --project src/ProfileManager
|
||||
|
||||
- name: 📝 Änderungen prüfen, committen und pushen
|
||||
run: |
|
||||
# Git-Identität für den Bot konfigurieren
|
||||
git config --global user.name "NickiBot[bot]"
|
||||
git config --global user.email "bot@webcore-consulting.dev"
|
||||
|
||||
# Prüfen, ob sich heatmap.svg oder README.md geändert haben
|
||||
if git status --porcelain | grep -E "(heatmap.svg|README.md)"; then
|
||||
echo "Änderungen an heatmap.svg oder README.md erkannt. Committe..."
|
||||
git add heatmap.svg README.md
|
||||
|
||||
# GANZ WICHTIG: [skip ci] verhindert Endlosschleifen in Gitea Actions!
|
||||
git commit -m "chore(profile): update activity heatmap & stats [skip ci]"
|
||||
|
||||
# Änderungen pushen
|
||||
git push
|
||||
echo "Profil erfolgreich aktualisiert und gepusht! ✨"
|
||||
else
|
||||
echo "Keine Änderungen vorhanden. Alles auf dem neuesten Stand. 👍"
|
||||
fi
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
## .NET Build & Output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
## IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
## OS Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
94
README.md
94
README.md
@ -1,2 +1,94 @@
|
||||
# .profile
|
||||
<div align="center">
|
||||
|
||||
# 🦊 Hey, ich bin Nicki!
|
||||
|
||||
### 🎓 Fachabiturient & Leidenschaftlicher C# / .NET Softwareentwickler
|
||||
*Building high-performance desktop & web applications with a touch of purple fox magic.*
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](https://avaloniaui.net/)
|
||||
[](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor)
|
||||
[](https://mudblazor.com/)
|
||||
|
||||
[](https://www.jetbrains.com/rider/)
|
||||
[](https://pop.system76.com/)
|
||||
[](https://www.debian.org/)
|
||||
[](https://www.docker.com/)
|
||||
[](https://about.gitea.com/)
|
||||
|
||||
---
|
||||
|
||||
### 🐾 "Schlau wie ein Fuchs, schnell wie .NET & kompromisslos self-hosted."
|
||||
|
||||
</div>
|
||||
|
||||
## 🧙♂️ Über mich & Meine Mission
|
||||
|
||||
Ich bin **Nicki**, angehender Softwareentwickler mit klarem Fokus auf moderne Software-Architektur im .NET-Ökosystem. Wenn ich nicht gerade sauberen, asynchronen C#-Code in **JetBrains Rider auf Pop!_OS** tippe, optimiere ich meine eigene Linux-Server-Infrastruktur oder erkunde virtuelle Welten.
|
||||
|
||||
- 💻 **Mein Fokus:** C#, .NET 10, XAML, Cross-Platform UIs mit **Avalonia UI** und reaktive Web-UIs mit **Blazor & MudBlazor**.
|
||||
- 🐧 **Mein OS & Dev-Setup:** Rider-Enthusiast auf **Pop!_OS (Linux)** – weil Entwicklungs-Power Freiheit braucht.
|
||||
- 🏰 **Self-Hosting & Homelab:** Eigener Debian-Server mit Docker-Containern: Gitea, Mailcow, Nginx Reverse-Proxy, abgesichert mit UFW & fail2ban.
|
||||
- 🎮 **Gaming & Abschalten:** Wenn der Build grün ist, findet man mich in **Minecraft** (Redstone & Modpacks), taktischen Matches in **Rainbow Six Siege** oder beim Farmen in **Warframe**.
|
||||
- 🦊 **Das Fuchs-Branding:** Lila Akzente, Neugier, Agilität und maximale Eleganz in Code & UI.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 📊 Meine Gitea Aktivität & Streak
|
||||
|
||||
<!-- Die Heatmap wird vollautomatisch per Gitea Action mit .NET 10 generiert -->
|
||||
<img src="heatmap.svg" alt="Nicki's Gitea Contribution Heatmap" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Ausgewählte Projekte
|
||||
|
||||
Hier sind ein paar Highlights aus meiner Entwickler-Schmiede:
|
||||
|
||||
| Projekt | Stack | Beschreibung |
|
||||
| :--- | :---: | :--- |
|
||||
| 📝 **Markdown Editor Pro** | `Avalonia UI` • `C#` | Eleganter, performanter Desktop-Editor mit Live-Rendering, Math-Support und Theme-Engine. |
|
||||
| 💧 **CloudDrop** | `ASP.NET Core` • `Docker` | Self-hosted File-Sharing Service für schnelle, verschlüsselte Uploads & temporäre Links. |
|
||||
| 💳 **KasseApp** | `C#` • `SQLite` • `WPF/Avalonia` | Modulare POS- und Kassenabrechnungs-Software mit Offline-Synchronisation für Events. |
|
||||
| 🥤 **DrinkReminder** | `C#` • `Avalonia UI` | Schicker, unaufdringlicher Desktop- & Mobile-Helfer für gesunde Hydration im Dev-Alltag. |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Zuletzt aktualisierte Repositories
|
||||
|
||||
<!-- Dieser Bereich wird dynamisch von der C# .NET 10 Konsolen-App aktualisiert -->
|
||||
<!-- START_LATEST_REPOS -->
|
||||
- 🚀 [**.profile**](https://git.webcore-consulting.dev/NickiCloud/.profile) – 🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10
|
||||
`C# / Markdown` • 🕒 *Aktualisiert am 02.09.2026*
|
||||
<!-- END_LATEST_REPOS -->
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 🎮 Gaming Corner & Fun Facts
|
||||
|
||||
[](https://minecraft.net)
|
||||
[](https://www.ubisoft.com/game/rainbow-six/siege)
|
||||
[](https://www.warframe.com/)
|
||||
|
||||
```csharp
|
||||
var nicki = new Developer
|
||||
{
|
||||
Drink = "Kühles Spezi oder Mate",
|
||||
Editor = "JetBrains Rider",
|
||||
Terminal = "zsh on Pop!_OS",
|
||||
Motto = "Refactor early, test continuously, deploy autonomously."
|
||||
};
|
||||
```
|
||||
|
||||
<sub>Gebaut mit purer C# .NET 10 Power & Gitea Actions • Design im Lila-Fuchs-Style 💜</sub>
|
||||
|
||||
</div>
|
||||
|
||||
1220
heatmap.svg
Normal file
1220
heatmap.svg
Normal file
File diff suppressed because it is too large
Load Diff
42
src/ProfileManager/Models/GiteaRepo.cs
Normal file
42
src/ProfileManager/Models/GiteaRepo.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ProfileManager.Models;
|
||||
|
||||
/// <summary>
|
||||
/// DTO representing a repository in Gitea's /api/v1/users/{user}/repos response.
|
||||
/// </summary>
|
||||
public sealed class GiteaRepo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("full_name")]
|
||||
public string FullName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonPropertyName("html_url")]
|
||||
public string HtmlUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; set; }
|
||||
|
||||
[JsonPropertyName("fork")]
|
||||
public bool Fork { get; set; }
|
||||
|
||||
[JsonPropertyName("archived")]
|
||||
public bool Archived { get; set; }
|
||||
|
||||
[JsonPropertyName("stars_count")]
|
||||
public int StarsCount { get; set; }
|
||||
|
||||
[JsonPropertyName("forks_count")]
|
||||
public int ForksCount { get; set; }
|
||||
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
15
src/ProfileManager/Models/HeatmapItem.cs
Normal file
15
src/ProfileManager/Models/HeatmapItem.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ProfileManager.Models;
|
||||
|
||||
/// <summary>
|
||||
/// DTO representing an entry in Gitea's /api/v1/users/{user}/heatmap response.
|
||||
/// </summary>
|
||||
public sealed class HeatmapItem
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
[JsonPropertyName("contributions")]
|
||||
public int Contributions { get; set; }
|
||||
}
|
||||
28
src/ProfileManager/Models/ProfileStats.cs
Normal file
28
src/ProfileManager/Models/ProfileStats.cs
Normal file
@ -0,0 +1,28 @@
|
||||
namespace ProfileManager.Models;
|
||||
|
||||
public sealed class LanguageStat
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required double Percentage { get; init; }
|
||||
public required int RepoCount { get; init; }
|
||||
public required string Color { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DayContribution
|
||||
{
|
||||
public required DateOnly Date { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required int Level { get; init; } // 0..4
|
||||
public required int Column { get; init; } // 0..51
|
||||
public required int Row { get; init; } // 0..6 (0=Mo..6=So)
|
||||
}
|
||||
|
||||
public sealed class ProfileStats
|
||||
{
|
||||
public required int TotalContributions365 { get; init; }
|
||||
public required int CurrentStreak { get; init; }
|
||||
public required int LongestStreak { get; init; }
|
||||
public required List<DayContribution> Days { get; init; }
|
||||
public required List<LanguageStat> TopLanguages { get; init; }
|
||||
public required List<GiteaRepo> LatestRepos { get; init; }
|
||||
}
|
||||
11
src/ProfileManager/ProfileManager.csproj
Normal file
11
src/ProfileManager/ProfileManager.csproj
Normal file
@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
143
src/ProfileManager/Program.cs
Normal file
143
src/ProfileManager/Program.cs
Normal file
@ -0,0 +1,143 @@
|
||||
using System.Text;
|
||||
using ProfileManager.Services;
|
||||
|
||||
namespace ProfileManager;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
Console.WriteLine("""
|
||||
====================================================
|
||||
🦊 NickiCloud Gitea Profile Manager (.NET 10)
|
||||
Zero-Dependency Autonomous Activity Engine
|
||||
====================================================
|
||||
""");
|
||||
|
||||
// Parse arguments / environment variables
|
||||
var username = GetConfigValue(args, "--username", "GITEA_USERNAME", "NickiCloud");
|
||||
var apiUrl = GetConfigValue(args, "--api-url", "GITEA_API_URL", "https://git.webcore-consulting.dev/api/v1");
|
||||
var token = GetConfigValue(args, "--token", "GITEA_TOKEN", string.Empty);
|
||||
var svgPath = GetConfigValue(args, "--output-svg", "OUTPUT_SVG", "heatmap.svg");
|
||||
var readmePath = GetConfigValue(args, "--readme", "README_PATH", "README.md");
|
||||
|
||||
// Resolve paths relative to working directory or repo root
|
||||
if (!Path.IsPathRooted(svgPath))
|
||||
{
|
||||
svgPath = Path.GetFullPath(Path.Combine(FindRepoRoot(), svgPath));
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(readmePath))
|
||||
{
|
||||
readmePath = Path.GetFullPath(Path.Combine(FindRepoRoot(), readmePath));
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Config] Target User : {username}");
|
||||
Console.WriteLine($"[Config] API Endpoint: {apiUrl}");
|
||||
Console.WriteLine($"[Config] Output SVG : {svgPath}");
|
||||
Console.WriteLine($"[Config] Target README: {readmePath}");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
var client = new GiteaApiClient(apiUrl, string.IsNullOrWhiteSpace(token) ? null : token);
|
||||
|
||||
// Step 1: Fetch data in parallel
|
||||
var heatmapTask = client.GetHeatmapAsync(username);
|
||||
var reposTask = client.GetReposAsync(username);
|
||||
|
||||
await Task.WhenAll(heatmapTask, reposTask);
|
||||
|
||||
var heatmap = await heatmapTask;
|
||||
var repos = await reposTask;
|
||||
|
||||
// Step 2: Calculate metrics
|
||||
Console.WriteLine("[Stats] Calculating streaks, contributions and language metrics...");
|
||||
var stats = StatsCalculator.Calculate(heatmap, repos);
|
||||
|
||||
Console.WriteLine($"[Stats] Total Contributions (365d): {stats.TotalContributions365}");
|
||||
Console.WriteLine($"[Stats] Current Streak : {stats.CurrentStreak} days");
|
||||
Console.WriteLine($"[Stats] Longest Streak : {stats.LongestStreak} days");
|
||||
Console.WriteLine($"[Stats] Top Languages : {string.Join(", ", stats.TopLanguages.Select(l => $"{l.Name} ({l.Percentage:F1}%)"))}");
|
||||
Console.WriteLine($"[Stats] Latest Repositories : {string.Join(", ", stats.LatestRepos.Select(r => r.Name))}");
|
||||
Console.WriteLine();
|
||||
|
||||
// Step 3: Generate animated SVG
|
||||
Console.WriteLine($"[SVG] Rendering responsive SVG...");
|
||||
var svgContent = SvgGenerator.Generate(stats);
|
||||
var svgDir = Path.GetDirectoryName(svgPath);
|
||||
if (!string.IsNullOrEmpty(svgDir) && !Directory.Exists(svgDir))
|
||||
{
|
||||
Directory.CreateDirectory(svgDir);
|
||||
}
|
||||
await File.WriteAllTextAsync(svgPath, svgContent, Encoding.UTF8);
|
||||
Console.WriteLine($"[SVG] Saved successfully to: {svgPath}");
|
||||
|
||||
// Step 4: Inject latest repos into README.md
|
||||
if (File.Exists(readmePath))
|
||||
{
|
||||
Console.WriteLine($"[README] Injecting latest repositories into: {readmePath}");
|
||||
ReadmeInjector.InjectLatestRepos(readmePath, stats.LatestRepos);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[README-WARN] Target README not found at {readmePath}. Skipping injection.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("\n✨ Profile update finished successfully!");
|
||||
Console.ResetColor();
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"\n❌ Critical Error: {ex.Message}");
|
||||
Console.WriteLine(ex.StackTrace);
|
||||
Console.ResetColor();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConfigValue(string[] args, string flag, string envVar, string defaultValue)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
var env = Environment.GetEnvironmentVariable(envVar);
|
||||
if (!string.IsNullOrWhiteSpace(env))
|
||||
{
|
||||
return env;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var current = Directory.GetCurrentDirectory();
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current, ".git")) ||
|
||||
Directory.Exists(Path.Combine(current, ".gitea")) ||
|
||||
File.Exists(Path.Combine(current, "README.md")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(current)?.FullName;
|
||||
if (parent == current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return Directory.GetCurrentDirectory();
|
||||
}
|
||||
}
|
||||
208
src/ProfileManager/Services/GiteaApiClient.cs
Normal file
208
src/ProfileManager/Services/GiteaApiClient.cs
Normal file
@ -0,0 +1,208 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using ProfileManager.Models;
|
||||
|
||||
namespace ProfileManager.Services;
|
||||
|
||||
public sealed class GiteaApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _baseUrl;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public GiteaApiClient(string? baseUrl = null, string? apiToken = null)
|
||||
{
|
||||
_baseUrl = (baseUrl ?? Environment.GetEnvironmentVariable("GITEA_API_URL") ?? "https://git.webcore-consulting.dev/api/v1")
|
||||
.TrimEnd('/');
|
||||
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
_httpClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NickiProfileBot", "1.0"));
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var token = apiToken ?? Environment.GetEnvironmentVariable("GITEA_TOKEN");
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<HeatmapItem>> GetHeatmapAsync(string username, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/users/{Uri.EscapeDataString(username)}/heatmap";
|
||||
Console.WriteLine($"[API] Fetching heatmap from: {url}");
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var items = JsonSerializer.Deserialize<List<HeatmapItem>>(json, JsonOptions);
|
||||
if (items != null && items.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"[API] Successfully retrieved {items.Count} heatmap data points.");
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[API-WARN] Heatmap request failed (Status: {response.StatusCode}). Switching to mock fallback.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[API-WARN] Network error connecting to Gitea ({ex.Message}). Using realistic fallback data.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
return GenerateMockHeatmap();
|
||||
}
|
||||
|
||||
public async Task<List<GiteaRepo>> GetReposAsync(string username, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/users/{Uri.EscapeDataString(username)}/repos?limit=100&sort=updated";
|
||||
Console.WriteLine($"[API] Fetching repositories from: {url}");
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var repos = JsonSerializer.Deserialize<List<GiteaRepo>>(json, JsonOptions);
|
||||
if (repos != null && repos.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"[API] Successfully retrieved {repos.Count} repositories.");
|
||||
return repos;
|
||||
}
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[API-WARN] Repositories request failed (Status: {response.StatusCode}). Switching to mock fallback.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[API-WARN] Network error connecting to Gitea ({ex.Message}). Using realistic fallback data.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
return GenerateMockRepos();
|
||||
}
|
||||
|
||||
private static List<HeatmapItem> GenerateMockHeatmap()
|
||||
{
|
||||
var items = new List<HeatmapItem>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var random = new Random(42); // Fixed seed for reproducible fallback
|
||||
|
||||
for (int i = 0; i < 365; i++)
|
||||
{
|
||||
var day = now.AddDays(-i);
|
||||
// Simulate realistic developer habits (more activity on weekdays and active streaks)
|
||||
bool isWeekend = day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||
int chance = isWeekend ? 40 : 75;
|
||||
|
||||
if (random.Next(100) < chance)
|
||||
{
|
||||
int commits = random.Next(1, 14);
|
||||
items.Add(new HeatmapItem
|
||||
{
|
||||
Timestamp = day.ToUnixTimeSeconds(),
|
||||
Contributions = commits
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<GiteaRepo> GenerateMockRepos()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return
|
||||
[
|
||||
new GiteaRepo
|
||||
{
|
||||
Name = "Markdown-Editor-Pro",
|
||||
FullName = "NickiCloud/Markdown-Editor-Pro",
|
||||
Description = "Moderner, performanter Markdown-Editor mit Live-Vorschau, Syntax-Highlighting & LaTeX-Support auf Avalonia UI.",
|
||||
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/Markdown-Editor-Pro",
|
||||
Language = "C#",
|
||||
StarsCount = 7,
|
||||
ForksCount = 1,
|
||||
UpdatedAt = now.AddHours(-3)
|
||||
},
|
||||
new GiteaRepo
|
||||
{
|
||||
Name = "CloudDrop",
|
||||
FullName = "NickiCloud/CloudDrop",
|
||||
Description = "Leichtgewichtiger, selbstgehosteter File-Sharing-Dienst mit temporären Download-Links und verschlüsselter Ablage.",
|
||||
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/CloudDrop",
|
||||
Language = "C#",
|
||||
StarsCount = 5,
|
||||
ForksCount = 0,
|
||||
UpdatedAt = now.AddDays(-2)
|
||||
},
|
||||
new GiteaRepo
|
||||
{
|
||||
Name = "KasseApp",
|
||||
FullName = "NickiCloud/KasseApp",
|
||||
Description = "Intuitive Kassen- und Abrechnungssoftware für Vereine und Events mit Offline-First SQLite-Datenbank.",
|
||||
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/KasseApp",
|
||||
Language = "C#",
|
||||
StarsCount = 4,
|
||||
ForksCount = 0,
|
||||
UpdatedAt = now.AddDays(-5)
|
||||
},
|
||||
new GiteaRepo
|
||||
{
|
||||
Name = "DrinkReminder",
|
||||
FullName = "NickiCloud/DrinkReminder",
|
||||
Description = "Smarter Tray-Assistent für Desktop & Mobile zur Erinnerung an regelmäßige Flüssigkeitszufuhr.",
|
||||
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/DrinkReminder",
|
||||
Language = "C#",
|
||||
StarsCount = 3,
|
||||
ForksCount = 0,
|
||||
UpdatedAt = now.AddDays(-12)
|
||||
},
|
||||
new GiteaRepo
|
||||
{
|
||||
Name = "Selfhosted-Infra",
|
||||
FullName = "NickiCloud/Selfhosted-Infra",
|
||||
Description = "Docker-Compose & Konfigurationen für Debian, Gitea, Mailcow, Nginx Reverse Proxy, UFW & Fail2ban.",
|
||||
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/Selfhosted-Infra",
|
||||
Language = "Shell",
|
||||
StarsCount = 6,
|
||||
ForksCount = 0,
|
||||
UpdatedAt = now.AddDays(-18)
|
||||
},
|
||||
new GiteaRepo
|
||||
{
|
||||
Name = "WebCore-Portal",
|
||||
FullName = "NickiCloud/WebCore-Portal",
|
||||
Description = "Interaktives Service-Portal mit MudBlazor Komponenten und JWT-Authentifizierung.",
|
||||
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/WebCore-Portal",
|
||||
Language = "HTML",
|
||||
StarsCount = 2,
|
||||
ForksCount = 0,
|
||||
UpdatedAt = now.AddDays(-25)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
70
src/ProfileManager/Services/ReadmeInjector.cs
Normal file
70
src/ProfileManager/Services/ReadmeInjector.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ProfileManager.Models;
|
||||
|
||||
namespace ProfileManager.Services;
|
||||
|
||||
public static class ReadmeInjector
|
||||
{
|
||||
private const string StartMarker = "<!-- START_LATEST_REPOS -->";
|
||||
private const string EndMarker = "<!-- END_LATEST_REPOS -->";
|
||||
|
||||
public static bool InjectLatestRepos(string readmePath, List<GiteaRepo> repos)
|
||||
{
|
||||
if (!File.Exists(readmePath))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[README-WARN] File not found: {readmePath}");
|
||||
Console.ResetColor();
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = File.ReadAllText(readmePath, Encoding.UTF8);
|
||||
int startIndex = content.IndexOf(StartMarker, StringComparison.Ordinal);
|
||||
int endIndex = content.IndexOf(EndMarker, StringComparison.Ordinal);
|
||||
|
||||
if (startIndex == -1 || endIndex == -1 || endIndex < startIndex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[README-WARN] Markers '{StartMarker}' and '{EndMarker}' not found in {readmePath}");
|
||||
Console.ResetColor();
|
||||
return false;
|
||||
}
|
||||
|
||||
var replacement = new StringBuilder();
|
||||
replacement.AppendLine(StartMarker);
|
||||
|
||||
if (repos.Count == 0)
|
||||
{
|
||||
replacement.AppendLine("*Momentan keine öffentlichen Repositories verfügbar.*");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var repo in repos)
|
||||
{
|
||||
var desc = string.IsNullOrWhiteSpace(repo.Description)
|
||||
? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase)
|
||||
? "🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10"
|
||||
: "Persönliches Entwicklungsprojekt.")
|
||||
: repo.Description.Trim();
|
||||
|
||||
var lang = string.IsNullOrWhiteSpace(repo.Language)
|
||||
? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase) ? "C# / Markdown" : "Code")
|
||||
: repo.Language;
|
||||
var stars = repo.StarsCount > 0 ? $" • ⭐ {repo.StarsCount}" : string.Empty;
|
||||
var updated = repo.UpdatedAt.ToString("dd.MM.yyyy");
|
||||
|
||||
replacement.AppendLine($"- 🚀 [**{repo.Name}**]({repo.HtmlUrl}) – {desc} ");
|
||||
replacement.AppendLine($" `{lang}`{stars} • 🕒 *Aktualisiert am {updated}*");
|
||||
}
|
||||
}
|
||||
|
||||
replacement.Append(EndMarker);
|
||||
|
||||
var newContent = content.Substring(0, startIndex) + replacement + content.Substring(endIndex + EndMarker.Length);
|
||||
|
||||
File.WriteAllText(readmePath, newContent, Encoding.UTF8);
|
||||
Console.WriteLine($"[README] Successfully injected {repos.Count} repositories into {readmePath}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
180
src/ProfileManager/Services/StatsCalculator.cs
Normal file
180
src/ProfileManager/Services/StatsCalculator.cs
Normal file
@ -0,0 +1,180 @@
|
||||
using ProfileManager.Models;
|
||||
|
||||
namespace ProfileManager.Services;
|
||||
|
||||
public static class StatsCalculator
|
||||
{
|
||||
private static readonly Dictionary<string, string> LanguageColors = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "C#", "#a855f7" },
|
||||
{ "C++", "#f34b7d" },
|
||||
{ "C", "#555555" },
|
||||
{ "HTML", "#e34c26" },
|
||||
{ "CSS", "#563d7c" },
|
||||
{ "JavaScript", "#f1e05a" },
|
||||
{ "TypeScript", "#3178c6" },
|
||||
{ "Python", "#3572A5" },
|
||||
{ "Shell", "#89e051" },
|
||||
{ "Bash", "#89e051" },
|
||||
{ "Dockerfile", "#384d54" },
|
||||
{ "Go", "#00ADD8" },
|
||||
{ "Rust", "#dea584" },
|
||||
{ "Vue", "#41b883" },
|
||||
{ "PHP", "#4F5D95" },
|
||||
{ "Kotlin", "#A97BFF" },
|
||||
{ "Java", "#b07219" },
|
||||
{ "Dart", "#00B4AB" }
|
||||
};
|
||||
|
||||
public static ProfileStats Calculate(List<HeatmapItem> heatmapItems, List<GiteaRepo> repos, DateOnly? referenceDate = null)
|
||||
{
|
||||
var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
// 1. Group heatmap contributions by DateOnly (UTC)
|
||||
var dateMap = new Dictionary<DateOnly, int>();
|
||||
foreach (var item in heatmapItems)
|
||||
{
|
||||
var date = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).UtcDateTime);
|
||||
dateMap[date] = dateMap.GetValueOrDefault(date, 0) + Math.Max(0, item.Contributions);
|
||||
}
|
||||
|
||||
// 2. Build 52x7 Grid ending on Sunday of current week (Monday-based rows 0..6)
|
||||
int dayOffsetFromMonday = ((int)today.DayOfWeek + 6) % 7;
|
||||
var gridEndSunday = today.AddDays(6 - dayOffsetFromMonday);
|
||||
var gridStartMonday = gridEndSunday.AddDays(-(52 * 7 - 1));
|
||||
|
||||
var days = new List<DayContribution>(52 * 7);
|
||||
for (int col = 0; col < 52; col++)
|
||||
{
|
||||
for (int row = 0; row < 7; row++)
|
||||
{
|
||||
var currentDate = gridStartMonday.AddDays(col * 7 + row);
|
||||
int count = currentDate > today ? 0 : dateMap.GetValueOrDefault(currentDate, 0);
|
||||
int level = count switch
|
||||
{
|
||||
0 => 0,
|
||||
>= 1 and <= 3 => 1,
|
||||
>= 4 and <= 6 => 2,
|
||||
>= 7 and <= 9 => 3,
|
||||
_ => 4
|
||||
};
|
||||
|
||||
days.Add(new DayContribution
|
||||
{
|
||||
Date = currentDate,
|
||||
Count = count,
|
||||
Level = level,
|
||||
Column = col,
|
||||
Row = row
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Total contributions (last 365 days)
|
||||
var oneYearAgo = today.AddDays(-365);
|
||||
int total365 = dateMap
|
||||
.Where(kv => kv.Key >= oneYearAgo && kv.Key <= today)
|
||||
.Sum(kv => kv.Value);
|
||||
|
||||
// 4. Streaks calculation
|
||||
int currentStreak = 0;
|
||||
if (dateMap.GetValueOrDefault(today, 0) > 0)
|
||||
{
|
||||
currentStreak = 1;
|
||||
var check = today.AddDays(-1);
|
||||
while (dateMap.GetValueOrDefault(check, 0) > 0)
|
||||
{
|
||||
currentStreak++;
|
||||
check = check.AddDays(-1);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// If today has no commit yet, yesterday's streak is still active
|
||||
var check = today.AddDays(-1);
|
||||
while (dateMap.GetValueOrDefault(check, 0) > 0)
|
||||
{
|
||||
currentStreak++;
|
||||
check = check.AddDays(-1);
|
||||
}
|
||||
}
|
||||
|
||||
// Longest Streak across all dates
|
||||
int longestStreak = 0;
|
||||
if (dateMap.Count > 0)
|
||||
{
|
||||
var minDate = dateMap.Keys.Min();
|
||||
if (minDate > gridStartMonday) minDate = gridStartMonday;
|
||||
|
||||
int currentRun = 0;
|
||||
for (var d = minDate; d <= today; d = d.AddDays(1))
|
||||
{
|
||||
if (dateMap.GetValueOrDefault(d, 0) > 0)
|
||||
{
|
||||
currentRun++;
|
||||
if (currentRun > longestStreak) longestStreak = currentRun;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentRun = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Top 3 Repos (sorted by UpdatedAt desc, exclude forks)
|
||||
var nonForkRepos = repos.Where(r => !r.Fork).ToList();
|
||||
var candidateRepos = nonForkRepos.Count > 0 ? nonForkRepos : repos;
|
||||
|
||||
var latestRepos = candidateRepos
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
// 6. Top Languages calculation
|
||||
var languageGroups = candidateRepos
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Language))
|
||||
.GroupBy(r => r.Language!.Trim())
|
||||
.Select(g => new { Name = g.Key, Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ToList();
|
||||
|
||||
int totalLanguageRepos = languageGroups.Sum(x => x.Count);
|
||||
var topLanguages = new List<LanguageStat>();
|
||||
|
||||
if (totalLanguageRepos > 0)
|
||||
{
|
||||
foreach (var lang in languageGroups.Take(5))
|
||||
{
|
||||
double pct = Math.Round((double)lang.Count / totalLanguageRepos * 100.0, 1);
|
||||
topLanguages.Add(new LanguageStat
|
||||
{
|
||||
Name = lang.Name,
|
||||
Percentage = pct,
|
||||
RepoCount = lang.Count,
|
||||
Color = LanguageColors.GetValueOrDefault(lang.Name, "#c084fc")
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default language representation if repos have no language tags
|
||||
topLanguages.Add(new LanguageStat
|
||||
{
|
||||
Name = "C#",
|
||||
Percentage = 100.0,
|
||||
RepoCount = 1,
|
||||
Color = "#a855f7"
|
||||
});
|
||||
}
|
||||
|
||||
return new ProfileStats
|
||||
{
|
||||
TotalContributions365 = total365,
|
||||
CurrentStreak = currentStreak,
|
||||
LongestStreak = Math.Max(longestStreak, currentStreak),
|
||||
Days = days,
|
||||
TopLanguages = topLanguages,
|
||||
LatestRepos = latestRepos
|
||||
};
|
||||
}
|
||||
}
|
||||
213
src/ProfileManager/Services/SvgGenerator.cs
Normal file
213
src/ProfileManager/Services/SvgGenerator.cs
Normal file
@ -0,0 +1,213 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ProfileManager.Models;
|
||||
|
||||
namespace ProfileManager.Services;
|
||||
|
||||
public static class SvgGenerator
|
||||
{
|
||||
private static readonly string[] MonthNames =
|
||||
[
|
||||
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"
|
||||
];
|
||||
|
||||
public static string Generate(ProfileStats stats)
|
||||
{
|
||||
var sb = new StringBuilder(16384);
|
||||
|
||||
const int svgWidth = 740;
|
||||
const int svgHeight = 265;
|
||||
const int gridStartX = 34;
|
||||
const int gridStartY = 64;
|
||||
const int cellSize = 10;
|
||||
const int cellGap = 3;
|
||||
const int colStep = cellSize + cellGap; // 13px
|
||||
|
||||
sb.AppendLine($"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {svgWidth} {svgHeight}" width="100%" height="100%">""");
|
||||
sb.AppendLine(" <defs>");
|
||||
sb.AppendLine(" <linearGradient id=\"foxGrad\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">");
|
||||
sb.AppendLine(" <stop offset=\"0%\" stop-color=\"#d980fa\" />");
|
||||
sb.AppendLine(" <stop offset=\"100%\" stop-color=\"#7e3f98\" />");
|
||||
sb.AppendLine(" </linearGradient>");
|
||||
sb.AppendLine(" <clipPath id=\"langBarClip\">");
|
||||
sb.AppendLine($" <rect x=\"{gridStartX}\" y=\"215\" width=\"676\" height=\"8\" rx=\"4\" />");
|
||||
sb.AppendLine(" </clipPath>");
|
||||
sb.AppendLine(" </defs>");
|
||||
|
||||
// Styles & Media Queries
|
||||
sb.AppendLine("""
|
||||
<style>
|
||||
:root {
|
||||
--bg: #11111b;
|
||||
--card: #181825;
|
||||
--border: #313244;
|
||||
--text-main: #cdd6f4;
|
||||
--text-sub: #a6adc8;
|
||||
--text-muted: #6c7086;
|
||||
--accent: #d980fa;
|
||||
--c-lvl-0: #1e1e2e;
|
||||
--c-lvl-0-border: #27273a;
|
||||
--c-lvl-1: #4a2559;
|
||||
--c-lvl-2: #7e3f98;
|
||||
--c-lvl-3: #a349a4;
|
||||
--c-lvl-4: #d980fa;
|
||||
--metric-bg: #1e1e2e;
|
||||
--metric-border: #313244;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #fbf7ff;
|
||||
--card: #ffffff;
|
||||
--border: #e9d5ff;
|
||||
--text-main: #2e1065;
|
||||
--text-sub: #6b21a8;
|
||||
--text-muted: #9333ea;
|
||||
--accent: #9333ea;
|
||||
--c-lvl-0: #ede9fe;
|
||||
--c-lvl-0-border: #e4d4f7;
|
||||
--c-lvl-1: #c4b5fd;
|
||||
--c-lvl-2: #a78bfa;
|
||||
--c-lvl-3: #8b5cf6;
|
||||
--c-lvl-4: #6d28d9;
|
||||
--metric-bg: #faf5ff;
|
||||
--metric-border: #e9d5ff;
|
||||
}
|
||||
}
|
||||
.bg-panel { fill: var(--bg); stroke: var(--border); stroke-width: 1.2px; rx: 12px; }
|
||||
.title { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; fill: var(--text-main); }
|
||||
.subtitle { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 11px; fill: var(--text-sub); }
|
||||
.lbl-month, .lbl-day { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 9px; fill: var(--text-muted); }
|
||||
.stat-val { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 700; fill: var(--text-main); }
|
||||
.stat-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; fill: var(--text-sub); }
|
||||
.lang-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; font-weight: 500; fill: var(--text-sub); }
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% { opacity: 0; transform: scale(0.65); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.cell {
|
||||
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
.lvl-0 { fill: var(--c-lvl-0); stroke: var(--c-lvl-0-border); stroke-width: 0.5px; }
|
||||
.lvl-1 { fill: var(--c-lvl-1); }
|
||||
.lvl-2 { fill: var(--c-lvl-2); }
|
||||
.lvl-3 { fill: var(--c-lvl-3); }
|
||||
.lvl-4 { fill: var(--c-lvl-4); }
|
||||
</style>
|
||||
""");
|
||||
|
||||
// Background Panel
|
||||
sb.AppendLine($" <rect class=\"bg-panel\" x=\"1\" y=\"1\" width=\"{svgWidth - 2}\" height=\"{svgHeight - 2}\" />");
|
||||
|
||||
// Header: Title & Total Commits Badge
|
||||
sb.AppendLine(" <g transform=\"translate(20, 20)\">");
|
||||
sb.AppendLine(" <!-- Fox Icon & Title -->");
|
||||
sb.AppendLine(" <text class=\"title\" x=\"0\" y=\"14\">🦊 NickiCloud <tspan font-weight=\"400\" fill=\"var(--accent)\">•</tspan> <tspan font-weight=\"500\" font-size=\"12\" fill=\"var(--text-sub)\">Gitea Activity</tspan></text>");
|
||||
|
||||
// Header badge (right aligned)
|
||||
string badgeText = $"{stats.TotalContributions365:N0} Commits im letzten Jahr";
|
||||
sb.AppendLine($" <rect x=\"510\" y=\"0\" width=\"190\" height=\"22\" rx=\"11\" fill=\"var(--metric-bg)\" stroke=\"var(--metric-border)\" stroke-width=\"1\" />");
|
||||
sb.AppendLine($" <circle cx=\"522\" cy=\"11\" r=\"4\" fill=\"var(--accent)\" />");
|
||||
sb.AppendLine($" <text class=\"subtitle\" x=\"532\" y=\"15\" font-weight=\"600\">{badgeText}</text>");
|
||||
sb.AppendLine(" </g>");
|
||||
|
||||
// Month Labels
|
||||
sb.AppendLine(" <!-- Month Labels -->");
|
||||
int lastMonth = -1;
|
||||
for (int col = 0; col < 52; col++)
|
||||
{
|
||||
var firstDayOfCol = stats.Days.FirstOrDefault(d => d.Column == col && d.Row == 0);
|
||||
if (firstDayOfCol != null && firstDayOfCol.Date.Month != lastMonth)
|
||||
{
|
||||
lastMonth = firstDayOfCol.Date.Month;
|
||||
// Avoid rendering month if too close to right edge
|
||||
if (col < 50)
|
||||
{
|
||||
int x = gridStartX + col * colStep;
|
||||
sb.AppendLine($" <text class=\"lbl-month\" x=\"{x}\" y=\"54\">{MonthNames[lastMonth - 1]}</text>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Day of Week Labels (Mo, Mi, Fr)
|
||||
sb.AppendLine(" <!-- Weekday Labels -->");
|
||||
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 8}\">Mo</text>");
|
||||
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 2 * colStep + 8}\">Mi</text>");
|
||||
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 4 * colStep + 8}\">Fr</text>");
|
||||
|
||||
// Heatmap Cells (52 cols x 7 rows)
|
||||
sb.AppendLine(" <!-- Heatmap Grid -->");
|
||||
sb.AppendLine(" <g>");
|
||||
foreach (var day in stats.Days)
|
||||
{
|
||||
int x = gridStartX + day.Column * colStep;
|
||||
int y = gridStartY + day.Row * colStep;
|
||||
int delayMs = day.Column * 12;
|
||||
|
||||
string tooltip = $"{day.Date:dd.MM.yyyy}: {day.Count} Commits";
|
||||
sb.AppendLine($" <rect class=\"cell lvl-{day.Level}\" x=\"{x}\" y=\"{y}\" width=\"{cellSize}\" height=\"{cellSize}\" rx=\"2\" style=\"animation-delay: {delayMs}ms;\">");
|
||||
sb.AppendLine($" <title>{tooltip}</title>");
|
||||
sb.AppendLine(" </rect>");
|
||||
}
|
||||
sb.AppendLine(" </g>");
|
||||
|
||||
// Stats Row: 3 metric cards
|
||||
sb.AppendLine(" <!-- Stat Badges -->");
|
||||
int cardY = 166;
|
||||
int cardHeight = 36;
|
||||
int cardWidth = 216;
|
||||
int gap = 14;
|
||||
|
||||
// Card 1: Total
|
||||
RenderMetricCard(sb, gridStartX, cardY, cardWidth, cardHeight, "📦", $"{stats.TotalContributions365:N0}", "Jahres-Beiträge");
|
||||
|
||||
// Card 2: Current Streak
|
||||
RenderMetricCard(sb, gridStartX + cardWidth + gap, cardY, cardWidth, cardHeight, "🔥", $"{stats.CurrentStreak} {(stats.CurrentStreak == 1 ? "Tag" : "Tage")}", "Aktueller Streak");
|
||||
|
||||
// Card 3: Longest Streak
|
||||
RenderMetricCard(sb, gridStartX + (cardWidth + gap) * 2, cardY, cardWidth, cardHeight, "⚡", $"{stats.LongestStreak} {(stats.LongestStreak == 1 ? "Tag" : "Tage")}", "Längster Streak");
|
||||
|
||||
// Top Languages Bar (width = 676px)
|
||||
sb.AppendLine(" <!-- Top Languages Bar -->");
|
||||
sb.AppendLine(" <g clip-path=\"url(#langBarClip)\">");
|
||||
double currentX = gridStartX;
|
||||
const double totalBarWidth = 676.0;
|
||||
|
||||
foreach (var lang in stats.TopLanguages)
|
||||
{
|
||||
double w = Math.Max(2.0, (lang.Percentage / 100.0) * totalBarWidth);
|
||||
sb.AppendLine($" <rect x=\"{currentX.ToString("F1", CultureInfo.InvariantCulture)}\" y=\"215\" width=\"{w.ToString("F1", CultureInfo.InvariantCulture)}\" height=\"8\" fill=\"{lang.Color}\">");
|
||||
sb.AppendLine($" <title>{lang.Name}: {lang.Percentage:F1}%</title>");
|
||||
sb.AppendLine(" </rect>");
|
||||
currentX += w;
|
||||
}
|
||||
sb.AppendLine(" </g>");
|
||||
|
||||
// Language Legend
|
||||
sb.AppendLine(" <!-- Language Legend -->");
|
||||
sb.AppendLine(" <g transform=\"translate(34, 238)\">");
|
||||
int legendX = 0;
|
||||
foreach (var lang in stats.TopLanguages)
|
||||
{
|
||||
sb.AppendLine($" <circle cx=\"{legendX + 4}\" cy=\"4\" r=\"4\" fill=\"{lang.Color}\" />");
|
||||
string text = $"{lang.Name} {lang.Percentage:F1}%";
|
||||
sb.AppendLine($" <text class=\"lang-lbl\" x=\"{legendX + 13}\" y=\"7\">{text}</text>");
|
||||
legendX += 13 + (text.Length * 7) + 16;
|
||||
}
|
||||
sb.AppendLine(" </g>");
|
||||
|
||||
sb.AppendLine("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void RenderMetricCard(StringBuilder sb, int x, int y, int w, int h, string icon, string value, string label)
|
||||
{
|
||||
sb.AppendLine($" <g transform=\"translate({x}, {y})\">");
|
||||
sb.AppendLine($" <rect width=\"{w}\" height=\"{h}\" rx=\"8\" fill=\"var(--metric-bg)\" stroke=\"var(--metric-border)\" stroke-width=\"1\" />");
|
||||
sb.AppendLine($" <text x=\"12\" y=\"23\" font-size=\"16\">{icon}</text>");
|
||||
sb.AppendLine($" <text class=\"stat-val\" x=\"38\" y=\"17\">{value}</text>");
|
||||
sb.AppendLine($" <text class=\"stat-lbl\" x=\"38\" y=\"29\">{label}</text>");
|
||||
sb.AppendLine(" </g>");
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user