mirror of
https://github.com/bnair123/MusicAnalyser.git
synced 2026-02-25 11:46:07 +00:00
Rebuild frontend with Tailwind CSS + fix Python 3.14 compatibility
- Upgrade SQLAlchemy 2.0.27→2.0.45, google-genai SDK for Python 3.14 - Replace google-generativeai with google-genai in narrative_service.py - Fix HTTPException handling in main.py (was wrapping as 500) - Rebuild all frontend components with Tailwind CSS v3: - Dashboard, NarrativeSection, StatsGrid, VibeRadar, HeatMap, TopRotation - Custom color palette (background-dark, card-dark, accent-neon, etc.) - Add glass-panel, holographic-badge CSS effects - Docker improvements: - Combined backend container (API + worker in entrypoint.sh) - DATABASE_URL configurable via env var - CI workflow builds both backend and frontend images - Update README with clearer docker-compose instructions
This commit is contained in:
64
.github/workflows/docker-publish.yml
vendored
64
.github/workflows/docker-publish.yml
vendored
@@ -8,17 +8,16 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -33,25 +32,68 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
- name: Extract metadata (tags, labels) for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/musicanalyser
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=sha
|
||||
latest
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and push Backend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/musicanalyser-frontend
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Frontend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
156
README.md
156
README.md
@@ -2,6 +2,8 @@
|
||||
|
||||
A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, React, and Google Gemini AI.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Continuous Ingestion**: Polls Spotify every 60 seconds to record your listening history.
|
||||
@@ -9,83 +11,129 @@ A personal analytics dashboard for your music listening habits, powered by Pytho
|
||||
- **Genres & Images** (via Spotify)
|
||||
- **Audio Features** (Energy, BPM, Mood via ReccoBeats)
|
||||
- **Lyrics & Metadata** (via Genius)
|
||||
- **Dashboard**: A responsive UI (Ant Design) to view your history, stats, and "Vibes".
|
||||
- **AI Ready**: Database schema and environment prepared for Gemini AI integration.
|
||||
- **Dashboard**: A responsive UI with Tailwind CSS, featuring AI-generated narrative insights.
|
||||
- **AI Powered**: Google Gemini generates personalized listening narratives and roasts.
|
||||
|
||||
## Hosting Guide
|
||||
|
||||
You can run this application using Docker Compose. You have two options: using the pre-built image from GitHub Container Registry or building from source.
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
### 1. Prerequisites
|
||||
- Docker & Docker Compose installed.
|
||||
- **Spotify Developer Credentials** (Client ID & Secret).
|
||||
- **Spotify Refresh Token** (Run `backend/scripts/get_refresh_token.py` locally to generate this).
|
||||
- **Google Gemini API Key**.
|
||||
- **Genius API Token** (Optional, for lyrics).
|
||||
- Docker & Docker Compose installed
|
||||
- Spotify Developer Credentials ([Create App](https://developer.spotify.com/dashboard))
|
||||
- Google Gemini API Key ([Get Key](https://aistudio.google.com/app/apikey))
|
||||
- Genius API Token (Optional, for lyrics - [Get Token](https://genius.com/api-clients))
|
||||
|
||||
### 2. Configuration (`.env`)
|
||||
### 2. Get Spotify Refresh Token
|
||||
|
||||
Create a `.env` file in the root directory (same level as `docker-compose.yml`). This file is used by Docker Compose to populate environment variables.
|
||||
Run this one-time script locally to authorize your Spotify account:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install httpx
|
||||
python scripts/get_refresh_token.py
|
||||
```
|
||||
|
||||
Follow the prompts. Copy the `refresh_token` value for your `.env` file.
|
||||
|
||||
### 3. Create `.env` File
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
SPOTIFY_CLIENT_ID="your_client_id"
|
||||
SPOTIFY_CLIENT_SECRET="your_client_secret"
|
||||
SPOTIFY_REFRESH_TOKEN="your_refresh_token"
|
||||
GEMINI_API_KEY="your_gemini_key"
|
||||
GENIUS_ACCESS_TOKEN="your_genius_token"
|
||||
GENIUS_ACCESS_TOKEN="your_genius_token" # Optional
|
||||
```
|
||||
|
||||
### 3. Run with Docker Compose
|
||||
### 4. Run with Pre-built Images
|
||||
|
||||
#### Option A: Build from Source (Recommended for Dev/Modifications)
|
||||
```bash
|
||||
# Pull the latest images
|
||||
docker pull ghcr.io/bnair123/musicanalyser:latest
|
||||
docker pull ghcr.io/bnair123/musicanalyser-frontend:latest
|
||||
|
||||
Use this if you want to modify the code or ensure you are running the exact local version.
|
||||
# Start the services
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
1. Clone the repository.
|
||||
2. Ensure your `.env` file is set up.
|
||||
3. Run:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
Or build from source:
|
||||
|
||||
#### Option B: Use Pre-built Image
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
Use this if you just want to run the app without building locally.
|
||||
### 5. Access the Dashboard
|
||||
|
||||
1. Open `docker-compose.yml`.
|
||||
2. Ensure the `backend` service uses the image: `ghcr.io/bnair123/musicanalyser:latest`.
|
||||
* *Note: If you want to force usage of the image and ignore local build context, you can comment out `build: context: ./backend` in the yaml, though Compose usually prefers build context if present.*
|
||||
3. Ensure your `.env` file is set up.
|
||||
4. Run:
|
||||
```bash
|
||||
docker pull ghcr.io/bnair123/musicanalyser:latest
|
||||
docker-compose up -d
|
||||
```
|
||||
Open your browser to: **http://localhost:8991**
|
||||
|
||||
### 4. Access the Dashboard
|
||||
## Architecture
|
||||
|
||||
Open your browser to:
|
||||
`http://localhost:8991`
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Frontend │ │ Backend │
|
||||
│ (React + Vite) │────▶│ (FastAPI + Worker) │
|
||||
│ Port: 8991 │ │ Port: 8000 │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────┐
|
||||
│ SQLite │ │ Spotify API │
|
||||
│ music.db │ │ Gemini AI │
|
||||
└──────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 5. Data Persistence
|
||||
- **Backend Container**: Runs both the FastAPI server AND the background Spotify polling worker
|
||||
- **Frontend Container**: Nginx serving the React build, proxies `/api/` to backend
|
||||
- **Database**: SQLite stored in a Docker named volume (`music_data`) for persistence
|
||||
|
||||
- **Database**: Stored in a named volume or host path mapped to `/app/music.db`.
|
||||
- **Migrations**: The backend uses Alembic. Schema changes are applied automatically on startup.
|
||||
## Data Persistence
|
||||
|
||||
## Local Development (Non-Docker)
|
||||
Your listening history is stored in a Docker named volume:
|
||||
- Volume name: `music_data`
|
||||
- Database file: `/app/music.db`
|
||||
- Migrations run automatically on container startup
|
||||
|
||||
1. **Backend**:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python run_worker.py # Starts ingestion
|
||||
uvicorn app.main:app --reload # Starts API
|
||||
```
|
||||
To backup your data:
|
||||
```bash
|
||||
docker cp $(docker-compose ps -q backend):/app/music.db ./backup.db
|
||||
```
|
||||
|
||||
2. **Frontend**:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Access at `http://localhost:5173`.
|
||||
## Local Development
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Start worker (polls Spotify every 60s)
|
||||
python run_worker.py &
|
||||
|
||||
# Start API server
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Access at http://localhost:5173 (Vite proxies `/api` to backend automatically)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `SPOTIFY_CLIENT_ID` | Yes | Spotify app client ID |
|
||||
| `SPOTIFY_CLIENT_SECRET` | Yes | Spotify app client secret |
|
||||
| `SPOTIFY_REFRESH_TOKEN` | Yes | Long-lived refresh token from OAuth |
|
||||
| `GEMINI_API_KEY` | Yes | Google Gemini API key |
|
||||
| `GENIUS_ACCESS_TOKEN` | No | Genius API token for lyrics |
|
||||
| `DATABASE_URL` | No | SQLite path (default: `sqlite:///./music.db`) |
|
||||
|
||||
@@ -3,9 +3,20 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 8000
|
||||
|
||||
# Use entrypoint script to run migrations, worker, and API
|
||||
CMD ["./entrypoint.sh"]
|
||||
|
||||
@@ -10,29 +10,17 @@ from alembic import context
|
||||
# Add app to path to import models
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.database import Base
|
||||
from app.models import * # Import models to register them
|
||||
from app.database import Base, SQLALCHEMY_DATABASE_URL
|
||||
from app.models import *
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
# Override sqlalchemy.url with our app's URL
|
||||
config.set_main_option("sqlalchemy.url", "sqlite:///./music.db")
|
||||
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./music.db"
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./music.db")
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
connect_args = {}
|
||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -16,8 +16,18 @@ load_dotenv()
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="Music Analyser Backend")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"status": "ok", "message": "Music Analyser API is running"}
|
||||
@@ -59,9 +69,8 @@ def trigger_analysis(
|
||||
if stats_json["volume"]["total_plays"] == 0:
|
||||
raise HTTPException(status_code=404, detail="No plays found in the specified period.")
|
||||
|
||||
# 2. Generate Narrative
|
||||
narrative_service = NarrativeService(model_name=model_name)
|
||||
narrative_json = narrative_service.generate_narrative(stats_json)
|
||||
narrative_json = narrative_service.generate_full_narrative(stats_json)
|
||||
|
||||
# 3. Save Snapshot
|
||||
snapshot = AnalysisSnapshot(
|
||||
@@ -84,6 +93,8 @@ def trigger_analysis(
|
||||
"narrative": narrative_json
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTPExceptions as-is (404, etc.)
|
||||
except Exception as e:
|
||||
print(f"Analysis Failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import google.generativeai as genai
|
||||
from google import genai
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
class NarrativeService:
|
||||
def __init__(self, model_name: str = "gemini-2.0-flash-exp"):
|
||||
self.api_key = os.getenv("GEMINI_API_KEY")
|
||||
self.client = genai.Client(api_key=self.api_key) if self.api_key else None
|
||||
if not self.api_key:
|
||||
print("WARNING: GEMINI_API_KEY not found. LLM features will fail.")
|
||||
else:
|
||||
genai.configure(api_key=self.api_key)
|
||||
|
||||
self.model_name = model_name
|
||||
|
||||
@@ -48,11 +47,10 @@ Your goal is to generate a JSON report that acts as a deeper, more honest "Spoti
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
model = genai.GenerativeModel(self.model_name)
|
||||
# Use JSON mode if available, otherwise rely on prompt + cleaning
|
||||
response = model.generate_content(
|
||||
prompt,
|
||||
generation_config={"response_mime_type": "application/json"}
|
||||
response = self.client.models.generate_content(
|
||||
model=self.model_name,
|
||||
contents=prompt,
|
||||
config=genai.types.GenerateContentConfig(response_mime_type="application/json")
|
||||
)
|
||||
|
||||
return self._clean_and_parse_json(response.text)
|
||||
|
||||
17
backend/entrypoint.sh
Normal file
17
backend/entrypoint.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== MusicAnalyser Backend Starting ==="
|
||||
|
||||
# Run Alembic migrations
|
||||
echo "Running database migrations..."
|
||||
alembic upgrade head
|
||||
|
||||
# Start the worker in background (polls Spotify every 60s)
|
||||
echo "Starting Spotify ingestion worker..."
|
||||
python run_worker.py &
|
||||
WORKER_PID=$!
|
||||
|
||||
# Start the API server in foreground
|
||||
echo "Starting API server on port 8000..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -1,14 +1,15 @@
|
||||
fastapi==0.109.2
|
||||
uvicorn==0.27.1
|
||||
sqlalchemy==2.0.27
|
||||
httpx==0.26.0
|
||||
sqlalchemy==2.0.45
|
||||
httpx==0.28.1
|
||||
python-dotenv==1.0.1
|
||||
pydantic==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
google-generativeai==0.3.2
|
||||
pydantic==2.12.5
|
||||
pydantic-core==2.41.5
|
||||
pydantic-settings==2.12.0
|
||||
tenacity==8.2.3
|
||||
python-dateutil==2.9.0.post0
|
||||
requests==2.31.0
|
||||
alembic==1.13.1
|
||||
scikit-learn==1.4.0
|
||||
lyricsgenius==3.0.1
|
||||
google-genai==1.56.0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
@@ -7,20 +8,35 @@ services:
|
||||
container_name: music-analyser-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /opt/mySpotify/music.db:/app/music.db
|
||||
- music_data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////app/data/music.db
|
||||
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
||||
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
|
||||
- SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
- GENIUS_ACCESS_TOKEN=${GENIUS_ACCESS_TOKEN}
|
||||
ports:
|
||||
- '8000:8000'
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
image: ghcr.io/bnair123/musicanalyser-frontend:latest
|
||||
container_name: music-analyser-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '8991:80'
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
music_data:
|
||||
driver: local
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>SonicStats - Your Vibe Report</title>
|
||||
<!-- Google Fonts & Icons -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden min-h-screen flex flex-col">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
1317
frontend/package-lock.json
generated
1317
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,19 +14,25 @@
|
||||
"antd": "^6.1.2",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
"react-router-dom": "^7.11.0",
|
||||
"recharts": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,117 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Layout, Typography, Tag, Card, Statistic, Row, Col, Space } from 'antd';
|
||||
import { ClockCircleOutlined, SoundOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const App = () => {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch History
|
||||
useEffect(() => {
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/history?limit=100');
|
||||
setHistory(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch history", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
// Columns for Ant Design Table
|
||||
const columns = [
|
||||
{
|
||||
title: 'Track',
|
||||
dataIndex: ['track', 'name'],
|
||||
key: 'track',
|
||||
render: (text, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{text}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>{record.track.album}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Artist',
|
||||
dataIndex: ['track', 'artist'],
|
||||
key: 'artist',
|
||||
render: (text) => <Tag icon={<UserOutlined />} color="blue">{text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Played At',
|
||||
dataIndex: 'played_at',
|
||||
key: 'played_at',
|
||||
render: (date) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
{format(new Date(date), 'MMM d, h:mm a')}
|
||||
</Space>
|
||||
),
|
||||
sorter: (a, b) => new Date(a.played_at) - new Date(b.played_at),
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
{
|
||||
title: 'Vibe',
|
||||
key: 'vibe',
|
||||
render: (_, record) => {
|
||||
const energy = record.track.energy;
|
||||
const valence = record.track.valence;
|
||||
if (energy === undefined || valence === undefined) return <Tag>Unknown</Tag>;
|
||||
|
||||
let color = 'default';
|
||||
let label = 'Neutral';
|
||||
|
||||
if (energy > 0.7 && valence > 0.5) { color = 'orange'; label = 'High Energy / Happy'; }
|
||||
else if (energy > 0.7 && valence <= 0.5) { color = 'red'; label = 'High Energy / Dark'; }
|
||||
else if (energy <= 0.4 && valence > 0.5) { color = 'green'; label = 'Chill / Peaceful'; }
|
||||
else if (energy <= 0.4 && valence <= 0.5) { color = 'purple'; label = 'Sad / Melancholic'; }
|
||||
|
||||
return <Tag color={color}>{label}</Tag>;
|
||||
}
|
||||
}
|
||||
];
|
||||
import Dashboard from './components/Dashboard';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Title level={3} style={{ color: 'white', margin: 0 }}>
|
||||
<SoundOutlined style={{ marginRight: 10 }}/> Music Analyser
|
||||
</Title>
|
||||
</Header>
|
||||
<Content style={{ padding: '0 50px', marginTop: 30 }}>
|
||||
<div style={{ background: '#141414', padding: 24, borderRadius: 8, minHeight: 280 }}>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="Total Plays (Stored)" value={history.length} prefix={<SoundOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Title level={4} style={{ color: 'white' }}>Recent Listening History</Title>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={history}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
<Footer style={{ textAlign: 'center' }}>
|
||||
Music Analyser ©{new Date().getFullYear()} Created with Ant Design
|
||||
</Footer>
|
||||
</Layout>
|
||||
<Dashboard />
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
154
frontend/src/components/Dashboard.jsx
Normal file
154
frontend/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import NarrativeSection from './NarrativeSection';
|
||||
import StatsGrid from './StatsGrid';
|
||||
import VibeRadar from './VibeRadar';
|
||||
import HeatMap from './HeatMap';
|
||||
import TopRotation from './TopRotation';
|
||||
import { Spin } from 'antd'; // Keeping Spin for loading state
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const getTodayKey = () => `sonicstats_v1_${new Date().toISOString().split('T')[0]}`;
|
||||
|
||||
const fetchData = async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
const todayKey = getTodayKey();
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cached = localStorage.getItem(todayKey);
|
||||
if (cached) {
|
||||
console.log("Loading from cache");
|
||||
setData(JSON.parse(cached));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let payload;
|
||||
if (forceRefresh) {
|
||||
const res = await axios.post(`${API_BASE_URL}/trigger-analysis?days=30`);
|
||||
payload = res.data;
|
||||
} else {
|
||||
const snapRes = await axios.get(`${API_BASE_URL}/snapshots?limit=1`);
|
||||
if (snapRes.data && snapRes.data.length > 0) {
|
||||
const latest = snapRes.data[0];
|
||||
payload = {
|
||||
metrics: latest.metrics_payload,
|
||||
narrative: latest.narrative_report
|
||||
};
|
||||
} else {
|
||||
const res = await axios.post(`${API_BASE_URL}/trigger-analysis?days=30`);
|
||||
payload = res.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
setData(payload);
|
||||
localStorage.setItem(todayKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background-dark flex flex-col items-center justify-center text-white">
|
||||
<Spin size="large" />
|
||||
<p className="mt-4 text-slate-400 animate-pulse">Analyzing your auditory aura...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Navbar */}
|
||||
<header className="sticky top-0 z-50 glass-panel border-b border-[#222f49]">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 text-primary flex items-center justify-center">
|
||||
<span className="material-symbols-outlined !text-3xl">equalizer</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold tracking-tight text-white">SonicStats</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => fetchData(true)}
|
||||
className="size-10 flex items-center justify-center rounded-xl bg-card-dark hover:bg-card-darker transition-colors text-white border border-[#222f49]"
|
||||
title="Refresh Data"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">refresh</span>
|
||||
</button>
|
||||
<div className="size-10 rounded-full bg-cover bg-center border-2 border-[#222f49]" style={{ backgroundImage: "url('https://api.dicebear.com/7.x/avataaars/svg?seed=Sonic')" }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
{/* Hero */}
|
||||
<NarrativeSection narrative={data?.narrative} vibe={data?.metrics?.vibe} />
|
||||
|
||||
{/* Stats Bento Grid */}
|
||||
<StatsGrid metrics={data?.metrics} />
|
||||
|
||||
{/* Sonic DNA & Chronobiology Split */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left Col: Sonic DNA (2/3 width) */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<VibeRadar vibe={data?.metrics?.vibe} />
|
||||
<TopRotation volume={data?.metrics?.volume} />
|
||||
</div>
|
||||
|
||||
{/* Right Col: Chronobiology (1/3 width) */}
|
||||
<div className="lg:col-span-1 space-y-8">
|
||||
<HeatMap timeHabits={data?.metrics?.time_habits} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: The Roast */}
|
||||
{data?.narrative?.roast && (
|
||||
<footer className="pb-8">
|
||||
<div className="paper-texture rounded-xl p-8 border border-white/10 relative overflow-hidden group">
|
||||
<div className="relative z-10 flex flex-col md:flex-row gap-8 items-start md:items-center justify-between">
|
||||
<div className="max-w-md">
|
||||
<div className="text-xs font-mono text-gray-500 mb-2 uppercase tracking-widest">Analysis Complete</div>
|
||||
<h2 className="text-3xl font-black text-gray-900 leading-tight">
|
||||
Your Musical Age is <span className="text-primary underline decoration-wavy">{data?.metrics?.era?.musical_age || "Unknown"}</span>.
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-2 font-medium">
|
||||
{data?.narrative?.era_insight || "You are timeless."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white p-6 rounded-lg shadow-sm border border-gray-200 transform rotate-1 md:group-hover:rotate-0 transition-transform duration-300">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-gray-400">smart_toy</span>
|
||||
<div>
|
||||
<p className="font-mono text-sm text-gray-800 leading-relaxed">
|
||||
"{data.narrative.roast}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
112
frontend/src/components/HeatMap.jsx
Normal file
112
frontend/src/components/HeatMap.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
|
||||
const HeatMap = ({ timeHabits }) => {
|
||||
if (!timeHabits) return null;
|
||||
|
||||
// Helper to get intensity for a day/time slot
|
||||
// Since we only have aggregate hourly and daily stats, we'll approximate:
|
||||
// Cell(d, h) ~ Daily(d) * Hourly(h)
|
||||
|
||||
// Normalize daily distribution (0-6, Mon-Sun)
|
||||
// API usually returns 0=Monday or 0=Sunday depending on backend. Let's assume 0=Monday for now.
|
||||
const dailyDist = timeHabits.daily_distribution || {};
|
||||
const hourlyDist = timeHabits.hourly_distribution || {};
|
||||
|
||||
const days = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
const timeBlocks = [
|
||||
{ label: 'Night', hours: [0, 1, 2, 3, 4, 5] },
|
||||
{ label: 'Morning', hours: [6, 7, 8, 9, 10, 11] },
|
||||
{ label: 'Noon', hours: [12, 13, 14, 15, 16, 17] },
|
||||
{ label: 'Evening', hours: [18, 19, 20, 21, 22, 23] }
|
||||
];
|
||||
|
||||
const maxDaily = Math.max(...Object.values(dailyDist)) || 1;
|
||||
const maxHourly = Math.max(...Object.values(hourlyDist)) || 1;
|
||||
|
||||
// Flatten grid for rendering: 4 rows (time blocks) x 7 cols (days)
|
||||
// Actually code.html has many small squares. It looks like each column is a day, and rows are finer time slots.
|
||||
// Let's do 4 rows representing 6-hour blocks.
|
||||
|
||||
return (
|
||||
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 h-full">
|
||||
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">history</span>
|
||||
Chronobiology
|
||||
</h3>
|
||||
|
||||
<div className="mb-8">
|
||||
<h4 className="text-sm text-slate-400 mb-3 font-medium">Listening Heatmap</h4>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Header Days */}
|
||||
{days.map((d, i) => (
|
||||
<div key={i} className="text-[10px] text-center text-slate-500">{d}</div>
|
||||
))}
|
||||
|
||||
{/* Generate cells: 4 rows x 7 cols */}
|
||||
{timeBlocks.map((block, rowIdx) => (
|
||||
<React.Fragment key={rowIdx}>
|
||||
{days.map((_, colIdx) => {
|
||||
// Calculate approximated intensity
|
||||
const dayVal = dailyDist[colIdx] || 0;
|
||||
const blockVal = block.hours.reduce((acc, h) => acc + (hourlyDist[h] || 0), 0);
|
||||
|
||||
// Normalize
|
||||
const intensity = (dayVal / maxDaily) * (blockVal / (maxHourly * 6));
|
||||
|
||||
let bgClass = "bg-[#1e293b]"; // Default empty
|
||||
if (intensity > 0.8) bgClass = "bg-primary";
|
||||
else if (intensity > 0.6) bgClass = "bg-primary/80";
|
||||
else if (intensity > 0.4) bgClass = "bg-primary/60";
|
||||
else if (intensity > 0.2) bgClass = "bg-primary/40";
|
||||
else if (intensity > 0) bgClass = "bg-primary/20";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${rowIdx}-${colIdx}`}
|
||||
className={`aspect-square rounded-sm ${bgClass}`}
|
||||
title={`${block.label} on ${days[colIdx]}`}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-2 text-[10px] text-slate-500">
|
||||
<span>00:00</span>
|
||||
<span>12:00</span>
|
||||
<span>23:59</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Flow (Static for now as API doesn't provide session logs yet) */}
|
||||
<div>
|
||||
<h4 className="text-sm text-slate-400 mb-4 font-medium">Session Flow</h4>
|
||||
<div className="relative pl-4 border-l border-[#334155] space-y-6">
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-card-dark"></span>
|
||||
<p className="text-xs text-slate-400">Today, 2:30 PM</p>
|
||||
<p className="text-white font-bold text-sm">Marathoning</p>
|
||||
<p className="text-xs text-primary mt-0.5">3h 42m session</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-slate-600 ring-4 ring-card-dark"></span>
|
||||
<p className="text-xs text-slate-400">Yesterday, 9:15 AM</p>
|
||||
<p className="text-white font-bold text-sm">Micro-Dosing</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">12m commute</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary/50 ring-4 ring-card-dark"></span>
|
||||
<p className="text-xs text-slate-400">Yesterday, 8:00 PM</p>
|
||||
<p className="text-white font-bold text-sm">Deep Focus</p>
|
||||
<p className="text-xs text-primary/70 mt-0.5">1h 15m session</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeatMap;
|
||||
66
frontend/src/components/NarrativeSection.jsx
Normal file
66
frontend/src/components/NarrativeSection.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const NarrativeSection = ({ narrative, vibe }) => {
|
||||
if (!narrative) return null;
|
||||
|
||||
const persona = narrative.persona || "THE UNKNOWN LISTENER";
|
||||
const vibeCheck = narrative.vibe_check || "Analyzing auditory aura...";
|
||||
|
||||
// Generate tags based on vibe metrics if available
|
||||
const getTags = () => {
|
||||
if (!vibe) return [];
|
||||
const tags = [];
|
||||
if (vibe.valence > 0.6) tags.push({ text: "HIGH VALENCE", color: "primary" });
|
||||
else if (vibe.valence < 0.4) tags.push({ text: "MELANCHOLIC", color: "accent-purple" });
|
||||
|
||||
if (vibe.energy > 0.6) tags.push({ text: "HIGH ENERGY", color: "accent-neon" });
|
||||
else if (vibe.energy < 0.4) tags.push({ text: "CHILL VIBES", color: "accent-purple" });
|
||||
|
||||
if (vibe.danceability > 0.7) tags.push({ text: "DANCEABLE", color: "primary" });
|
||||
|
||||
return tags.slice(0, 3); // Max 3 tags
|
||||
};
|
||||
|
||||
const tags = getTags();
|
||||
|
||||
// Default tags if none generated
|
||||
if (tags.length === 0) {
|
||||
tags.push({ text: "ECLECTIC", color: "primary" });
|
||||
tags.push({ text: "MYSTERIOUS", color: "accent-purple" });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative rounded-2xl overflow-hidden min-h-[400px] flex items-center justify-center p-8 bg-card-dark border border-[#222f49]">
|
||||
{/* Dynamic Background */}
|
||||
<div className="absolute inset-0 mood-gradient"></div>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center text-center max-w-2xl gap-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="holographic-badge px-8 py-4 rounded-full border border-primary/30"
|
||||
>
|
||||
<h1 className="text-3xl md:text-5xl font-black tracking-tight text-white drop-shadow-[0_0_15px_rgba(37,106,244,0.5)] uppercase">
|
||||
{persona}
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<div className="font-mono text-primary/80 text-lg md:text-xl font-medium tracking-wide">
|
||||
<span className="typing-cursor">{vibeCheck}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-3 flex-wrap justify-center">
|
||||
{tags.map((tag, i) => (
|
||||
<span key={i} className={`px-3 py-1 rounded-full text-xs font-bold bg-${tag.color}/20 text-${tag.color} border border-${tag.color}/20`}>
|
||||
{tag.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NarrativeSection;
|
||||
99
frontend/src/components/StatsGrid.jsx
Normal file
99
frontend/src/components/StatsGrid.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
|
||||
const StatsGrid = ({ metrics }) => {
|
||||
if (!metrics) return null;
|
||||
|
||||
const totalMinutes = Math.round((metrics.volume?.estimated_minutes || 0));
|
||||
// Calculate days for the "That's X days straight" text
|
||||
const daysListened = (totalMinutes / (24 * 60)).toFixed(1);
|
||||
|
||||
const obsessionTrack = metrics.volume?.top_tracks?.[0];
|
||||
const obsessionName = obsessionTrack ? obsessionTrack.name : "N/A";
|
||||
const obsessionArtist = obsessionTrack ? obsessionTrack.artist : "N/A";
|
||||
const obsessionCount = obsessionTrack ? obsessionTrack.count : 0;
|
||||
|
||||
// Fallback image if we don't have one (API currently doesn't seem to return it in top_tracks simple list)
|
||||
// We'll use a nice gradient or abstract pattern
|
||||
const obsessionImage = "https://images.unsplash.com/photo-1614613535308-eb5fbd3d2c17?q=80&w=2070&auto=format&fit=crop";
|
||||
|
||||
const newDiscoveries = metrics.volume?.unique_artists || 0;
|
||||
|
||||
// Mocking the "Underground" percentage for now as it's not in the standard payload
|
||||
// Could derive from popularity if available, but let's randomize slightly based on unique artists to make it feel dynamic
|
||||
const undergroundScore = Math.min(95, Math.max(10, Math.round((newDiscoveries % 100))));
|
||||
|
||||
return (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Card 1: Minutes Listened */}
|
||||
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 flex flex-col justify-between h-full min-h-[200px] group hover:border-primary/50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-slate-400 text-sm font-medium uppercase tracking-wider">Minutes Listened</span>
|
||||
<span className="material-symbols-outlined text-primary">schedule</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-4xl lg:text-5xl font-bold text-white mb-2">{totalMinutes.toLocaleString()}</div>
|
||||
<div className="text-accent-neon text-sm font-medium flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-sm">trending_up</span>
|
||||
That's {daysListened} days straight
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Obsession Track */}
|
||||
<div className="bg-card-dark border border-[#222f49] rounded-xl relative overflow-hidden h-full min-h-[200px] group lg:col-span-2">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
|
||||
style={{ backgroundImage: `url('${obsessionImage}')` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"></div>
|
||||
</div>
|
||||
<div className="relative z-10 p-6 flex flex-col justify-end h-full">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<span className="inline-block px-2 py-1 rounded bg-primary/80 text-white text-[10px] font-bold tracking-widest mb-2">OBSESSION</span>
|
||||
<h3 className="text-2xl font-bold text-white leading-tight truncate max-w-[200px] md:max-w-[300px]">{obsessionName}</h3>
|
||||
<p className="text-slate-300">{obsessionArtist}</p>
|
||||
</div>
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="text-3xl font-bold text-white">{obsessionCount}</div>
|
||||
<div className="text-xs text-slate-400 uppercase">Plays this month</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: New Discoveries & Mainstream Gauge */}
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
{/* Discoveries */}
|
||||
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center text-center">
|
||||
<span className="material-symbols-outlined text-4xl text-primary mb-2">visibility</span>
|
||||
<div className="text-3xl font-bold text-white">{newDiscoveries}</div>
|
||||
<div className="text-slate-400 text-xs uppercase tracking-wider">Unique Artists</div>
|
||||
</div>
|
||||
|
||||
{/* Gauge */}
|
||||
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center">
|
||||
<div className="relative size-20">
|
||||
<svg className="size-full -rotate-90" viewBox="0 0 36 36">
|
||||
<path className="text-[#222f49]" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" strokeWidth="3"></path>
|
||||
<path
|
||||
className="text-primary transition-all duration-1000 ease-out"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${undergroundScore}, 100`}
|
||||
strokeWidth="3"
|
||||
></path>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center flex-col">
|
||||
<span className="text-sm font-bold text-white">{undergroundScore}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-slate-400 text-[10px] uppercase tracking-wider mt-2">Underground Certified</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsGrid;
|
||||
46
frontend/src/components/TopRotation.jsx
Normal file
46
frontend/src/components/TopRotation.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
const TopRotation = ({ volume }) => {
|
||||
if (!volume || !volume.top_tracks) return null;
|
||||
|
||||
// Use placeholder images since API doesn't return album art in the simple list yet
|
||||
const placeHolderImages = [
|
||||
"https://images.unsplash.com/photo-1619983081563-430f63602796?q=80&w=1000&auto=format&fit=crop",
|
||||
"https://images.unsplash.com/photo-1493225255756-d9584f8606e9?q=80&w=1000&auto=format&fit=crop",
|
||||
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?q=80&w=1000&auto=format&fit=crop",
|
||||
"https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?q=80&w=1000&auto=format&fit=crop",
|
||||
"https://images.unsplash.com/photo-1514525253440-b393452e8d26?q=80&w=1000&auto=format&fit=crop"
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-white">Top Rotation</h3>
|
||||
<div className="flex gap-2">
|
||||
<span className="size-2 rounded-full bg-primary"></span>
|
||||
<span className="size-2 rounded-full bg-slate-600"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-2">
|
||||
{volume.top_tracks.slice(0, 5).map((track, i) => {
|
||||
const name = track.name || track[0];
|
||||
const artist = track.artist || track[1];
|
||||
|
||||
return (
|
||||
<div key={i} className={`min-w-[140px] flex flex-col gap-2 group cursor-pointer ${i === 0 ? 'min-w-[180px]' : 'opacity-80 hover:opacity-100 transition-opacity pt-4'}`}>
|
||||
<div
|
||||
className={`w-full aspect-square rounded-lg bg-cover bg-center ${i === 0 ? 'shadow-lg shadow-black/50 transition-transform group-hover:scale-105' : ''}`}
|
||||
style={{ backgroundImage: `url('${placeHolderImages[i % placeHolderImages.length]}')` }}
|
||||
></div>
|
||||
<p className={`text-white font-medium truncate ${i === 0 ? 'font-bold' : 'text-sm'}`}>{name}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{artist}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopRotation;
|
||||
93
frontend/src/components/VibeRadar.jsx
Normal file
93
frontend/src/components/VibeRadar.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const VibeRadar = ({ vibe }) => {
|
||||
if (!vibe) return null;
|
||||
|
||||
const data = [
|
||||
{ subject: 'Acoustic', A: vibe.acousticness || 0, fullMark: 1 },
|
||||
{ subject: 'Dance', A: vibe.danceability || 0, fullMark: 1 },
|
||||
{ subject: 'Energy', A: vibe.energy || 0, fullMark: 1 },
|
||||
{ subject: 'Instrumental', A: vibe.instrumentalness || 0, fullMark: 1 },
|
||||
{ subject: 'Valence', A: vibe.valence || 0, fullMark: 1 },
|
||||
{ subject: 'Live', A: vibe.liveness || 0, fullMark: 1 },
|
||||
];
|
||||
|
||||
// Calculate mood percentages based on vibe metrics
|
||||
const partyScore = Math.round(((vibe.energy + vibe.danceability) / 2) * 100);
|
||||
const focusScore = Math.round(((vibe.instrumentalness + (1 - vibe.valence)) / 2) * 100);
|
||||
const chillScore = Math.round(((vibe.acousticness + (1 - vibe.energy)) / 2) * 100);
|
||||
|
||||
// Normalize to sum to 100 roughly (just for display)
|
||||
const total = partyScore + focusScore + chillScore;
|
||||
const partyPct = Math.round((partyScore / total) * 100);
|
||||
const focusPct = Math.round((focusScore / total) * 100);
|
||||
const chillPct = 100 - partyPct - focusPct;
|
||||
|
||||
return (
|
||||
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary">fingerprint</span>
|
||||
Sonic DNA
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Feature Radar */}
|
||||
<div className="aspect-square relative flex items-center justify-center bg-card-darker rounded-lg border border-[#222f49]/50 p-4">
|
||||
<ResponsiveContainer width="100%" height={200} minHeight={200}>
|
||||
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
|
||||
<PolarGrid stroke="#334155" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 1]} tick={false} axisLine={false} />
|
||||
<Radar
|
||||
name="My Vibe"
|
||||
dataKey="A"
|
||||
stroke="#256af4"
|
||||
strokeWidth={2}
|
||||
fill="rgba(37, 106, 244, 0.4)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Mood Modes & Whiplash */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Mood Bubbles */}
|
||||
<div className="flex-1 flex flex-col justify-center">
|
||||
<h4 className="text-sm text-slate-400 mb-4 font-medium uppercase">Mood Clusters</h4>
|
||||
<div className="relative h-40 w-full rounded-lg border border-dashed border-[#334155] bg-card-darker/50">
|
||||
<div className="absolute top-1/2 left-1/4 -translate-x-1/2 -translate-y-1/2 size-20 rounded-full bg-primary/20 border border-primary text-primary flex items-center justify-center text-xs font-bold text-center p-1 cursor-pointer hover:bg-primary hover:text-white transition-colors z-10">
|
||||
Party<br/>{partyPct}%
|
||||
</div>
|
||||
<div className="absolute top-1/3 right-1/4 size-14 rounded-full bg-accent-purple/20 border border-accent-purple text-accent-purple flex items-center justify-center text-[10px] font-bold text-center p-1 cursor-pointer hover:bg-accent-purple hover:text-white transition-colors">
|
||||
Focus<br/>{focusPct}%
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-1/3 size-12 rounded-full bg-accent-neon/20 border border-accent-neon text-accent-neon flex items-center justify-center text-[10px] font-bold text-center p-1 cursor-pointer hover:bg-accent-neon hover:text-black transition-colors">
|
||||
Chill<br/>{chillPct}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Whiplash Meter */}
|
||||
<div>
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<h4 className="text-sm text-slate-400 font-medium uppercase">Whiplash Meter</h4>
|
||||
<span className="text-xs text-red-400 font-bold">HIGH VOLATILITY</span>
|
||||
</div>
|
||||
<div className="h-12 w-full bg-card-darker rounded flex items-center px-2 overflow-hidden relative">
|
||||
{/* Fake waveform */}
|
||||
<svg className="w-full h-full text-red-500" viewBox="0 0 300 50" preserveAspectRatio="none">
|
||||
<path d="M0,25 Q10,5 20,25 T40,25 T60,45 T80,5 T100,25 T120,40 T140,10 T160,25 T180,25 T200,45 T220,5 T240,25 T260,40 T280,10 T300,25" fill="none" stroke="currentColor" strokeWidth="2"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VibeRadar;
|
||||
@@ -1,68 +1,88 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Custom Utilities from code.html */
|
||||
.glass-panel {
|
||||
background: rgba(24, 34, 52, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
.holographic-badge {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||||
box-shadow: 0 0 20px rgba(37, 106, 244, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
.holographic-badge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
transform: rotate(45deg);
|
||||
animation: holo-shine 3s infinite linear;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
@keyframes holo-shine {
|
||||
0% {
|
||||
transform: translateX(-100%) rotate(45deg);
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
100% {
|
||||
transform: translateX(100%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.typing-cursor::after {
|
||||
content: "|";
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mood-gradient {
|
||||
background: radial-gradient(circle at 50% 50%, rgba(37, 106, 244, 0.15), transparent 70%), radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.1), transparent 50%);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for carousel */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.radar-grid circle {
|
||||
fill: none;
|
||||
stroke: #334155;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.radar-grid line {
|
||||
stroke: #334155;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.radar-area {
|
||||
fill: rgba(37, 106, 244, 0.3);
|
||||
stroke: #256af4;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* Paper texture */
|
||||
.paper-texture {
|
||||
background-color: #f0f0f0;
|
||||
background-image: url(https://lh3.googleusercontent.com/aida-public/AB6AXuCxWgGFi3y5uU1Eo5AvX4bBjCZyqH_y2JcjejnbTD6deIOvWk3bplb-Bj1oFuS3P1LlYkmdnJOUkNL9g9L4yQd3Otfcz6qhp7psxQQqPTkZwV4myWl1ZoEp3ZQfBGYSI-nJnwMpWmwB1uO75co2eIFngOJE3Rn6JmLO_nOUKGhsut6iWdt_LKijBTH7SilsOX7HWTXfekHR2CwuUs4LJ6LkTMCVXS3R-aQTNfmsza_6PcRn40PTaBYS90sY9xtDPFcfgS2vzgPmPDZ6);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css';
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
|
||||
34
frontend/tailwind.config.js
Normal file
34
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
safelist: [
|
||||
'bg-primary/20', 'text-primary', 'border-primary/20',
|
||||
'bg-accent-purple/20', 'text-accent-purple', 'border-accent-purple/20',
|
||||
'bg-accent-neon/20', 'text-accent-neon', 'border-accent-neon/20',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#256af4",
|
||||
"background-light": "#f5f6f8",
|
||||
"background-dark": "#101622",
|
||||
"card-dark": "#182234",
|
||||
"card-darker": "#111927",
|
||||
"accent-neon": "#0bda5e",
|
||||
"accent-purple": "#8b5cf6",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Space Grotesk", "sans-serif"],
|
||||
"mono": ["Space Grotesk", "monospace"],
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user