Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\" })"
]
}
}
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions deployment/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/UI/App/.env.production
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VITE_API_BASE_URL=https://rytho.standleypg.com/api
VITE_API_BASE_URL=/api
3 changes: 2 additions & 1 deletion src/UI/App/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
dist
node_modules
obj
package-lock.json
*.min.css
*.min.css
64 changes: 64 additions & 0 deletions src/UI/App/src/components/RankList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="glass-card panel">
<div className="panel-header">
<h2 className="panel-title">{title}</h2>
{onViewAll && (
<button className="glass-button view-all-button" onClick={onViewAll}>
View all
</button>
)}
</div>
{items.length === 0 ? (
<p className="panel-empty">{emptyMessage}</p>
) : (
<ol className="rank-list">
{items.map((item, index) => (
<li className="rank-item" key={item.key}>
{showRank && (
<span className={`rank-badge rank-${index + 1}`}>
{index + 1}
</span>
)}
<div className="rank-info">
<div className="rank-label" title={item.label}>
{item.label}
</div>
{item.sublabel && (
<div className="rank-sublabel" title={item.sublabel}>
{item.sublabel}
</div>
)}
</div>
<span className="rank-value" title={item.valueTitle}>
{item.value}
</span>
</li>
))}
</ol>
)}
</section>
);
}
155 changes: 147 additions & 8 deletions src/UI/App/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -272,7 +273,7 @@ body {
width: 100%;
}

/* ===== Toolbar (search / filters / export) ===== */
/* ===== Toolbar (search / filters) ===== */

.toolbar {
display: flex;
Expand All @@ -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 ===== */
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
6 changes: 6 additions & 0 deletions src/UI/App/src/layouts/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export function DashboardLayout() {
<span>Rytho Dashboard</span>
</div>
<nav className="nav">
<button
className={`nav-button glass-button ${isActive('/')}`}
onClick={() => handleNavigation('/')}
>
Overview
</button>
<button
className={`nav-button glass-button ${isActive('/users')}`}
onClick={() => handleNavigation('/users')}
Expand Down
Loading
Loading