mirror of
https://github.com/wahyd4/you-music.git
synced 2026-08-09 05:06:44 +10:00
add db migration
This commit is contained in:
Generated
+61
-7
@@ -10,14 +10,16 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -32,15 +34,15 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.5"
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -1338,6 +1340,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||
@@ -1606,6 +1631,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
|
||||
+22
-20
@@ -10,38 +10,40 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"sonner": "^1.5.0"
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.5"
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import PlaylistDetailPage from './components/playlist/PlaylistDetailPage'
|
||||
import DownloadCenter from './components/download/DownloadCenter'
|
||||
import ArtistsPage from './components/artist/ArtistsPage'
|
||||
import ArtistDetailPage from './components/artist/ArtistDetailPage'
|
||||
import SettingsPage from './components/settings/SettingsPage'
|
||||
import Navigation from './components/Navigation'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
@@ -132,9 +133,10 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && currentMusic) {
|
||||
// Check if it's a streaming URL or a local file
|
||||
// Check if it's a streaming URL
|
||||
const isStreamUrl = currentMusic.file_path.startsWith('/api/stream') || currentMusic.file_path.startsWith('http')
|
||||
audioRef.current.src = isStreamUrl ? currentMusic.file_path : `/music/${currentMusic.file_path}`
|
||||
// Use dedicated file serving endpoint for all local files
|
||||
audioRef.current.src = isStreamUrl ? currentMusic.file_path : `/api/music/file/${currentMusic.id}`
|
||||
if (isPlaying) {
|
||||
audioRef.current.play()
|
||||
}
|
||||
@@ -164,6 +166,7 @@ function App() {
|
||||
<Route path="/playlists" element={<PlaylistsPage onPlayMusic={playMusic} />} />
|
||||
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage onPlayMusic={playMusic} />} />
|
||||
<Route path="/downloads" element={<DownloadCenter onPlayMusic={playMusic} />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const musicApi = {
|
||||
getAll: () => api.get('/music/'),
|
||||
search: (query: string) => api.get('/music/search', { params: { q: query } }),
|
||||
getById: (id: number) => api.get(`/music/${id}`),
|
||||
getDetailInfo: (id: number) => api.get(`/music/${id}/info`),
|
||||
update: (id: number, data: any) => api.put(`/music/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/music/${id}`),
|
||||
upload: (file: File) => {
|
||||
@@ -66,5 +67,13 @@ export const searchApi = {
|
||||
export const artistApi = {
|
||||
getAll: () => api.get('/artists/'),
|
||||
getArtistSongs: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}`),
|
||||
getArtistInfo: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}/info`),
|
||||
getArtistInfo: (artistName: string) => api.get(`/artists/info`, { params: { artist_name: artistName } }),
|
||||
}
|
||||
|
||||
// Settings API
|
||||
export const settingsApi = {
|
||||
get: () => api.get('/settings/'),
|
||||
update: (data: any) => api.put('/settings/', data),
|
||||
scan: () => api.post('/settings/scan'),
|
||||
getScanStatus: () => api.get('/settings/scan-status'),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { musicApi } from '@/api/client'
|
||||
import { Music } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Play } from 'lucide-react'
|
||||
import { Play, AlertCircle, Info } from 'lucide-react'
|
||||
import MusicDetailModal from './music/MusicDetailModal'
|
||||
|
||||
interface MusicLibraryProps {
|
||||
onPlayMusic: (music: Music, playlist: Music[]) => void
|
||||
@@ -11,6 +13,8 @@ interface MusicLibraryProps {
|
||||
|
||||
export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||
const navigate = useNavigate()
|
||||
const [selectedMusicId, setSelectedMusicId] = useState<number | null>(null)
|
||||
|
||||
const { data: musicList = [], isLoading } = useQuery({
|
||||
queryKey: ['music'],
|
||||
queryFn: async () => {
|
||||
@@ -50,13 +54,19 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => onPlayMusic(music, musicList)}
|
||||
disabled={!music.file_exists}
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">{music.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{music.title}</h3>
|
||||
{!music.file_exists && (
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{music.artist && music.artist !== 'Unknown' ? (
|
||||
<button
|
||||
onClick={() => navigate(`/artists/${encodeURIComponent(music.artist!)}`)}
|
||||
@@ -71,12 +81,27 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{music.duration && `${Math.floor(music.duration / 60)}:${String(Math.floor(music.duration % 60)).padStart(2, '0')}`}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{music.duration && `${Math.floor(music.duration / 60)}:${String(Math.floor(music.duration % 60)).padStart(2, '0')}`}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedMusicId(music.id)}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MusicDetailModal
|
||||
musicId={selectedMusicId}
|
||||
open={selectedMusicId !== null}
|
||||
onClose={() => setSelectedMusicId(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Home, Search, ListMusic, Download, Music2, Sun, Moon } from 'lucide-react'
|
||||
import { Home, Search, ListMusic, Download, Music2, Sun, Moon, Settings } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -16,6 +16,7 @@ export default function Navigation({ onToggleTheme, theme }: NavigationProps) {
|
||||
{ path: '/artists', label: 'Artists', icon: Music2 },
|
||||
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ path: '/downloads', label: 'Downloads', icon: Download },
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${artistInfo.image})` }}
|
||||
style={{ backgroundImage: `url(${artistInfo.image.startsWith('http') ? artistInfo.image : `/${artistInfo.image}`})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/70 to-background" />
|
||||
</>
|
||||
@@ -83,12 +83,17 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
|
||||
src={artistInfo.image.startsWith('http') ? artistInfo.image : `/${artistInfo.image}`}
|
||||
alt={decodedArtistName}
|
||||
className="w-48 h-48 rounded-lg shadow-2xl object-cover"
|
||||
onError={(e) => {
|
||||
// Hide broken image and show fallback
|
||||
e.currentTarget.style.display = 'none'
|
||||
const fallback = e.currentTarget.nextElementSibling
|
||||
if (fallback) (fallback as HTMLElement).style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-48 h-48 rounded-lg bg-gradient-to-br from-white/20 to-white/5 backdrop-blur-sm shadow-2xl flex items-center justify-center">
|
||||
<Music2 className="h-20 w-20 text-white/80" />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<div className="w-48 h-48 rounded-lg bg-gradient-to-br from-white/20 to-white/5 backdrop-blur-sm shadow-2xl flex items-center justify-center" style={{ display: artistInfo?.image ? 'none' : 'flex' }}>
|
||||
<Music2 className="h-20 w-20 text-white/80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-white">
|
||||
|
||||
@@ -75,12 +75,16 @@ export default function ArtistsPage() {
|
||||
src={artistImage.startsWith('http') ? artistImage : `/${artistImage}`}
|
||||
alt={artist.name}
|
||||
className="w-32 h-32 mx-auto rounded-full object-cover shadow-lg group-hover:shadow-xl transition-shadow"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
const fallback = e.currentTarget.nextElementSibling
|
||||
if (fallback) (fallback as HTMLElement).style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 mx-auto rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
|
||||
<Music2 className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<div className="w-32 h-32 mx-auto rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center" style={{ display: artistImage ? 'none' : 'flex' }}>
|
||||
<Music2 className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold truncate mb-1">{artist.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { musicApi } from '@/api/client'
|
||||
import { MusicDetailInfo } from '@/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2, FileAudio, HardDrive, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
|
||||
interface MusicDetailModalProps {
|
||||
musicId: number | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function MusicDetailModal({ musicId, open, onClose }: MusicDetailModalProps) {
|
||||
const { data: musicInfo, isLoading } = useQuery<MusicDetailInfo>({
|
||||
queryKey: ['music-detail', musicId],
|
||||
queryFn: async () => {
|
||||
if (!musicId) return null
|
||||
const response = await musicApi.getDetailInfo(musicId)
|
||||
return response.data
|
||||
},
|
||||
enabled: !!musicId && open
|
||||
})
|
||||
|
||||
const formatFileSize = (bytes: number | null | undefined) => {
|
||||
if (!bytes) return '0 B'
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number | null | undefined) => {
|
||||
if (!seconds) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Music Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Detailed information about this music file
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : musicInfo ? (
|
||||
<div className="space-y-6">
|
||||
{/* Thumbnail */}
|
||||
{musicInfo.thumbnail && (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={musicInfo.thumbnail.startsWith('http') ? musicInfo.thumbnail : `/music/${musicInfo.thumbnail}`}
|
||||
alt={musicInfo.title}
|
||||
className="w-48 h-48 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Title</label>
|
||||
<p className="text-base">{musicInfo.title}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Artist</label>
|
||||
<p className="text-base">{musicInfo.artist || 'Unknown'}</p>
|
||||
</div>
|
||||
|
||||
{musicInfo.album && (
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Album</label>
|
||||
<p className="text-base">{musicInfo.album}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Duration</label>
|
||||
<p className="text-base">{formatDuration(musicInfo.duration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<FileAudio className="h-5 w-5" />
|
||||
File Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Format</label>
|
||||
<p className="text-base uppercase">{musicInfo.file_format || 'Unknown'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Size</label>
|
||||
<p className="text-base">{formatFileSize(musicInfo.file_size)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Source</label>
|
||||
<p className="text-base capitalize">{musicInfo.source_type || 'Unknown'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||
File Status
|
||||
</label>
|
||||
<p className="text-base flex items-center gap-2">
|
||||
{musicInfo.file_exists ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span>Available</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
<span>Missing</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
File Path
|
||||
</label>
|
||||
<p className="text-sm font-mono bg-muted p-2 rounded mt-1 break-all">
|
||||
{musicInfo.file_location || 'Not available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Timestamps
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Created</label>
|
||||
<p className="text-base">{new Date(musicInfo.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Updated</label>
|
||||
<p className="text-base">{new Date(musicInfo.updated_at).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
{musicInfo.last_scanned_at && (
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-muted-foreground">Last Scanned</label>
|
||||
<p className="text-base">{new Date(musicInfo.last_scanned_at).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source URL */}
|
||||
{musicInfo.source_url && (
|
||||
<div className="border-t pt-4">
|
||||
<label className="text-sm font-semibold text-muted-foreground">Source URL</label>
|
||||
<a
|
||||
href={musicInfo.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline break-all block mt-1"
|
||||
>
|
||||
{musicInfo.source_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -54,12 +54,12 @@ export default function SearchPage({ onPlayMusic }: SearchPageProps) {
|
||||
// Create temporary music object for streaming playback
|
||||
const streamUrl = `/api/stream?url=${encodeURIComponent(url)}`
|
||||
const tempMusic: Music = {
|
||||
id: 0, // Temporary ID
|
||||
id: 0,
|
||||
title,
|
||||
artist: artist || null,
|
||||
album: null,
|
||||
duration: null,
|
||||
file_path: streamUrl, // Use streaming endpoint
|
||||
file_path: streamUrl,
|
||||
file_size: null,
|
||||
source_url: url,
|
||||
source_type: url.includes('youtube') ? 'youtube' : 'bilibili',
|
||||
@@ -67,6 +67,10 @@ export default function SearchPage({ onPlayMusic }: SearchPageProps) {
|
||||
lyrics: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
file_format: null,
|
||||
file_location: null,
|
||||
file_exists: true,
|
||||
last_scanned_at: null,
|
||||
}
|
||||
|
||||
onPlayMusic(tempMusic)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { Settings, ScanStatus } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, FolderOpen, RefreshCw, CheckCircle, Clock } from 'lucide-react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [localSettings, setLocalSettings] = useState<Partial<Settings>>({})
|
||||
const [scanIntervalHours, setScanIntervalHours] = useState(1)
|
||||
|
||||
const { data: settings, isLoading } = useQuery<Settings>({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await settingsApi.get()
|
||||
return response.data
|
||||
}
|
||||
})
|
||||
|
||||
const { data: scanStatus, refetch: refetchScanStatus } = useQuery<ScanStatus>({
|
||||
queryKey: ['scan-status'],
|
||||
queryFn: async () => {
|
||||
const response = await settingsApi.getScanStatus()
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: (query) => query.state.data?.is_scanning ? 2000 : false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setLocalSettings(settings)
|
||||
setScanIntervalHours(settings.scan_interval / 3600)
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: Partial<Settings>) => {
|
||||
const response = await settingsApi.update(data)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success('Settings updated successfully')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update settings')
|
||||
}
|
||||
})
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await settingsApi.scan()
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Scan started')
|
||||
refetchScanStatus()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to start scan')
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({
|
||||
...localSettings,
|
||||
scan_interval: scanIntervalHours * 3600
|
||||
})
|
||||
}
|
||||
|
||||
const handleScanNow = () => {
|
||||
scanMutation.mutate()
|
||||
}
|
||||
|
||||
const formatInterval = (hours: number) => {
|
||||
if (hours < 1) return `${Math.round(hours * 60)} minutes`
|
||||
if (hours === 1) return '1 hour'
|
||||
if (hours < 24) return `${hours} hours`
|
||||
return `${hours / 24} day${hours / 24 > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
// Format file size helper (keeping for potential future use)
|
||||
// const formatFileSize = (bytes: number | null | undefined) => {
|
||||
// if (!bytes) return '0 B'
|
||||
// const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
// const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
// return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`
|
||||
// }
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
<h1 className="text-3xl font-bold mb-6">Settings</h1>
|
||||
|
||||
{/* Music Directory Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Music Directories</CardTitle>
|
||||
<CardDescription>
|
||||
Configure where your music files are stored
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="local-music-dir" className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Local Music Directory
|
||||
</Label>
|
||||
<Input
|
||||
id="local-music-dir"
|
||||
type="text"
|
||||
placeholder="/path/to/your/music"
|
||||
value={localSettings.local_music_dir || ''}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, local_music_dir: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Additional directory to scan for music files (optional)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scanner Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Automatic Scanning</CardTitle>
|
||||
<CardDescription>
|
||||
Configure how often to scan for music files
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="auto-scan">Enable Auto-Scan</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically scan for new music files
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-scan"
|
||||
checked={localSettings.auto_scan_enabled || false}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setLocalSettings({ ...localSettings, auto_scan_enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="scan-interval">
|
||||
Scan Interval: {formatInterval(scanIntervalHours)}
|
||||
</Label>
|
||||
<Slider
|
||||
id="scan-interval"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={[scanIntervalHours]}
|
||||
onValueChange={([value]) => setScanIntervalHours(value)}
|
||||
className="mt-2"
|
||||
disabled={!localSettings.auto_scan_enabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>30 min</span>
|
||||
<span>24 hours</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="delete-missing">Delete Missing Files</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove database entries for missing files
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="delete-missing"
|
||||
checked={localSettings.delete_missing_files || false}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setLocalSettings({ ...localSettings, delete_missing_files: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings?.last_scan_at && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
Last scan: {new Date(settings.last_scan_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scan Status */}
|
||||
{scanStatus && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Scan Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{scanStatus.is_scanning ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Scanning in progress...</span>
|
||||
</div>
|
||||
{scanStatus.total && scanStatus.total > 0 && (
|
||||
<div>
|
||||
<Progress value={(scanStatus.progress || 0) / scanStatus.total * 100} />
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{scanStatus.progress} / {scanStatus.total} files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{scanStatus.current_file && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current: {scanStatus.current_file}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
{scanStatus.completed_at && (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Added:</span>{' '}
|
||||
<span className="font-semibold">{scanStatus.files_added}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Updated:</span>{' '}
|
||||
<span className="font-semibold">{scanStatus.files_updated}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Missing:</span>{' '}
|
||||
<span className="font-semibold">{scanStatus.files_missing}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Errors:</span>{' '}
|
||||
<span className="font-semibold">{scanStatus.errors.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scanStatus.errors.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-semibold text-red-600 mb-2">Errors:</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{scanStatus.errors.map((error, i) => (
|
||||
<p key={i} className="text-xs text-red-600">{error}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleScanNow}
|
||||
disabled={scanMutation.isPending || scanStatus?.is_scanning}
|
||||
>
|
||||
{(scanMutation.isPending || scanStatus?.is_scanning) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Scan Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,120 @@
|
||||
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 max-h-[90vh] overflow-y-auto",
|
||||
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,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { value?: number }
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -6,6 +6,10 @@ export interface Music {
|
||||
duration: number | null
|
||||
file_path: string
|
||||
file_size: number | null
|
||||
file_format: string | null
|
||||
file_location: string | null
|
||||
file_exists: boolean
|
||||
last_scanned_at: string | null
|
||||
source_url: string | null
|
||||
source_type: string | null
|
||||
thumbnail: string | null
|
||||
@@ -14,6 +18,31 @@ export interface Music {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MusicDetailInfo extends Music {
|
||||
// Already includes all fields from Music
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
local_music_dir: string | null
|
||||
scan_interval: number
|
||||
delete_missing_files: boolean
|
||||
auto_scan_enabled: boolean
|
||||
last_scan_at: string | null
|
||||
}
|
||||
|
||||
export interface ScanStatus {
|
||||
is_scanning: boolean
|
||||
progress: number | null
|
||||
total: number | null
|
||||
current_file: string | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
files_added: number
|
||||
files_updated: number
|
||||
files_missing: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: number
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user