Restart repository history from target event
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { Disc, Music, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import { Album } from "@/types";
|
||||
|
||||
interface AlbumSelectorProps {
|
||||
albums: Album[];
|
||||
selectedAlbum: Album | null;
|
||||
onSelect: (album: Album) => void;
|
||||
}
|
||||
|
||||
export function AlbumSelector({ albums, selectedAlbum, onSelect }: AlbumSelectorProps) {
|
||||
if (albums.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{albums.map((album, i) => (
|
||||
<motion.div
|
||||
key={album.path}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2, delay: i * 0.03 }}
|
||||
onClick={() => onSelect(album)}
|
||||
className={cn(
|
||||
"group cursor-pointer flex flex-col gap-3 relative",
|
||||
selectedAlbum?.path === album.path ? "opacity-80" : ""
|
||||
)}
|
||||
>
|
||||
<div className="aspect-square rounded-lg bg-muted/20 overflow-hidden shadow-lg border border-white/5 group-hover:border-primary/50 group-hover:shadow-primary/10 transition-all duration-300 relative">
|
||||
{album.coverPath ? (
|
||||
<img
|
||||
src={`/api/cover?path=${encodeURIComponent(album.coverPath)}`}
|
||||
alt={album.name}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted/10">
|
||||
<Music className="w-16 h-16 text-muted-foreground/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-sm leading-tight truncate group-hover:text-primary transition-colors">
|
||||
{album.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate font-mono opacity-60">
|
||||
{album.path.split('/').pop()}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AlertTriangle, ArrowRight, Check, X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RenameProposal, ProposeRenameResponse, BatchRenameResponse } from "@/types";
|
||||
|
||||
interface RenameDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
albumPath: string;
|
||||
onRenameComplete: () => void;
|
||||
}
|
||||
|
||||
export function RenameDialog({ open, onOpenChange, albumPath, onRenameComplete }: RenameDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [proposals, setProposals] = useState<RenameProposal[]>([]);
|
||||
const [allFiles, setAllFiles] = useState<RenameProposal[]>([]);
|
||||
const [applyToAll, setApplyToAll] = useState(true);
|
||||
const [maxFileLength, setMaxFileLength] = useState(0);
|
||||
const [selectedStrategy, setSelectedStrategy] = useState<"remove_brackets" | "remove_parens" | "remove_both" | "truncate">("remove_both");
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && albumPath) {
|
||||
fetchProposals();
|
||||
}
|
||||
}, [open, albumPath]);
|
||||
|
||||
const fetchProposals = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/propose-rename", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ albumPath }),
|
||||
});
|
||||
const data = await res.json() as ProposeRenameResponse;
|
||||
if (data.success) {
|
||||
setProposals(data.problematicFiles);
|
||||
setAllFiles(data.allFiles || []);
|
||||
setMaxFileLength(data.maxFileLength);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch proposals", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
setRenaming(true);
|
||||
try {
|
||||
const activeProposals = applyToAll ? allFiles : proposals;
|
||||
const renames = activeProposals.map(p => ({
|
||||
original: p.original,
|
||||
newName: p.strategies[selectedStrategy] || p.strategies.truncate // Fallback to truncate if strategy returns null (shouldn't happen for truncate)
|
||||
}));
|
||||
|
||||
const res = await fetch("/api/batch-rename", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ albumPath, renames }),
|
||||
});
|
||||
const result = await res.json() as BatchRenameResponse;
|
||||
|
||||
if (result.success) {
|
||||
onRenameComplete();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
alert(`Rename failed: ${result.errors?.join(', ')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Rename request failed");
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPreview = (proposal: RenameProposal) => {
|
||||
const newName = proposal.strategies[selectedStrategy];
|
||||
if (!newName) return <span className="text-red-500 italic">Strategy not applicable (still too long)</span>;
|
||||
return <span className="text-green-500 font-medium">{newName}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Long Filenames Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
The following files exceed the maximum allowed length ({maxFileLength} characters).
|
||||
Please select a renaming strategy to fix them.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-4 py-4">
|
||||
<Tabs value={selectedStrategy} onValueChange={(v) => setSelectedStrategy(v as "remove_brackets" | "remove_parens" | "remove_both" | "truncate")} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="remove_brackets">Remove []</TabsTrigger>
|
||||
<TabsTrigger value="remove_parens">Remove ()</TabsTrigger>
|
||||
<TabsTrigger value="remove_both">Remove Both</TabsTrigger>
|
||||
<TabsTrigger value="truncate">Smart Truncate</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none hover:text-foreground transition-colors overflow-hidden">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyToAll}
|
||||
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-black/20 text-primary focus:ring-primary/50"
|
||||
/>
|
||||
Apply chosen resolution to all files in the album
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1 border rounded-md mt-4 overflow-hidden bg-muted/20">
|
||||
<ScrollArea className="h-[400px] p-4">
|
||||
<div className="space-y-4">
|
||||
{(applyToAll ? allFiles : proposals).map((p, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_auto_1fr] gap-4 items-center text-sm border-b border-border/50 pb-4 last:border-0 last:pb-0">
|
||||
<div className="break-all text-muted-foreground">
|
||||
{p.original}
|
||||
<Badge variant="outline" className="ml-2 text-xs">{p.original.length}</Badge>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground/50" />
|
||||
<div className="break-all">
|
||||
{getPreview(p)}
|
||||
{p.strategies[selectedStrategy] && (
|
||||
<Badge variant="outline" className="ml-2 text-xs border-green-500/30 text-green-500">
|
||||
{p.strategies[selectedStrategy]!.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Ignore & Close
|
||||
</Button>
|
||||
<Button onClick={handleRename} disabled={renaming}>
|
||||
{renaming ? "Renaming..." : "Apply Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface StatusLogProps {
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export function StatusLog({ logs }: StatusLogProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when logs change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-neutral-900/40 backdrop-blur text-gray-500 font-mono text-xs p-4 h-48 flex flex-col shadow-inner border-gray-900/30">
|
||||
<div className="flex items-center gap-2 mb-2 border-b border-gray-900/50 pb-1">
|
||||
<Terminal className="w-3 h-3" />
|
||||
<span className="uppercase tracking-wider text-[10px] text-gray-400">System Log</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="flex flex-col gap-1 max-h-10">
|
||||
<AnimatePresence initial={false}>
|
||||
{logs.map((log, i) => (
|
||||
<motion.div
|
||||
key={`${i}-${log.substring(0, 10)}`} // Use index + content snippet for key to ensure uniqueness but allow animation
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="break-all"
|
||||
>
|
||||
<span className="opacity-50 mr-2 select-none">{">"}</span>
|
||||
{log}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { Search, Loader2, Music, Clock, Copy, Filter, ExternalLink } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
artists: { name: string }[];
|
||||
duration: number;
|
||||
url: string;
|
||||
imageCover?: { url: string; width: number; height: number }[];
|
||||
mediaTags?: string[];
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
export function TidalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<"relevance" | "duration" | "year">("relevance");
|
||||
const [filterQuality, setFilterQuality] = useState<"all" | "hires" | "lossless">("all");
|
||||
const [countryCode, setCountryCode] = useState("US");
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setResults([]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tidal/search?query=${encodeURIComponent(query)}&countryCode=${countryCode}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data && data.albums) {
|
||||
setResults(data.albums);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setResults(data);
|
||||
} else {
|
||||
console.warn("Unexpected API response structure:", data);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
toast.error("Search failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = results.filter(album => {
|
||||
if (filterQuality === "all") return true;
|
||||
if (!album.mediaTags) return false;
|
||||
if (filterQuality === "hires") {
|
||||
return album.mediaTags.includes("HIRES_LOSSLESS");
|
||||
}
|
||||
if (filterQuality === "lossless") {
|
||||
// Exclusive filter: Must be Lossless AND NOT Hi-Res/MQA
|
||||
const isHires = album.mediaTags.includes("HIRES_LOSSLESS");
|
||||
return album.mediaTags.includes("LOSSLESS") && !isHires;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortedResults = [...filteredResults].sort((a, b) => {
|
||||
if (sortBy === "duration") {
|
||||
return (b.duration || 0) - (a.duration || 0);
|
||||
}
|
||||
if (sortBy === "year") {
|
||||
const dateA = new Date(a.releaseDate || 0).getFullYear();
|
||||
const dateB = new Date(b.releaseDate || 0).getFullYear();
|
||||
return dateB - dateA; // Newest first
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string, id: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
// Clear existing timeout to prevent premature hiding
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
setCopiedId(id);
|
||||
toast.success("Copied to clipboard!");
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
timeoutRef.current = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
toast.error("Failed to copy link");
|
||||
}
|
||||
};
|
||||
|
||||
function getYear(dateString?: string) {
|
||||
if (!dateString) return "";
|
||||
return new Date(dateString).getFullYear().toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-6 mx-auto w-full">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center justify-between bg-neutral-800/90 p-2 rounded-xl border shadow-sm backdrop-blur-sm">
|
||||
<div className="flex flex-1 gap-2 items-center bg-background/50 p-1.5 rounded-lg border border-border/40 focus-within:border-primary/50 transition-colors">
|
||||
<Search className="w-4 h-4 ml-2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search for music..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="flex-1 border-none shadow-none bg-transparent focus-visible:ring-0 h-9 font-medium"
|
||||
/>
|
||||
<div className="h-5 w-[1px] bg-border/60 mx-1" />
|
||||
<Input
|
||||
placeholder="US"
|
||||
value={countryCode}
|
||||
onChange={(e) => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
|
||||
className="w-14 border-none shadow-none bg-transparent focus-visible:ring-0 h-9 text-center font-mono text-xs uppercase text-muted-foreground focus:text-foreground transition-colors"
|
||||
maxLength={2}
|
||||
title="Region (ISO 3166-1 alpha-2)"
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading} size="sm" className="h-8 px-4 font-semibold shadow-none">
|
||||
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center px-1">
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as "relevance" | "duration" | "year")}>
|
||||
<SelectTrigger className="w-[140px] h-9 text-xs border-border/40 !bg-background/50 hover:!bg-background/70 backdrop-blur-sm font-medium">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="relevance">Relevance</SelectItem>
|
||||
<SelectItem value="duration">Duration</SelectItem>
|
||||
<SelectItem value="year">Release Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterQuality} onValueChange={(v) => setFilterQuality(v as "all" | "hires" | "lossless")}>
|
||||
<SelectTrigger className="w-[130px] h-9 text-xs border-border/40 !bg-background/50 hover:!bg-background/70 backdrop-blur-sm font-medium">
|
||||
<SelectValue placeholder="Quality" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Qualities</SelectItem>
|
||||
<SelectItem value="hires">Hi-Res Only</SelectItem>
|
||||
<SelectItem value="lossless">Lossless Only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-4 px-4">
|
||||
{sortedResults.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-muted-foreground gap-4">
|
||||
<div className="p-6 rounded-full bg-muted/30">
|
||||
<Music className="w-12 h-12 opacity-30" />
|
||||
</div>
|
||||
<p className="font-medium text-lg text-muted-foreground/60">{query ? "No results found" : "Start your search above"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 pb-6">
|
||||
{sortedResults.map((album) => {
|
||||
const artworkUrl = album.imageCover && album.imageCover.length > 0
|
||||
? album.imageCover.find(img => img.width === 640)?.url || album.imageCover[0].url
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={album.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="group flex flex-col gap-3 cursor-pointer"
|
||||
onClick={() => copyToClipboard(album.url, album.id)}
|
||||
>
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-muted/50 relative shadow-md group-hover:shadow-xl transition-all duration-300 ">
|
||||
{artworkUrl ? (
|
||||
<img
|
||||
src={artworkUrl}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground bg-secondary/30">
|
||||
<Music className="w-16 h-16 opacity-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 bg-black/60 backdrop-blur-[1px] flex items-center justify-center opacity-0 transition-opacity duration-200",
|
||||
copiedId === album.id ? "opacity-100 bg-emerald-500/90 backdrop-blur-sm" : "group-hover:opacity-100"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-3 transform transition-transform duration-200",
|
||||
copiedId === album.id ? "translate-y-0" : "translate-y-4 group-hover:translate-y-0"
|
||||
)}>
|
||||
{copiedId === album.id ? (
|
||||
<div className="flex flex-col items-center text-white animate-in zoom-in duration-200">
|
||||
<div className="h-12 w-12 rounded-full bg-white text-emerald-600 flex items-center justify-center shadow-lg mb-2">
|
||||
<Copy className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="font-bold tracking-widest text-sm">COPIED</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full bg-black/40 hover:!bg-white text-white hover:!text-black transition-all duration-100 backdrop-blur-md shadow-lg border border-white/20 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(album.url, album.id);
|
||||
}}
|
||||
title="Copy Link"
|
||||
>
|
||||
<Copy className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full bg-black/40 hover:!bg-white text-white hover:!text-black transition-all duration-100 backdrop-blur-md shadow-lg border border-white/20 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(album.url, '_blank');
|
||||
}}
|
||||
title="Open in TIDAL"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Badges - Refined Layout */}
|
||||
{album.mediaTags && (
|
||||
<div className="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1.5 pointer-events-none">
|
||||
{album.mediaTags.includes("HIRES_LOSSLESS") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-[#FFD700] text-black text-[0.6rem] font-bold tracking-wider shadow-sm border border-yellow-600/20">HI-RES</span>
|
||||
)}
|
||||
{album.mediaTags.includes("MQA") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-black text-[#FFD700] border border-[#FFD700] text-[0.6rem] font-bold tracking-wider shadow-sm">MQA</span>
|
||||
)}
|
||||
{!album.mediaTags.includes("HIRES_LOSSLESS") && !album.mediaTags.includes("MQA") && album.mediaTags.includes("LOSSLESS") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-cyan-500 text-black text-[0.6rem] font-bold tracking-wider shadow-sm">LOSSLESS</span>
|
||||
)}
|
||||
{album.mediaTags.includes("DOLBY_ATMOS") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-indigo-600 text-white text-[0.6rem] font-bold tracking-wider shadow-sm border border-white/10">ATMOS</span>
|
||||
)}
|
||||
{album.mediaTags.includes("SONY_360RA") && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-blue-600 text-white text-[0.6rem] font-bold tracking-wider shadow-sm border border-white/10">360RA</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="font-semibold text-sm leading-tight line-clamp-1 group-hover:text-primary transition-colors" title={album.title}>{album.title}</h3>
|
||||
<div className="h-6 w-full relative overflow-hidden">
|
||||
<ScrollableContent>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{album.artists && album.artists.length > 0 ? (
|
||||
album.artists.map((artist, index) => (
|
||||
<div key={index} className="flex items-center shrink-0">
|
||||
<span
|
||||
className="hover:text-primary cursor-copy transition-colors hover:underline decoration-primary/50 underline-offset-2"
|
||||
title={`Copy "${artist.name}"`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(artist.name);
|
||||
toast.success(`Copied "${artist.name}"`);
|
||||
}}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
{index < album.artists.length - 1 && (
|
||||
<span className="text-muted-foreground/60 mr-1">,</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span>Unknown Artist</span>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableContent>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[0.65rem] font-medium text-muted-foreground/70 uppercase tracking-widest h-4">
|
||||
{album.releaseDate && (
|
||||
<span>{getYear(album.releaseDate)}</span>
|
||||
)}
|
||||
{formatDuration(album.duration) && (
|
||||
<>
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-border" />
|
||||
<span>{formatDuration(album.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollableContent({ children, className }: { children: React.ReactNode, className?: string }) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollDistance, setScrollDistance] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && contentRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const contentWidth = contentRef.current.offsetWidth;
|
||||
|
||||
if (contentWidth > containerWidth) {
|
||||
const distance = contentWidth - containerWidth;
|
||||
setScrollDistance(distance);
|
||||
setDuration(distance * 0.01); // Adjust speed
|
||||
} else {
|
||||
setScrollDistance(0);
|
||||
}
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("overflow-hidden relative flex items-center w-full", className)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<motion.div
|
||||
ref={contentRef}
|
||||
className="whitespace-nowrap flex items-center"
|
||||
animate={{ x: isHovered && scrollDistance > 0 ? -scrollDistance : 0 }}
|
||||
transition={{
|
||||
duration: duration,
|
||||
ease: "linear",
|
||||
repeat: isHovered ? Infinity : 0,
|
||||
repeatType: "mirror",
|
||||
repeatDelay: 1
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ToolCheckResult } from '@/lib/tool-check';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
|
||||
export function ToolChecker({ children }: { children: React.ReactNode }) {
|
||||
const [result, setResult] = useState<ToolCheckResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [skipped, setSkipped] = useState(false);
|
||||
|
||||
const checkTools = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/system/check-tools');
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to check tools');
|
||||
}
|
||||
const data: ToolCheckResult = await res.json();
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError('Failed to communicate with the server.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkTools();
|
||||
}, []);
|
||||
|
||||
const handleSkip = () => {
|
||||
if (confirm("WARNING: Skipping these checks may cause the application to fail or produce corrupt files. Are you sure you want to proceed without the required tools?")) {
|
||||
setSkipped(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background text-foreground">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-lg font-medium">Checking system prerequisites...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background text-foreground p-4">
|
||||
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-lg p-6 flex flex-col items-center text-center gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold">System Check Failed</h2>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<button
|
||||
onClick={checkTools}
|
||||
className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full px-4 py-2 text-sm text-yellow-500/80 hover:text-yellow-500 hover:bg-yellow-500/10 rounded-md transition-colors"
|
||||
>
|
||||
Skip Checks (Unsafe)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result && !result.allExists && !skipped) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background text-foreground p-4">
|
||||
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-lg p-6 flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold">System Check Failed</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{result.mkbrrPresetExists === false
|
||||
? 'The required "red" preset for mkbrr is missing.'
|
||||
: 'Some required CLI tools are missing from your system. Please install them to continue.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{result.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={`flex items-center justify-between p-3 rounded-md border ${tool.exists
|
||||
? 'bg-muted/50 border-border'
|
||||
: 'bg-red-400/5 border-red-400/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm font-medium">{tool.name}</span>
|
||||
</div>
|
||||
{tool.exists ? (
|
||||
<div className="flex items-center gap-2 text-green-500 text-sm">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>Installed</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Missing</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{result.mkbrrPresetExists === false && (
|
||||
<div className="p-3 rounded-md border bg-red-400/5 border-red-400/20">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm font-medium">mkbrr preset: red</span>
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>Missing</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Please create the preset by following the documentation.
|
||||
</p>
|
||||
<a
|
||||
href="https://mkbrr.com/features/presets"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
View Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={checkTools}
|
||||
className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Recheck Dependencies
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full px-4 py-2 text-sm text-yellow-500/80 hover:text-yellow-500 hover:bg-yellow-500/10 rounded-md transition-colors"
|
||||
>
|
||||
Skip Checks (Unsafe)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-red-400 bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-red-400/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export function AudioWave() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-1 h-32">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
custom={i}
|
||||
variants={{
|
||||
initial: { height: "20%" },
|
||||
animate: (i: number) => ({
|
||||
height: ["20%", "70%"],
|
||||
transition: {
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
type: "spring",
|
||||
repeatType: "reverse",
|
||||
stiffness: Math.random() * 100 + 10,
|
||||
damping: Math.random() * 5 + 10,
|
||||
delay: i * 0.05,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="w-2 bg-primary/50 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-1 h-32 scale-120 absolute top-0 left-0 right-0">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
custom={i}
|
||||
variants={{
|
||||
initial: { height: "20%" },
|
||||
animate: (i: number) => ({
|
||||
height: ["20%", "70%"],
|
||||
transition: {
|
||||
duration: 8,
|
||||
repeat: Infinity,
|
||||
type: "spring",
|
||||
repeatType: "reverse",
|
||||
stiffness: Math.random() * 100 + 10,
|
||||
damping: Math.random() * 5 + 10,
|
||||
delay: i * 0.05,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="w-2 bg-primary/10 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-red-400 text-white [a&]:hover:bg-red-400/90 focus-visible:ring-red-400/20 dark:focus-visible:ring-red-400/40 dark:bg-red-400/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 cursor-pointer",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 cursor-pointer",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
Reference in New Issue
Block a user