Skip to content

Commit 4ded94d

Browse files
committed
feat: Enhance overview page with song and user statistics, add RankList component, and implement responsive design
1 parent aba4e5a commit 4ded94d

15 files changed

Lines changed: 437 additions & 115 deletions

File tree

.claude/settings.local.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
"Bash(node scripts/validate_palette.js \"#ff6b6b,#4ecdc4,#45b7d1,#f9ca24,#f0932b,#eb4d4b,#6ab04c,#9c88ff\" --mode dark)",
99
"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)",
1010
"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\")",
11-
"PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--personal-Discord-Music-Bot\\\\42ca7a0b-f5a7-4a2e-a708-2f026c81c580\\\\scratchpad\\\\mock-api.js\")"
11+
"PowerShell(node \"C:\\\\Users\\\\stand\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--personal-Discord-Music-Bot\\\\42ca7a0b-f5a7-4a2e-a708-2f026c81c580\\\\scratchpad\\\\mock-api.js\")",
12+
"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\" } } })",
13+
"PowerShell(bun run *)",
14+
"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)",
15+
"PowerShell(Test-Path *)",
16+
"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)",
17+
"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\\)\\))",
18+
"PowerShell(Get-Process *)",
19+
"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\" })"
1220
]
1321
}
1422
}

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ Tests live in `src/Tests/` (xUnit + NSubstitute; integration tests use Testconta
5050
- **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.
5151
- **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, guild player, guild player manager, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL).
5252
- **UI** (`src/UI/`)
53-
- `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Login checks against `JwtSettings:InternalPassword` (no user password hashing).
54-
- `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`.
53+
- `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).
54+
- `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`.
5555
- **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.
5656

5757
### Key Patterns

deployment/Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ FROM lib-build AS build
2323

2424
ARG BUILD_CONFIGURATION=Release
2525
ARG PROJECT_PATH=src/Worker/Worker.csproj
26-
ARG VITE_API_BASE_URL=https://rytho.standleypg.com/api
27-
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
26+
27+
# The frontend calls the API via a relative /api path (see src/UI/App/.env.production),
28+
# so the same image works locally and in deployment without a baked-in URL.
2829

2930
WORKDIR /app
3031

src/UI/App/.env.production

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VITE_API_BASE_URL=https://rytho.standleypg.com/api
1+
VITE_API_BASE_URL=/api

src/UI/App/.prettierignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
dist
22
node_modules
3+
obj
34
package-lock.json
4-
*.min.css
5+
*.min.css
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
export interface RankListItem {
2+
key: string;
3+
label: string;
4+
sublabel?: string | null;
5+
value: string;
6+
valueTitle?: string;
7+
}
8+
9+
interface RankListProps {
10+
title: string;
11+
items: RankListItem[];
12+
emptyMessage: string;
13+
showRank?: boolean;
14+
onViewAll?: () => void;
15+
}
16+
17+
export function RankList({
18+
title,
19+
items,
20+
emptyMessage,
21+
showRank = true,
22+
onViewAll,
23+
}: RankListProps) {
24+
return (
25+
<section className="glass-card panel">
26+
<div className="panel-header">
27+
<h2 className="panel-title">{title}</h2>
28+
{onViewAll && (
29+
<button className="glass-button view-all-button" onClick={onViewAll}>
30+
View all
31+
</button>
32+
)}
33+
</div>
34+
{items.length === 0 ? (
35+
<p className="panel-empty">{emptyMessage}</p>
36+
) : (
37+
<ol className="rank-list">
38+
{items.map((item, index) => (
39+
<li className="rank-item" key={item.key}>
40+
{showRank && (
41+
<span className={`rank-badge rank-${index + 1}`}>
42+
{index + 1}
43+
</span>
44+
)}
45+
<div className="rank-info">
46+
<div className="rank-label" title={item.label}>
47+
{item.label}
48+
</div>
49+
{item.sublabel && (
50+
<div className="rank-sublabel" title={item.sublabel}>
51+
{item.sublabel}
52+
</div>
53+
)}
54+
</div>
55+
<span className="rank-value" title={item.valueTitle}>
56+
{item.value}
57+
</span>
58+
</li>
59+
))}
60+
</ol>
61+
)}
62+
</section>
63+
);
64+
}

src/UI/App/src/index.css

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ body {
109109
.stat-song-title {
110110
display: -webkit-box;
111111
-webkit-line-clamp: 2;
112+
line-clamp: 2;
112113
-webkit-box-orient: vertical;
113114
overflow: hidden;
114115
overflow-wrap: anywhere;
@@ -272,7 +273,7 @@ body {
272273
width: 100%;
273274
}
274275

275-
/* ===== Toolbar (search / filters / export) ===== */
276+
/* ===== Toolbar (search / filters) ===== */
276277

277278
.toolbar {
278279
display: flex;
@@ -290,10 +291,120 @@ body {
290291
padding: 0.6rem 1.1rem;
291292
}
292293

293-
.export-button {
294-
padding: 0.6rem 1.25rem;
294+
/* ===== Overview page ===== */
295+
296+
.overview-grid {
297+
display: grid;
298+
grid-template-columns: repeat(2, minmax(0, 1fr));
299+
gap: 1.5rem;
300+
}
301+
302+
.panel {
303+
padding: 1.5rem;
304+
}
305+
306+
.panel-header {
307+
display: flex;
308+
align-items: center;
309+
justify-content: space-between;
310+
gap: 0.75rem;
311+
margin-bottom: 1rem;
312+
}
313+
314+
.panel-title {
315+
font-size: 1.05rem;
316+
font-weight: 600;
317+
color: rgba(255, 255, 255, 0.9);
318+
margin: 0;
319+
}
320+
321+
.view-all-button {
322+
padding: 0.35rem 0.9rem;
323+
font-size: 0.75rem;
324+
white-space: nowrap;
325+
}
326+
327+
.panel-empty {
328+
color: rgba(255, 255, 255, 0.55);
329+
font-size: 0.9rem;
330+
padding: 0.5rem 0;
331+
margin: 0;
332+
}
333+
334+
.rank-list {
335+
list-style: none;
336+
display: flex;
337+
flex-direction: column;
338+
gap: 0.5rem;
339+
margin: 0;
340+
padding: 0;
341+
}
342+
343+
.rank-item {
344+
display: flex;
345+
align-items: center;
346+
gap: 0.75rem;
347+
padding: 0.55rem 0.7rem;
348+
border-radius: 0.6rem;
349+
background: rgba(255, 255, 255, 0.05);
350+
}
351+
352+
.rank-badge {
353+
width: 1.7rem;
354+
height: 1.7rem;
355+
border-radius: 50%;
356+
display: flex;
357+
align-items: center;
358+
justify-content: center;
359+
font-size: 0.8rem;
360+
font-weight: 700;
361+
flex-shrink: 0;
362+
background: rgba(255, 255, 255, 0.12);
363+
color: rgba(255, 255, 255, 0.8);
364+
}
365+
366+
.rank-badge.rank-1 {
367+
background: #f5c542;
368+
color: #3b2f00;
369+
}
370+
371+
.rank-badge.rank-2 {
372+
background: #c8d1dc;
373+
color: #2b3542;
374+
}
375+
376+
.rank-badge.rank-3 {
377+
background: #d2996b;
378+
color: #3d2412;
379+
}
380+
381+
.rank-info {
382+
flex: 1;
383+
min-width: 0;
384+
}
385+
386+
.rank-label {
387+
font-size: 0.9rem;
388+
color: rgba(255, 255, 255, 0.9);
389+
white-space: nowrap;
390+
overflow: hidden;
391+
text-overflow: ellipsis;
392+
}
393+
394+
.rank-sublabel {
395+
font-size: 0.75rem;
396+
color: rgba(255, 255, 255, 0.55);
397+
white-space: nowrap;
398+
overflow: hidden;
399+
text-overflow: ellipsis;
400+
}
401+
402+
.rank-value {
295403
font-size: 0.85rem;
296-
margin-left: auto;
404+
font-weight: 600;
405+
color: #86b6ef;
406+
white-space: nowrap;
407+
flex-shrink: 0;
297408
}
298409

299410
/* ===== Pagination ===== */
@@ -1010,15 +1121,20 @@ body {
10101121
flex-basis: 100%;
10111122
}
10121123

1013-
.export-button {
1014-
margin-left: 0;
1015-
}
1016-
10171124
.content-card {
10181125
padding: 1rem;
10191126
min-height: 300px;
10201127
}
10211128

1129+
.overview-grid {
1130+
grid-template-columns: minmax(0, 1fr);
1131+
gap: 1rem;
1132+
}
1133+
1134+
.panel {
1135+
padding: 1rem;
1136+
}
1137+
10221138
.chart-container {
10231139
height: 320px;
10241140
}
@@ -1108,4 +1224,27 @@ body {
11081224
.stat-label {
11091225
font-size: 0.8rem;
11101226
}
1227+
1228+
.rank-item {
1229+
gap: 0.55rem;
1230+
padding: 0.5rem 0.55rem;
1231+
}
1232+
1233+
.rank-badge {
1234+
width: 1.5rem;
1235+
height: 1.5rem;
1236+
font-size: 0.75rem;
1237+
}
1238+
1239+
.rank-label {
1240+
font-size: 0.85rem;
1241+
}
1242+
1243+
.rank-value {
1244+
font-size: 0.78rem;
1245+
}
1246+
1247+
.panel-header {
1248+
margin-bottom: 0.75rem;
1249+
}
11111250
}

src/UI/App/src/layouts/DashboardLayout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export function DashboardLayout() {
2929
<span>Rytho Dashboard</span>
3030
</div>
3131
<nav className="nav">
32+
<button
33+
className={`nav-button glass-button ${isActive('/')}`}
34+
onClick={() => handleNavigation('/')}
35+
>
36+
Overview
37+
</button>
3238
<button
3339
className={`nav-button glass-button ${isActive('/users')}`}
3440
onClick={() => handleNavigation('/users')}

0 commit comments

Comments
 (0)