mirror of
https://github.com/bnair123/MusicAnalyser.git
synced 2026-02-25 11:46:07 +00:00
- Migrate database from SQLite to PostgreSQL (100.91.248.114:5433) - Fix playlist curation to use actual top tracks instead of AI name matching - Add /playlists/history endpoint for historical playlist viewing - Add Playlist Archives section to frontend with expandable history - Add playlist-modify-* scopes to Spotify OAuth for playlist creation - Rewrite Genius client to use official API (fixes 403 scraping blocks) - Ensure playlists are created on Spotify before curation attempts - Add DATABASE.md documentation for PostgreSQL schema - Add migrations for PlaylistConfig and composition storage
256 lines
9.6 KiB
JavaScript
256 lines
9.6 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import axios from 'axios';
|
|
import { Card, Button, Typography, Space, Spin, message, Tooltip as AntTooltip, Collapse, Empty } from 'antd';
|
|
import {
|
|
PlayCircleOutlined,
|
|
ReloadOutlined,
|
|
HistoryOutlined,
|
|
InfoCircleOutlined,
|
|
CustomerServiceOutlined,
|
|
CalendarOutlined,
|
|
DownOutlined
|
|
} from '@ant-design/icons';
|
|
import Tooltip from './Tooltip';
|
|
import TrackList from './TrackList';
|
|
|
|
const { Title, Text, Paragraph } = Typography;
|
|
|
|
const PlaylistsSection = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState({ sixHour: false, daily: false });
|
|
const [playlists, setPlaylists] = useState(null);
|
|
|
|
const [history, setHistory] = useState([]);
|
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
|
|
const fetchPlaylists = async () => {
|
|
try {
|
|
const response = await axios.get('/api/playlists');
|
|
setPlaylists(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch playlists:', error);
|
|
message.error('Failed to load playlist metadata');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchHistory = async () => {
|
|
if (loadingHistory) return;
|
|
setLoadingHistory(true);
|
|
try {
|
|
const response = await axios.get('/api/playlists/history');
|
|
setHistory(response.data.history || []);
|
|
} catch (error) {
|
|
console.error('Failed to fetch playlist history:', error);
|
|
message.error('Failed to load playlist history');
|
|
} finally {
|
|
setLoadingHistory(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchPlaylists();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (showHistory && history.length === 0) {
|
|
fetchHistory();
|
|
}
|
|
}, [showHistory]);
|
|
|
|
const handleRefresh = async (type) => {
|
|
const isSixHour = type === 'six-hour';
|
|
setRefreshing(prev => ({ ...prev, [isSixHour ? 'sixHour' : 'daily']: true }));
|
|
|
|
try {
|
|
const endpoint = isSixHour ? '/api/playlists/refresh/six-hour' : '/api/playlists/refresh/daily';
|
|
await axios.post(endpoint);
|
|
message.success(`${isSixHour ? '6-Hour' : 'Daily'} playlist refreshed!`);
|
|
await fetchPlaylists();
|
|
if (showHistory) fetchHistory();
|
|
} catch (error) {
|
|
console.error(`Refresh failed for ${type}:`, error);
|
|
message.error(`Failed to refresh ${type} playlist`);
|
|
} finally {
|
|
setRefreshing(prev => ({ ...prev, [isSixHour ? 'sixHour' : 'daily']: false }));
|
|
}
|
|
};
|
|
|
|
if (loading) return <div className="flex justify-center p-8"><Spin size="large" /></div>;
|
|
|
|
const historyItems = history.map((item) => ({
|
|
key: item.id,
|
|
label: (
|
|
<div className="flex justify-between items-center w-full">
|
|
<div className="flex items-center space-x-3">
|
|
<Text className="text-gray-400 font-mono text-xs">
|
|
{new Date(item.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
|
</Text>
|
|
<Text className="text-white font-medium">{item.theme}</Text>
|
|
<span className="px-2 py-0.5 rounded text-[10px] bg-slate-700 text-blue-300 border border-slate-600">
|
|
{item.period_label || '6h'}
|
|
</span>
|
|
</div>
|
|
<Text className="text-gray-500 text-xs">{item.composition?.length || 0} tracks</Text>
|
|
</div>
|
|
),
|
|
children: (
|
|
<div className="pl-2">
|
|
<Paragraph className="text-gray-300 text-sm italic mb-2 border-l-2 border-blue-500 pl-3 py-1">
|
|
"{item.reasoning}"
|
|
</Paragraph>
|
|
<TrackList tracks={item.composition} maxHeight="max-h-96" />
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
return (
|
|
<div className="mt-8 space-y-6">
|
|
<div className="flex items-center space-x-2">
|
|
<Title level={3} className="!mb-0 text-white flex items-center">
|
|
<CustomerServiceOutlined className="mr-2 text-blue-400" />
|
|
AI Curated Playlists
|
|
</Title>
|
|
<Tooltip text="Dynamic playlists that evolve with your taste. Refreshed automatically, or trigger manually here.">
|
|
<InfoCircleOutlined className="text-gray-400 cursor-help" />
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* 6-Hour Playlist */}
|
|
<Card
|
|
className="bg-slate-800 border-slate-700 shadow-xl"
|
|
title={<span className="text-blue-400 flex items-center"><HistoryOutlined className="mr-2" /> Short & Sweet (6h)</span>}
|
|
extra={
|
|
<Button
|
|
type="text"
|
|
icon={<ReloadOutlined spin={refreshing.sixHour} />}
|
|
onClick={() => handleRefresh('six-hour')}
|
|
className="text-gray-400 hover:text-white"
|
|
disabled={refreshing.sixHour}
|
|
/>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Current Theme</Text>
|
|
<Title level={4} className="!mt-0 !mb-1 text-white">{playlists?.six_hour?.theme || 'Calculating...'}</Title>
|
|
<Paragraph className="text-gray-300 text-sm italic mb-0">
|
|
"{playlists?.six_hour?.reasoning || 'Analyzing your recent listening patterns to find the perfect vibe.'}"
|
|
</Paragraph>
|
|
|
|
<TrackList tracks={playlists?.six_hour?.composition} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
|
|
<div className="flex flex-col">
|
|
<Text className="text-gray-500 text-xs">Last Updated</Text>
|
|
<Text className="text-gray-300 text-xs font-mono">
|
|
{playlists?.six_hour?.last_refresh ? new Date(playlists.six_hour.last_refresh).toLocaleString() : 'Never'}
|
|
</Text>
|
|
</div>
|
|
|
|
<Button
|
|
type="primary"
|
|
shape="round"
|
|
icon={<PlayCircleOutlined />}
|
|
href={`https://open.spotify.com/playlist/${playlists?.six_hour?.id}`}
|
|
target="_blank"
|
|
disabled={!playlists?.six_hour?.id}
|
|
className="bg-blue-600 hover:bg-blue-500 border-none"
|
|
>
|
|
Open Spotify
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Daily Playlist */}
|
|
<Card
|
|
className="bg-slate-800 border-slate-700 shadow-xl"
|
|
title={<span className="text-purple-400 flex items-center"><PlayCircleOutlined className="mr-2" /> Proof of Commitment (24h)</span>}
|
|
extra={
|
|
<Button
|
|
type="text"
|
|
icon={<ReloadOutlined spin={refreshing.daily} />}
|
|
onClick={() => handleRefresh('daily')}
|
|
className="text-gray-400 hover:text-white"
|
|
disabled={refreshing.daily}
|
|
/>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Daily Mix Strategy</Text>
|
|
<Title level={4} className="!mt-0 !mb-1 text-white">Daily Devotion Mix</Title>
|
|
<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.
|
|
</Paragraph>
|
|
|
|
<TrackList tracks={playlists?.daily?.composition} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
|
|
<div className="flex flex-col">
|
|
<Text className="text-gray-500 text-xs">Last Updated</Text>
|
|
<Text className="text-gray-300 text-xs font-mono">
|
|
{playlists?.daily?.last_refresh ? new Date(playlists.daily.last_refresh).toLocaleString() : 'Never'}
|
|
</Text>
|
|
</div>
|
|
|
|
<Button
|
|
type="primary"
|
|
shape="round"
|
|
icon={<PlayCircleOutlined />}
|
|
href={`https://open.spotify.com/playlist/${playlists?.daily?.id}`}
|
|
target="_blank"
|
|
disabled={!playlists?.daily?.id}
|
|
className="bg-purple-600 hover:bg-purple-500 border-none"
|
|
>
|
|
Open Spotify
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="mt-8 border-t border-slate-700 pt-6">
|
|
<Button
|
|
type="text"
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
className="flex items-center text-gray-400 hover:text-white p-0 text-lg font-medium mb-4 transition-colors"
|
|
>
|
|
<CalendarOutlined className="mr-2" />
|
|
Playlist Archives
|
|
<DownOutlined className={`ml-2 text-xs transition-transform duration-300 ${showHistory ? 'rotate-180' : ''}`} />
|
|
</Button>
|
|
|
|
{showHistory && (
|
|
<div className="animate-fade-in">
|
|
{loadingHistory ? (
|
|
<div className="flex justify-center p-8"><Spin /></div>
|
|
) : history.length > 0 ? (
|
|
<Collapse
|
|
items={historyItems}
|
|
bordered={false}
|
|
className="bg-transparent"
|
|
expandIconPosition="end"
|
|
ghost
|
|
theme="dark"
|
|
itemLayout="horizontal"
|
|
style={{ background: 'transparent' }}
|
|
/>
|
|
) : (
|
|
<Empty description={<span className="text-gray-500">No playlist history available yet</span>} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PlaylistsSection;
|