From 4ded94d1fbe85fad63504f14fc7c6e18acfb7c7b Mon Sep 17 00:00:00 2001 From: Standley Gury Date: Sun, 19 Jul 2026 15:25:18 +0800 Subject: [PATCH] feat: Enhance overview page with song and user statistics, add RankList component, and implement responsive design --- .claude/settings.local.json | 10 +- CLAUDE.md | 4 +- deployment/Dockerfile | 5 +- src/UI/App/.env.production | 2 +- src/UI/App/.prettierignore | 3 +- src/UI/App/src/components/RankList.tsx | 64 +++++++++ src/UI/App/src/index.css | 155 ++++++++++++++++++-- src/UI/App/src/layouts/DashboardLayout.tsx | 6 + src/UI/App/src/pages/Overview.tsx | 159 +++++++++++++++++++++ src/UI/App/src/pages/SongStats.tsx | 30 ++-- src/UI/App/src/pages/UserStats.tsx | 62 ++------ src/UI/App/src/router.tsx | 7 +- src/UI/App/src/services/api.ts | 5 +- src/UI/App/src/utils/csv.ts | 23 --- src/UI/App/src/utils/time.ts | 17 +++ 15 files changed, 437 insertions(+), 115 deletions(-) create mode 100644 src/UI/App/src/components/RankList.tsx create mode 100644 src/UI/App/src/pages/Overview.tsx delete mode 100644 src/UI/App/src/utils/csv.ts create mode 100644 src/UI/App/src/utils/time.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 025c0c4..cf599e2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,15 @@ "Bash(node scripts/validate_palette.js \"#ff6b6b,#4ecdc4,#45b7d1,#f9ca24,#f0932b,#eb4d4b,#6ab04c,#9c88ff\" --mode dark)", "PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\bundled-skills\\\\2.1.206\\\\086a8e9e37ae6507cf6e474311e79744\\\\dataviz\\\\scripts\\\\validate_palette.js\" \"#ff6b6b,#4ecdc4,#45b7d1,#f9ca24,#f0932b,#eb4d4b,#6ab04c,#9c88ff\" --mode dark)", "PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\bundled-skills\\\\2.1.206\\\\086a8e9e37ae6507cf6e474311e79744\\\\dataviz\\\\scripts\\\\validate_palette.js\" \"#3987e5,#199e70,#c98500,#008300,#9085e9,#e66767,#d55181,#d95926\" --mode dark --surface \"#366472\")", - "PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--personal-Discord-Music-Bot\\\\42ca7a0b-f5a7-4a2e-a708-2f026c81c580\\\\scratchpad\\\\mock-api.js\")" + "PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--personal-Discord-Music-Bot\\\\42ca7a0b-f5a7-4a2e-a708-2f026c81c580\\\\scratchpad\\\\mock-api.js\")", + "PowerShell(try { $conns = Get-NetTCPConnection -LocalPort 5000,5173 -State Listen -ErrorAction Stop } catch { $conns = @\\(\\) }; if \\($conns.Count -eq 0\\) { \"no listeners on 5000/5173\" } else { foreach \\($c in $conns\\) { $p = Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue; if \\($p -and $p.ProcessName -match 'node|bun'\\) { Stop-Process -Id $p.Id -Force -Confirm:$false; \"killed $\\($p.ProcessName\\) pid $\\($p.Id\\) on port $\\($c.LocalPort\\)\" } else { \"port $\\($c.LocalPort\\): $\\($p.ProcessName\\) left alone\" } } })", + "PowerShell(bun run *)", + "PowerShell(Start-Process node -ArgumentList \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--personal-Discord-Music-Bot\\\\42ca7a0b-f5a7-4a2e-a708-2f026c81c580\\\\scratchpad\\\\mock-api.js\" -WindowStyle Hidden; Set-Location \"G:\\\\personal\\\\Discord_Music_Bot\\\\src\\\\UI\\\\App\"; Start-Process bun -ArgumentList \"run\",\"dev\" -WindowStyle Hidden; Start-Sleep -Seconds 5; \\(Invoke-WebRequest http://localhost:5173 -UseBasicParsing\\).StatusCode; \\(Invoke-WebRequest http://localhost:5000/api/users -UseBasicParsing\\).StatusCode)", + "PowerShell(Test-Path *)", + "PowerShell($out = \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--personal-Discord-Music-Bot\\\\42ca7a0b-f5a7-4a2e-a708-2f026c81c580\\\\scratchpad\"; & \"C:\\\\Program Files \\(x86\\)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe\" --headless --disable-gpu --dump-dom \"http://localhost:5000/api/users\" 2>$null | Select-Object -First 3)", + "PowerShell($html = & \"C:\\\\Program Files \\(x86\\)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe\" --headless --disable-gpu --dump-dom \"http://localhost:5173/\" 2>&1; \"len=$\\($html.Length\\)\"; $html2 = & \"C:\\\\Program Files \\(x86\\)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe\" --headless --disable-gpu --dump-dom \"http://127.0.0.1:5000/api/users\" 2>&1; \"api-len=$\\($html2.Length\\)\"; \\($html2 | Out-String\\).Substring\\(0, [Math]::Min\\(300, \\($html2 | Out-String\\).Length\\)\\))", + "PowerShell(Get-Process *)", + "PowerShell(try { $c = Get-NetTCPConnection -LocalPort 5173 -State Listen -ErrorAction Stop; $c | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -Confirm:$false }; \"killed vite\" } catch { \"no listener on 5173\" }; try { $c = Get-NetTCPConnection -LocalPort 5000 -State Listen -ErrorAction Stop; $c | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -Confirm:$false }; \"killed mock api\" } catch { \"no listener on 5000\" })" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 3dc8dcb..29c38c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,8 +50,8 @@ Tests live in `src/Tests/` (xUnit + NSubstitute; integration tests use Testconta - **Infrastructure** (`src/Infrastructure/`) - NetCord commands and interactions, audio pipeline (`GuildPlayerManager`, `GuildPlayer`, `AudioPlayerService`, `FfmpegProcessService`, `MusicQueueService`), `PlayerHandler` (thin guild-scoped Skip/Stop event adapter), YouTube/SoundCloud stream services, EF Core `DiscordBotContext` with compiled models, and radio/user/blacklist/statistics services. - **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, guild player, guild player manager, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL). - **UI** (`src/UI/`) - - `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Login checks against `JwtSettings:InternalPassword` (no user password hashing). - - `App/` - React 19 + TypeScript SPA. Build output writes into `src/Worker/wwwroot/`, which is served by the Worker as static files with SPA fallback to `index.html`. + - `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Anonymous stats endpoints (`/api/statistics-all`, `/api/statistics-today` for today's top songs, `/api/users`); radio-source CRUD and token validation require authorization. Login checks against `JwtSettings:InternalPassword` (no user password hashing). + - `App/` - React 19 + TypeScript SPA, responsive down to phone widths (breakpoints at 1024/768/480px in `index.css`; `hide-sm`/`hide-md` classes drop table columns on small screens). The index route is an Overview landing page (`pages/Overview.tsx` + `components/RankList.tsx`) with headline stats, top-songs-today, all-time favorites, top listeners, and a latest-activity feed derived from each user's recent songs. The song/user dashboards support search, sortable columns, pagination (`Pagination`/`SortableTh` components, 10 rows per page), an all-time/today filter (songs), relative "last played" timestamps (`utils/time.ts`), and auto-refresh every 60s via TanStack Query `refetchInterval`. Charts adapt to mobile via the `useIsMobile` hook. The API base URL is relative (`/api`) in production builds because the Worker serves the SPA and API from the same origin (works for local docker and deployment alike); only `.env.development` points at `http://localhost:5000/api` for the Vite dev server. Build output writes into `src/Worker/wwwroot/`, which is served by the Worker as static files with SPA fallback to `index.html`. - **Worker** (`src/Worker/`) - Composition root. Program.cs builds a `WebApplication` that hosts the NetCord Discord gateway (registered via `AddDiscordGateway`), the ASP.NET Core web API, and the `GuildPlayerManager` hosted service in one process on port 5000. ### Key Patterns diff --git a/deployment/Dockerfile b/deployment/Dockerfile index b1782d1..e6c7b3f 100644 --- a/deployment/Dockerfile +++ b/deployment/Dockerfile @@ -23,8 +23,9 @@ FROM lib-build AS build ARG BUILD_CONFIGURATION=Release ARG PROJECT_PATH=src/Worker/Worker.csproj -ARG VITE_API_BASE_URL=https://rytho.standleypg.com/api -ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +# The frontend calls the API via a relative /api path (see src/UI/App/.env.production), +# so the same image works locally and in deployment without a baked-in URL. WORKDIR /app diff --git a/src/UI/App/.env.production b/src/UI/App/.env.production index 9873d0f..14ea4ad 100644 --- a/src/UI/App/.env.production +++ b/src/UI/App/.env.production @@ -1 +1 @@ -VITE_API_BASE_URL=https://rytho.standleypg.com/api +VITE_API_BASE_URL=/api diff --git a/src/UI/App/.prettierignore b/src/UI/App/.prettierignore index 050dd3a..b25dbe4 100644 --- a/src/UI/App/.prettierignore +++ b/src/UI/App/.prettierignore @@ -1,4 +1,5 @@ dist node_modules +obj package-lock.json -*.min.css \ No newline at end of file +*.min.css diff --git a/src/UI/App/src/components/RankList.tsx b/src/UI/App/src/components/RankList.tsx new file mode 100644 index 0000000..ceabf46 --- /dev/null +++ b/src/UI/App/src/components/RankList.tsx @@ -0,0 +1,64 @@ +export interface RankListItem { + key: string; + label: string; + sublabel?: string | null; + value: string; + valueTitle?: string; +} + +interface RankListProps { + title: string; + items: RankListItem[]; + emptyMessage: string; + showRank?: boolean; + onViewAll?: () => void; +} + +export function RankList({ + title, + items, + emptyMessage, + showRank = true, + onViewAll, +}: RankListProps) { + return ( +
+
+

{title}

+ {onViewAll && ( + + )} +
+ {items.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
    + {items.map((item, index) => ( +
  1. + {showRank && ( + + {index + 1} + + )} +
    +
    + {item.label} +
    + {item.sublabel && ( +
    + {item.sublabel} +
    + )} +
    + + {item.value} + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/UI/App/src/index.css b/src/UI/App/src/index.css index f8f2a52..2a3b435 100644 --- a/src/UI/App/src/index.css +++ b/src/UI/App/src/index.css @@ -109,6 +109,7 @@ body { .stat-song-title { display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; @@ -272,7 +273,7 @@ body { width: 100%; } -/* ===== Toolbar (search / filters / export) ===== */ +/* ===== Toolbar (search / filters) ===== */ .toolbar { display: flex; @@ -290,10 +291,120 @@ body { padding: 0.6rem 1.1rem; } -.export-button { - padding: 0.6rem 1.25rem; +/* ===== Overview page ===== */ + +.overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem; +} + +.panel { + padding: 1.5rem; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.panel-title { + font-size: 1.05rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + margin: 0; +} + +.view-all-button { + padding: 0.35rem 0.9rem; + font-size: 0.75rem; + white-space: nowrap; +} + +.panel-empty { + color: rgba(255, 255, 255, 0.55); + font-size: 0.9rem; + padding: 0.5rem 0; + margin: 0; +} + +.rank-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; +} + +.rank-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0.7rem; + border-radius: 0.6rem; + background: rgba(255, 255, 255, 0.05); +} + +.rank-badge { + width: 1.7rem; + height: 1.7rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.8); +} + +.rank-badge.rank-1 { + background: #f5c542; + color: #3b2f00; +} + +.rank-badge.rank-2 { + background: #c8d1dc; + color: #2b3542; +} + +.rank-badge.rank-3 { + background: #d2996b; + color: #3d2412; +} + +.rank-info { + flex: 1; + min-width: 0; +} + +.rank-label { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rank-sublabel { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.55); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rank-value { font-size: 0.85rem; - margin-left: auto; + font-weight: 600; + color: #86b6ef; + white-space: nowrap; + flex-shrink: 0; } /* ===== Pagination ===== */ @@ -1010,15 +1121,20 @@ body { flex-basis: 100%; } - .export-button { - margin-left: 0; - } - .content-card { padding: 1rem; min-height: 300px; } + .overview-grid { + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + } + + .panel { + padding: 1rem; + } + .chart-container { height: 320px; } @@ -1108,4 +1224,27 @@ body { .stat-label { font-size: 0.8rem; } + + .rank-item { + gap: 0.55rem; + padding: 0.5rem 0.55rem; + } + + .rank-badge { + width: 1.5rem; + height: 1.5rem; + font-size: 0.75rem; + } + + .rank-label { + font-size: 0.85rem; + } + + .rank-value { + font-size: 0.78rem; + } + + .panel-header { + margin-bottom: 0.75rem; + } } diff --git a/src/UI/App/src/layouts/DashboardLayout.tsx b/src/UI/App/src/layouts/DashboardLayout.tsx index 1d6f7eb..5f53709 100644 --- a/src/UI/App/src/layouts/DashboardLayout.tsx +++ b/src/UI/App/src/layouts/DashboardLayout.tsx @@ -29,6 +29,12 @@ export function DashboardLayout() { Rytho Dashboard