Restart repository history from target event
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user