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:
bnair123
2025-12-26 20:25:44 +04:00
parent 9b8f7355fb
commit 56b7e2a5ba
25 changed files with 2255 additions and 319 deletions

View File

@@ -8,17 +8,16 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: build-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -33,25 +32,68 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Backend
id: meta id: meta-backend
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/musicanalyser
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}}
type=sha 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 uses: docker/build-push-action@v5
with: with:
context: ./backend context: ./backend
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} 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 platforms: linux/amd64,linux/arm64
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

130
README.md
View File

@@ -2,6 +2,8 @@
A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, React, and Google Gemini AI. A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, React, and Google Gemini AI.
![Dashboard Screenshot](screen.png)
## Features ## Features
- **Continuous Ingestion**: Polls Spotify every 60 seconds to record your listening history. - **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) - **Genres & Images** (via Spotify)
- **Audio Features** (Energy, BPM, Mood via ReccoBeats) - **Audio Features** (Energy, BPM, Mood via ReccoBeats)
- **Lyrics & Metadata** (via Genius) - **Lyrics & Metadata** (via Genius)
- **Dashboard**: A responsive UI (Ant Design) to view your history, stats, and "Vibes". - **Dashboard**: A responsive UI with Tailwind CSS, featuring AI-generated narrative insights.
- **AI Ready**: Database schema and environment prepared for Gemini AI integration. - **AI Powered**: Google Gemini generates personalized listening narratives and roasts.
## Hosting Guide ## Quick Start (Docker Compose)
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.
### 1. Prerequisites ### 1. Prerequisites
- Docker & Docker Compose installed. - Docker & Docker Compose installed
- **Spotify Developer Credentials** (Client ID & Secret). - Spotify Developer Credentials ([Create App](https://developer.spotify.com/dashboard))
- **Spotify Refresh Token** (Run `backend/scripts/get_refresh_token.py` locally to generate this). - Google Gemini API Key ([Get Key](https://aistudio.google.com/app/apikey))
- **Google Gemini API Key**. - Genius API Token (Optional, for lyrics - [Get Token](https://genius.com/api-clients))
- **Genius API Token** (Optional, for lyrics).
### 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 ```bash
SPOTIFY_CLIENT_ID="your_client_id" SPOTIFY_CLIENT_ID="your_client_id"
SPOTIFY_CLIENT_SECRET="your_client_secret" SPOTIFY_CLIENT_SECRET="your_client_secret"
SPOTIFY_REFRESH_TOKEN="your_refresh_token" SPOTIFY_REFRESH_TOKEN="your_refresh_token"
GEMINI_API_KEY="your_gemini_key" 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
```
Or build from source:
1. Clone the repository.
2. Ensure your `.env` file is set up.
3. Run:
```bash ```bash
docker-compose up -d --build docker-compose up -d --build
``` ```
#### Option B: Use Pre-built Image ### 5. Access the Dashboard
Use this if you just want to run the app without building locally. Open your browser to: **http://localhost:8991**
1. Open `docker-compose.yml`. ## Architecture
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: │ Frontend │ │ Backend │
```bash │ (React + Vite) │────▶│ (FastAPI + Worker) │
docker pull ghcr.io/bnair123/musicanalyser:latest Port: 8991 │ │ Port: 8000 │
docker-compose up -d └─────────────────────┘ └─────────────────────┘
┌────────┴────────┐
▼ ▼
┌──────────┐ ┌──────────────┐
│ SQLite │ │ Spotify API │
│ music.db │ │ Gemini AI │
└──────────┘ └──────────────┘
``` ```
### 4. Access the Dashboard - **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
Open your browser to: ## Data Persistence
`http://localhost:8991`
### 5. Data Persistence 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
- **Database**: Stored in a named volume or host path mapped to `/app/music.db`. To backup your data:
- **Migrations**: The backend uses Alembic. Schema changes are applied automatically on startup. ```bash
docker cp $(docker-compose ps -q backend):/app/music.db ./backup.db
```
## Local Development (Non-Docker) ## Local Development
1. **Backend**: ### Backend
```bash ```bash
cd backend cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python run_worker.py # Starts ingestion
uvicorn app.main:app --reload # Starts API # Run migrations
alembic upgrade head
# Start worker (polls Spotify every 60s)
python run_worker.py &
# Start API server
uvicorn app.main:app --reload
``` ```
2. **Frontend**: ### Frontend
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` ```
Access at `http://localhost:5173`.
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`) |

View File

@@ -3,9 +3,20 @@ FROM python:3.11-slim
WORKDIR /app 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . 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"]

View File

@@ -10,29 +10,17 @@ from alembic import context
# Add app to path to import models # Add app to path to import models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import Base from app.database import Base, SQLALCHEMY_DATABASE_URL
from app.models import * # Import models to register them 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 config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
# 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")
def run_migrations_offline() -> None: def run_migrations_offline() -> None:

View File

@@ -1,11 +1,14 @@
import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base 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( connect_args = {}
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} 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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()

View File

@@ -16,8 +16,18 @@ load_dotenv()
# Create tables # Create tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Music Analyser Backend") app = FastAPI(title="Music Analyser Backend")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/") @app.get("/")
def read_root(): def read_root():
return {"status": "ok", "message": "Music Analyser API is running"} return {"status": "ok", "message": "Music Analyser API is running"}
@@ -59,9 +69,8 @@ def trigger_analysis(
if stats_json["volume"]["total_plays"] == 0: if stats_json["volume"]["total_plays"] == 0:
raise HTTPException(status_code=404, detail="No plays found in the specified period.") raise HTTPException(status_code=404, detail="No plays found in the specified period.")
# 2. Generate Narrative
narrative_service = NarrativeService(model_name=model_name) 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 # 3. Save Snapshot
snapshot = AnalysisSnapshot( snapshot = AnalysisSnapshot(
@@ -84,6 +93,8 @@ def trigger_analysis(
"narrative": narrative_json "narrative": narrative_json
} }
except HTTPException:
raise # Re-raise HTTPExceptions as-is (404, etc.)
except Exception as e: except Exception as e:
print(f"Analysis Failed: {e}") print(f"Analysis Failed: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,16 +1,15 @@
import os import os
import json import json
import re import re
import google.generativeai as genai from google import genai
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
class NarrativeService: class NarrativeService:
def __init__(self, model_name: str = "gemini-2.0-flash-exp"): def __init__(self, model_name: str = "gemini-2.0-flash-exp"):
self.api_key = os.getenv("GEMINI_API_KEY") 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: if not self.api_key:
print("WARNING: GEMINI_API_KEY not found. LLM features will fail.") print("WARNING: GEMINI_API_KEY not found. LLM features will fail.")
else:
genai.configure(api_key=self.api_key)
self.model_name = model_name 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: try:
model = genai.GenerativeModel(self.model_name) response = self.client.models.generate_content(
# Use JSON mode if available, otherwise rely on prompt + cleaning model=self.model_name,
response = model.generate_content( contents=prompt,
prompt, config=genai.types.GenerateContentConfig(response_mime_type="application/json")
generation_config={"response_mime_type": "application/json"}
) )
return self._clean_and_parse_json(response.text) return self._clean_and_parse_json(response.text)

17
backend/entrypoint.sh Normal file
View 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

View File

@@ -1,14 +1,15 @@
fastapi==0.109.2 fastapi==0.109.2
uvicorn==0.27.1 uvicorn==0.27.1
sqlalchemy==2.0.27 sqlalchemy==2.0.45
httpx==0.26.0 httpx==0.28.1
python-dotenv==1.0.1 python-dotenv==1.0.1
pydantic==2.6.1 pydantic==2.12.5
pydantic-settings==2.1.0 pydantic-core==2.41.5
google-generativeai==0.3.2 pydantic-settings==2.12.0
tenacity==8.2.3 tenacity==8.2.3
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
requests==2.31.0 requests==2.31.0
alembic==1.13.1 alembic==1.13.1
scikit-learn==1.4.0 scikit-learn==1.4.0
lyricsgenius==3.0.1 lyricsgenius==3.0.1
google-genai==1.56.0

View File

@@ -1,4 +1,5 @@
version: '3.8' version: '3.8'
services: services:
backend: backend:
build: build:
@@ -7,20 +8,35 @@ services:
container_name: music-analyser-backend container_name: music-analyser-backend
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /opt/mySpotify/music.db:/app/music.db - music_data:/app/data
environment: environment:
- DATABASE_URL=sqlite:////app/data/music.db
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN} - SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN}
- GEMINI_API_KEY=${GEMINI_API_KEY} - GEMINI_API_KEY=${GEMINI_API_KEY}
- GENIUS_ACCESS_TOKEN=${GENIUS_ACCESS_TOKEN}
ports: ports:
- '8000:8000' - '8000:8000'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
image: ghcr.io/bnair123/musicanalyser-frontend:latest
container_name: music-analyser-frontend container_name: music-analyser-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- '8991:80' - '8991:80'
depends_on: depends_on:
- backend backend:
condition: service_healthy
volumes:
music_data:
driver: local

View File

@@ -1,12 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> </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> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,25 @@
"antd": "^6.1.2", "antd": "^6.1.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"lucide-react": "^0.562.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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;
}

View File

@@ -1,117 +1,9 @@
import React, { useEffect, useState } from 'react'; import Dashboard from './components/Dashboard';
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>;
}
}
];
function App() {
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Dashboard />
<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>
); );
}; }
export default App; export default App;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -1,68 +1,88 @@
:root { @tailwind base;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; @tailwind components;
line-height: 1.5; @tailwind utilities;
font-weight: 400;
color-scheme: light dark; /* Custom Utilities from code.html */
color: rgba(255, 255, 255, 0.87); .glass-panel {
background-color: #242424; background: rgba(24, 34, 52, 0.6);
backdrop-filter: blur(12px);
font-synthesis: none; -webkit-backdrop-filter: blur(12px);
text-rendering: optimizeLegibility; border: 1px solid rgba(255, 255, 255, 0.08);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
a { .holographic-badge {
font-weight: 500; background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
color: #646cff; box-shadow: 0 0 20px rgba(37, 106, 244, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.2);
text-decoration: inherit; backdrop-filter: blur(8px);
} position: relative;
a:hover { overflow: hidden;
color: #535bf2;
} }
body { .holographic-badge::before {
margin: 0; content: "";
display: flex; position: absolute;
place-items: center; top: -50%;
min-width: 320px; left: -50%;
min-height: 100vh; 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 { @keyframes holo-shine {
font-size: 3.2em; 0% {
line-height: 1.1; transform: translateX(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) rotate(45deg);
}
} }
button { .typing-cursor::after {
border-radius: 8px; content: "|";
border: 1px solid transparent; animation: blink 1s step-end infinite;
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) { @keyframes blink {
:root { 50% {
color: #213547; opacity: 0;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
} }
} }
.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);
}

View File

@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
import './index.css';
import { ConfigProvider, theme } from 'antd'; import { ConfigProvider, theme } from 'antd';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(

View 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: [],
}