Add text/CSV export endpoints for playlists and saved recommendations. Add export buttons to PlaylistDetail and Recommendations pages. Add Open Graph and Twitter meta tags to index.html for better SEO.
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Loader2, Clock, Heart, Sparkles, Download } from 'lucide-react'
|
|
import { useAuth } from '../lib/auth'
|
|
import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, exportSaved, type RecommendationItem } from '../lib/api'
|
|
import RecommendationCard from '../components/RecommendationCard'
|
|
|
|
type Tab = 'saved' | 'history'
|
|
|
|
export default function Recommendations() {
|
|
const { user } = useAuth()
|
|
const [tab, setTab] = useState<Tab>('saved')
|
|
const [saved, setSaved] = useState<RecommendationItem[]>([])
|
|
const [history, setHistory] = useState<RecommendationItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [savingIds, setSavingIds] = useState<Set<string>>(new Set())
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const [savedData, historyData] = await Promise.all([
|
|
getSavedRecommendations(),
|
|
getRecommendationHistory(),
|
|
])
|
|
setSaved(savedData)
|
|
setHistory(historyData)
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
load()
|
|
}, [])
|
|
|
|
const handleToggleSave = async (id: string) => {
|
|
setSavingIds((prev) => new Set(prev).add(id))
|
|
try {
|
|
const { saved: isSaved } = await toggleSaveRecommendation(id)
|
|
const updater = (items: RecommendationItem[]) =>
|
|
items.map((r) => (r.id === id ? { ...r, saved: isSaved } : r))
|
|
setSaved(updater)
|
|
setHistory(updater)
|
|
|
|
// If unsaved, remove from saved tab
|
|
if (!isSaved) {
|
|
setSaved((prev) => prev.filter((r) => r.id !== id))
|
|
}
|
|
// If saved, add to saved tab if not already there
|
|
if (isSaved) {
|
|
const item = history.find((r) => r.id === id)
|
|
if (item) {
|
|
setSaved((prev) =>
|
|
prev.some((r) => r.id === id) ? prev : [...prev, { ...item, saved: true }]
|
|
)
|
|
}
|
|
}
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setSavingIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(id)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const items = tab === 'saved' ? saved : history
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="w-8 h-8 text-purple animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-charcoal">Recommendations</h1>
|
|
<p className="text-charcoal-muted mt-1">Your discovered music</p>
|
|
</div>
|
|
|
|
{/* Daily remaining */}
|
|
{!user?.is_pro && (
|
|
<div className="flex items-center gap-2 px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl text-sm">
|
|
<Sparkles className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
|
<span className="text-amber-700">
|
|
<strong>{user?.daily_recommendations_remaining ?? 0}</strong> recommendations remaining today (free tier)
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Export + Tabs */}
|
|
<div className="flex items-center gap-4">
|
|
{tab === 'saved' && saved.length > 0 && (
|
|
<button
|
|
onClick={() => exportSaved()}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-purple-50 text-purple text-sm font-medium rounded-xl hover:bg-purple-100 transition-colors cursor-pointer border-none"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Export Saved
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1 bg-purple-50 p-1 rounded-xl w-fit">
|
|
<button
|
|
onClick={() => setTab('saved')}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
|
|
tab === 'saved'
|
|
? 'bg-white text-purple shadow-sm'
|
|
: 'bg-transparent text-charcoal-muted hover:text-charcoal'
|
|
}`}
|
|
>
|
|
<Heart className="w-4 h-4" />
|
|
Saved ({saved.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setTab('history')}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer border-none ${
|
|
tab === 'history'
|
|
? 'bg-white text-purple shadow-sm'
|
|
: 'bg-transparent text-charcoal-muted hover:text-charcoal'
|
|
}`}
|
|
>
|
|
<Clock className="w-4 h-4" />
|
|
History ({history.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* List */}
|
|
{items.length === 0 ? (
|
|
<div className="bg-white rounded-2xl border border-purple-100 p-12 text-center">
|
|
<div className="w-16 h-16 rounded-2xl bg-purple-50 flex items-center justify-center mx-auto mb-4">
|
|
{tab === 'saved' ? (
|
|
<Heart className="w-8 h-8 text-purple" />
|
|
) : (
|
|
<Clock className="w-8 h-8 text-purple" />
|
|
)}
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-charcoal mb-2">
|
|
{tab === 'saved' ? 'No saved recommendations' : 'No recommendations yet'}
|
|
</h2>
|
|
<p className="text-charcoal-muted max-w-md mx-auto">
|
|
{tab === 'saved'
|
|
? 'Tap the heart icon on recommendations to save them here'
|
|
: 'Head to the Discover page to get your first recommendations'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{items.map((rec) => (
|
|
<RecommendationCard
|
|
key={rec.id}
|
|
recommendation={rec}
|
|
onToggleSave={handleToggleSave}
|
|
saving={savingIds.has(rec.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|