Files

313 lines
15 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { FolderOpen, RefreshCw, Disc, Music, ArrowLeft, Trash2, Search, Waves } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AlbumSelector } from "@/components/album-selector";
import { AlbumView } from "@/components/album-view";
import { StatusLog } from "@/components/status-log";
import { AudioWave } from "@/components/ui/audio-wave";
import { TidalSearch } from "@/components/tidal-search";
import { motion, AnimatePresence } from "motion/react";
import { Album } from "@/types";
export default function Dashboard() {
const [albums, setAlbums] = useState<Album[]>([]);
const [selectedAlbum, setSelectedAlbum] = useState<Album | null>(null);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [view, setView] = useState<"idle" | "selection" | "details" | "tidal">("idle");
const addLog = (message: string) => {
setLogs((prev) => {
const newLogs = [...prev, `[${new Date().toLocaleTimeString()}] ${message}`];
if (newLogs.length > 50) return newLogs.slice(newLogs.length - 50);
return newLogs;
});
};
const scanWatchFolder = async (preserveView = false) => {
setLoading(true);
addLog("Scanning watch folder...");
try {
const res = await fetch("/api/scan");
const data = await res.json();
if (data.albums) {
setAlbums(data.albums);
addLog(`Found ${data.albums.length} albums.`);
if (!preserveView) {
if (data.albums.length > 0) {
setView("selection");
} else {
setView("idle");
}
}
}
} catch (error) {
addLog("Error scanning watch folder.");
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
scanWatchFolder();
}, []);
// Auto-refresh watch folder
useEffect(() => {
const interval = setInterval(() => {
// Silent scan - don't show loading spinner for auto-refresh
fetch("/api/scan")
.then(res => res.json())
.then((data: { albums: Album[] }) => {
if (data.albums) {
// Only update if count changed or deep comparison (simplified here by length/names check)
setAlbums(prev => {
const prevNames = prev.map(a => a.path).sort().join(',');
const newNames = data.albums.map((a) => a.path).sort().join(',');
if (prevNames !== newNames) {
addLog(`Watch folder updated: ${data.albums.length} albums found.`);
return data.albums;
}
// Even if list didn't change structure, update to get new cover paths if they appeared
// But we need to be careful not to cause re-renders if nothing changed.
// Let's check if any coverPath changed for the selected album
return data.albums;
});
// Sync selectedAlbum with new data to ensure cover art updates
if (selectedAlbum) {
const updatedSelected = data.albums.find((a) => a.path === selectedAlbum.path);
if (updatedSelected && updatedSelected.coverPath !== selectedAlbum.coverPath) {
setSelectedAlbum(updatedSelected);
}
}
// If we are in idle view and albums appear, switch to selection
if (view === "idle" && data.albums.length > 0) {
setView("selection");
}
// If we are in selection view and albums disappear, switch to idle.
// However, if we are in TIDAL view, we shouldn't force switch back.
if (view === "selection" && data.albums.length === 0) {
setView("idle");
}
}
})
.catch(err => console.error("Auto-scan failed", err));
}, 1000);
return () => clearInterval(interval);
}, [view, selectedAlbum]);
const handleAlbumUpdate = async (newPath?: string) => {
await scanWatchFolder(true);
if (newPath) {
const name = newPath.split('/').pop() || "";
if (selectedAlbum) {
setSelectedAlbum({ ...selectedAlbum, path: newPath, name });
}
}
};
const handleSelectAlbum = (album: Album) => {
setSelectedAlbum(album);
setView("details");
};
const handleBack = () => {
setSelectedAlbum(null);
setView(albums.length > 0 ? "selection" : "idle");
};
return (
<div className="min-h-screen bg-background text-foreground flex flex-col font-sans selection:bg-primary/20 max-w-5xl mx-auto">
{/* Header */}
<header className="flex justify-between items-center p-6 shrink-0 z-40 max-w-5xl">
<div className="flex items-center gap-3">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
>
<Disc className="w-8 h-8 text-primary" />
</motion.div>
<h1 className="text-3xl font-bold tracking-tight bg-linear-to-r from-white to-white/50 bg-clip-text text-transparent">
crossfade
</h1>
</div>
<div className="flex gap-2">
{view === "details" && (
<Button onClick={handleBack} variant="ghost" className="text-muted-foreground hover:text-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Albums
</Button>
)}
{view === "tidal" ? (
<Button onClick={handleBack} variant="ghost" className="text-muted-foreground hover:text-foreground cursor-pointer">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
) : (
<Button
onClick={() => setView("tidal")}
variant="secondary"
className="bg-primary/10 text-primary hover:bg-primary/20 border-primary/20"
>
<Waves className="w-4 h-4 mr-2" />
TIDAL Search
</Button>
)}
<Button
onClick={async () => {
if (confirm("Are you sure you want to delete ALL generated spectrograms? This cannot be undone.")) {
addLog("Purging spectrograms...");
try {
const res = await fetch("/api/spectrogram", { method: "DELETE" });
const result = await res.json();
if (result.success) {
addLog("Spectrograms purged successfully.");
} else {
addLog(`Failed to purge: ${result.error}`);
}
} catch (e) {
addLog("Purge request failed.");
}
}
}}
variant="destructive"
className="bg-red-500/10 hover:bg-red-500/20 text-red-500 border-red-500/20"
>
<Trash2 className="w-4 h-4 mr-2" />
Purge Spectrograms
</Button>
<Button onClick={() => scanWatchFolder()} disabled={loading} variant="outline" className="bg-background/50 backdrop-blur border-white/10 hover:bg-white/10">
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Manual Rescan
</Button>
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 relative flex flex-col">
<AnimatePresence mode="wait" initial={true}>
{view === "idle" && (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col items-center justify-center gap-8 p-6"
>
<div className="relative">
<div className="absolute inset-0 bg-primary/20 blur-3xl rounded-full" />
<AudioWave />
</div>
<div className="text-center space-y-2 max-w-md z-10">
<h2 className="text-2xl font-medium text-foreground">Waiting for Music</h2>
<p className="text-muted-foreground">
Place albums in the watch folder to begin.
</p>
</div>
</motion.div>
)}
{view === "selection" && (
<motion.div
key="selection"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="flex-1 p-6 overflow-hidden flex flex-col"
>
<div className="max-w-5xl mx-auto w-full h-full flex flex-col">
<div className="mb-6">
<h2 className="text-xl font-semibold flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-primary" />
Select Album
</h2>
<p className="text-sm text-muted-foreground">
Found {albums.length} albums ready for processing
</p>
</div>
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="pb-6">
<AlbumSelector
albums={albums}
selectedAlbum={selectedAlbum}
onSelect={handleSelectAlbum}
/>
</div>
</ScrollArea>
</div>
</motion.div>
)}
{view === "tidal" && (
<motion.div
key="tidal"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="flex-1 p-6 overflow-hidden flex flex-col"
>
<div className="max-w-5xl mx-auto w-full h-full flex flex-col">
<div className="mb-6">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Waves className="w-5 h-5 text-primary" />
TIDAL Search
</h2>
<p className="text-sm text-muted-foreground">
Search and copy links from TIDAL
</p>
</div>
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="pb-6">
<TidalSearch />
</div>
</ScrollArea>
</div>
</motion.div>
)}
{view === "details" && selectedAlbum && (
<motion.div
key="details"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex-1 p-6 overflow-y-auto flex flex-col"
>
<div className="max-w-5xl mx-auto w-full">
<AlbumView
album={selectedAlbum}
addLog={addLog}
onReset={handleBack}
onAlbumUpdate={handleAlbumUpdate}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
{/* Status Log */}
{process.env.NEXT_PUBLIC_SHOW_SYSTEM_LOG === "true" && (
<div className="shrink-0 p-6 pt-0 z-10">
<StatusLog logs={logs} />
</div>
)}
</div>
);
}