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

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

File diff suppressed because it is too large Load Diff

View File

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

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

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 {
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;
@keyframes holo-shine {
0% {
transform: translateX(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) rotate(45deg);
}
}
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;
.typing-cursor::after {
content: "|";
animation: blink 1s step-end infinite;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
@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);
}

View File

@@ -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(

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