mirror of
https://github.com/bnair123/MusicAnalyser.git
synced 2026-02-25 11:46:07 +00:00
feat(frontend): add Archives, update Playlists with composition, add Navbar
This commit is contained in:
@@ -1,8 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
|
import Archives from './components/Archives';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Dashboard />
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/archives" element={<Archives />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
209
frontend/src/components/Archives.jsx
Normal file
209
frontend/src/components/Archives.jsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Card, Typography, Spin, Drawer, Empty, Tag, Button } from 'antd';
|
||||||
|
import { CalendarOutlined, RightOutlined, HistoryOutlined, RobotOutlined } from '@ant-design/icons';
|
||||||
|
import NarrativeSection from './NarrativeSection';
|
||||||
|
import StatsGrid from './StatsGrid';
|
||||||
|
import TrackList from './TrackList';
|
||||||
|
import Navbar from './Navbar';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const Archives = () => {
|
||||||
|
const [snapshots, setSnapshots] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
||||||
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||||
|
|
||||||
|
const fetchSnapshots = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/snapshots');
|
||||||
|
setSnapshots(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch snapshots:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSnapshots();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSnapshotClick = (snapshot) => {
|
||||||
|
setSelectedSnapshot(snapshot);
|
||||||
|
setDrawerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDrawer = () => {
|
||||||
|
setDrawerVisible(false);
|
||||||
|
setSelectedSnapshot(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to safely parse JSON if it comes as string (though axios usually handles it)
|
||||||
|
const safeParse = (data) => {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar showRefresh={false} />
|
||||||
|
|
||||||
|
<main className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Title level={2} className="text-white !mb-0 flex items-center gap-3">
|
||||||
|
<HistoryOutlined className="text-primary" />
|
||||||
|
Archives
|
||||||
|
</Title>
|
||||||
|
<Text className="text-slate-400">
|
||||||
|
{snapshots.length} Snapshot{snapshots.length !== 1 && 's'} Found
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center p-12">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : snapshots.length === 0 ? (
|
||||||
|
<div className="glass-panel p-12 text-center rounded-xl border border-dashed border-slate-700">
|
||||||
|
<Empty description={<span className="text-slate-400">No archives found yet.</span>} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{snapshots.map((snap) => {
|
||||||
|
const narrative = safeParse(snap.narrative_report);
|
||||||
|
const metrics = safeParse(snap.metrics_payload);
|
||||||
|
const date = new Date(snap.created_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={snap.id}
|
||||||
|
hoverable
|
||||||
|
className="bg-slate-800 border-slate-700 shadow-xl transition-all duration-300 hover:border-primary/50 group"
|
||||||
|
onClick={() => handleSnapshotClick(snap)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<Tag icon={<CalendarOutlined />} color="blue">
|
||||||
|
{date.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||||
|
</Tag>
|
||||||
|
<Text className="text-slate-500 text-xs font-mono">
|
||||||
|
{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Text className="text-slate-400 text-xs uppercase tracking-wider block mb-1">Vibe</Text>
|
||||||
|
<Title level={4} className="!mt-0 !mb-1 text-white truncate">
|
||||||
|
{metrics?.vibe || 'Unknown Vibe'}
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text className="text-slate-400 text-xs uppercase tracking-wider block mb-1">Musical Era</Text>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-primary font-bold">
|
||||||
|
{metrics?.era?.musical_age || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-slate-700/50 flex justify-end">
|
||||||
|
<Button type="text" className="text-slate-400 group-hover:text-primary flex items-center gap-1 pl-0">
|
||||||
|
View Details <RightOutlined className="text-xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CalendarOutlined className="text-primary" />
|
||||||
|
<span className="text-white">
|
||||||
|
Snapshot: {selectedSnapshot && new Date(selectedSnapshot.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
width={800} // Wide drawer
|
||||||
|
onClose={closeDrawer}
|
||||||
|
open={drawerVisible}
|
||||||
|
className="bg-[#0f172a] text-white"
|
||||||
|
styles={{
|
||||||
|
header: { background: '#1e293b', borderBottom: '1px solid #334155' },
|
||||||
|
body: { background: '#0f172a', padding: '24px' },
|
||||||
|
mask: { backdropFilter: 'blur(4px)' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedSnapshot && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Reuse components but pass the specific snapshot data */}
|
||||||
|
<NarrativeSection
|
||||||
|
narrative={safeParse(selectedSnapshot.narrative_report)}
|
||||||
|
vibe={safeParse(selectedSnapshot.metrics_payload)?.vibe}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsGrid metrics={safeParse(selectedSnapshot.metrics_payload)} />
|
||||||
|
|
||||||
|
{/* Playlist Compositions if available */}
|
||||||
|
{selectedSnapshot.playlist_composition && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<HistoryOutlined className="text-blue-400 text-xl" />
|
||||||
|
<Title level={3} className="!mb-0 text-white">Archived Playlists</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* We need to parse playlist composition if it's stored as JSON string */}
|
||||||
|
{(() => {
|
||||||
|
const playlists = safeParse(selectedSnapshot.playlist_composition);
|
||||||
|
if (!playlists) return <Text className="text-slate-500">No playlist data archived.</Text>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{playlists.six_hour && (
|
||||||
|
<Card className="bg-slate-800 border-slate-700" title={<span className="text-blue-400">Short & Sweet (6h)</span>}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Theme</Text>
|
||||||
|
<Text className="text-white font-medium block">{playlists.six_hour.theme}</Text>
|
||||||
|
<Paragraph className="text-gray-400 text-sm italic mt-1">{playlists.six_hour.reasoning}</Paragraph>
|
||||||
|
</div>
|
||||||
|
<TrackList tracks={playlists.six_hour.composition} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playlists.daily && (
|
||||||
|
<Card className="bg-slate-800 border-slate-700" title={<span className="text-purple-400">Daily Devotion</span>}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Strategy</Text>
|
||||||
|
<Text className="text-white font-medium block">Daily Mix</Text>
|
||||||
|
</div>
|
||||||
|
<TrackList tracks={playlists.daily.composition} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Archives;
|
||||||
@@ -7,6 +7,7 @@ import VibeRadar from './VibeRadar';
|
|||||||
import HeatMap from './HeatMap';
|
import HeatMap from './HeatMap';
|
||||||
import TopRotation from './TopRotation';
|
import TopRotation from './TopRotation';
|
||||||
import ListeningLog from './ListeningLog';
|
import ListeningLog from './ListeningLog';
|
||||||
|
import Navbar from './Navbar';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
const API_BASE_URL = '/api';
|
const API_BASE_URL = '/api';
|
||||||
@@ -80,26 +81,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="sticky top-0 z-50 glass-panel border-b border-[#222f49]">
|
<Navbar onRefresh={() => fetchData(true)} />
|
||||||
<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">
|
<main className="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||||
<NarrativeSection narrative={data?.narrative} vibe={data?.metrics?.vibe} />
|
<NarrativeSection narrative={data?.narrative} vibe={data?.metrics?.vibe} />
|
||||||
|
|||||||
61
frontend/src/components/Navbar.jsx
Normal file
61
frontend/src/components/Navbar.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
const Navbar = ({ onRefresh, showRefresh = true }) => {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 glass-panel border-b border-[#222f49] backdrop-blur-md bg-[#0f172a]/80">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<NavLink to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||||
|
<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>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-1 bg-slate-800/50 p-1 rounded-lg border border-white/5">
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-white shadow-lg shadow-primary/25'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/archives"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-white shadow-lg shadow-primary/25'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Archives
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{showRefresh && (
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CustomerServiceOutlined
|
CustomerServiceOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Tooltip from './Tooltip';
|
import Tooltip from './Tooltip';
|
||||||
|
import TrackList from './TrackList';
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
@@ -86,6 +87,8 @@ const PlaylistsSection = () => {
|
|||||||
<Paragraph className="text-gray-300 text-sm italic mb-0">
|
<Paragraph className="text-gray-300 text-sm italic mb-0">
|
||||||
"{playlists?.six_hour?.reasoning || 'Analyzing your recent listening patterns to find the perfect vibe.'}"
|
"{playlists?.six_hour?.reasoning || 'Analyzing your recent listening patterns to find the perfect vibe.'}"
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
|
<TrackList tracks={playlists?.six_hour?.composition} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
|
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
|
||||||
@@ -132,6 +135,8 @@ const PlaylistsSection = () => {
|
|||||||
<Paragraph className="text-gray-300 text-sm mb-0">
|
<Paragraph className="text-gray-300 text-sm mb-0">
|
||||||
A blend of 30 all-time favorites and 20 recent discoveries to keep your rotation fresh but familiar.
|
A blend of 30 all-time favorites and 20 recent discoveries to keep your rotation fresh but familiar.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
|
<TrackList tracks={playlists?.daily?.composition} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
|
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
|
||||||
|
|||||||
61
frontend/src/components/TrackList.jsx
Normal file
61
frontend/src/components/TrackList.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Typography, Tooltip as AntTooltip, Avatar } from 'antd';
|
||||||
|
import {
|
||||||
|
PlayCircleOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
|
CustomerServiceOutlined,
|
||||||
|
RobotOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const TrackList = ({ tracks, maxHeight = "max-h-60" }) => {
|
||||||
|
if (!tracks || tracks.length === 0) return <div className="text-gray-500 text-sm py-4 text-center">No tracks available</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${maxHeight} overflow-y-auto custom-scrollbar pr-2 -mr-2 my-4 space-y-2`}>
|
||||||
|
{tracks.map((track, idx) => (
|
||||||
|
<div key={`${track.id}-${idx}`} className="flex items-center p-2 rounded-lg bg-slate-700/50 hover:bg-slate-700 transition-colors group">
|
||||||
|
<div className="flex-shrink-0 relative">
|
||||||
|
<Avatar shape="square" size={40} src={track.image_url} icon={<CustomerServiceOutlined />} />
|
||||||
|
<div className="absolute -top-1 -right-1">
|
||||||
|
{track.source === 'recommendation' ? (
|
||||||
|
<AntTooltip title="AI Recommendation">
|
||||||
|
<div className="bg-purple-500 rounded-full p-0.5 w-4 h-4 flex items-center justify-center">
|
||||||
|
<RobotOutlined className="text-white text-[10px]" />
|
||||||
|
</div>
|
||||||
|
</AntTooltip>
|
||||||
|
) : (
|
||||||
|
<AntTooltip title="From History">
|
||||||
|
<div className="bg-blue-500 rounded-full p-0.5 w-4 h-4 flex items-center justify-center">
|
||||||
|
<HistoryOutlined className="text-white text-[10px]" />
|
||||||
|
</div>
|
||||||
|
</AntTooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 flex-grow min-w-0">
|
||||||
|
<Text className="text-white text-sm font-medium truncate block" title={track.name}>
|
||||||
|
{track.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400 text-xs truncate block" title={track.artist}>
|
||||||
|
{track.artist}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
href={`https://open.spotify.com/track/${track.id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackList;
|
||||||
Reference in New Issue
Block a user