UI updates

This commit is contained in:
2025-10-31 22:03:02 +11:00
parent 308026f405
commit c7fbfcd732
8 changed files with 209 additions and 101 deletions
+33 -22
View File
@@ -18,22 +18,30 @@ function App() {
const [currentMusic, setCurrentMusic] = useState<Music | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [playlist, setPlaylist] = useState<Music[]>([])
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
// Initialize from localStorage or system preference
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
if (savedTheme) {
return savedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
const [playMode, setPlayMode] = useState<'loop' | 'shuffle' | 'repeat-one'>('loop')
const audioRef = useRef<HTMLAudioElement>(null)
const location = useLocation()
// Load theme from localStorage on mount
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
if (savedTheme) {
setTheme(savedTheme)
} else {
// Default to system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setTheme(prefersDark ? 'dark' : 'light')
}
}, [])
// Load theme from localStorage on mount - NO LONGER NEEDED, handled in initial state
// useEffect(() => {
// const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
// console.log('Loading theme from localStorage:', savedTheme)
// if (savedTheme) {
// setTheme(savedTheme)
// } else {
// // Default to system preference
// const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
// setTheme(prefersDark ? 'dark' : 'light')
// }
// }, [])
// Load play mode from localStorage
useEffect(() => {
@@ -45,12 +53,14 @@ function App() {
// Apply theme to document
useEffect(() => {
console.log('Applying theme:', theme)
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
localStorage.setItem('theme', theme)
console.log('Theme saved to localStorage:', theme)
}, [theme])
// Save play mode to localStorage
@@ -58,8 +68,9 @@ function App() {
localStorage.setItem('playMode', playMode)
}, [playMode])
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
const handleThemeChange = (isDark: boolean) => {
console.log('handleThemeChange called with:', isDark)
setTheme(isDark ? 'dark' : 'light')
}
const togglePlayMode = () => {
@@ -93,7 +104,7 @@ function App() {
if (!currentMusic || playlist.length === 0) return
const currentIndex = playlist.findIndex(m => m.id === currentMusic.id)
if (playMode === 'repeat-one') {
// Repeat current song
if (audioRef.current) {
@@ -102,20 +113,20 @@ function App() {
}
return
}
if (playMode === 'shuffle') {
// Pick a random song (not the current one)
const availableIndexes = playlist
.map((_, idx) => idx)
.filter(idx => idx !== currentIndex)
if (availableIndexes.length > 0) {
const randomIndex = availableIndexes[Math.floor(Math.random() * availableIndexes.length)]
playMusic(playlist[randomIndex], playlist)
}
return
}
// Loop mode (default)
if (currentIndex >= 0) {
const nextIndex = currentIndex < playlist.length - 1 ? currentIndex + 1 : 0
@@ -158,12 +169,12 @@ function App() {
<Routes>
{/* Public share route - no navigation or player */}
<Route path="/share/:token" element={<SharePlayer />} />
{/* Main app routes */}
<Route path="/*" element={
<div className="flex flex-col h-screen overflow-hidden bg-background">
<Navigation onToggleTheme={toggleTheme} theme={theme} />
<Navigation />
<main className="flex-1 overflow-y-auto overflow-x-hidden pb-24 md:pb-28">
<Routes>
<Route path="/" element={<HomePage onPlayMusic={playMusic} />} />
@@ -173,7 +184,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 />} />
<Route path="/settings" element={<SettingsPage theme={theme} onThemeChange={handleThemeChange} />} />
</Routes>
</main>
+2 -2
View File
@@ -11,8 +11,8 @@ export default api
// Music API
export const musicApi = {
getAll: (sortBy?: string, sortOrder?: string) =>
api.get('/music/', { params: { sort_by: sortBy, sort_order: sortOrder } }),
getAll: (sortBy?: string, sortOrder?: string, skip?: number, limit?: number) =>
api.get('/music/', { params: { sort_by: sortBy, sort_order: sortOrder, skip, limit } }),
getStats: () => api.get('/music/stats'),
search: (query: string) => api.get('/music/search', { params: { q: query } }),
getById: (id: number) => api.get(`/music/${id}`),
+1 -1
View File
@@ -7,7 +7,7 @@ interface HomePageProps {
export default function HomePage({ onPlayMusic }: HomePageProps) {
return (
<div className="max-w-screen-xl mx-auto p-4">
<div className="max-w-screen-xl mx-auto p-0 md:p-4">
<MusicLibrary onPlayMusic={onPlayMusic} />
</div>
)
+71 -35
View File
@@ -1,11 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useState, useMemo } from 'react'
import { useState, useMemo, useRef, useEffect } from 'react'
import { musicApi } from '@/api/client'
import { Music } from '@/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Play, AlertCircle, Info, Search, LayoutList, LayoutGrid, ArrowUpDown } from 'lucide-react'
import { Play, AlertCircle, Info, Search, LayoutList, LayoutGrid, ArrowUpDown, Loader2 } from 'lucide-react'
import MusicDetailModal from './music/MusicDetailModal'
import {
Select,
@@ -28,15 +28,30 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
const [layoutMode, setLayoutMode] = useState<LayoutMode>('list')
const [sortBy, setSortBy] = useState('created_at')
const [sortOrder, setSortOrder] = useState('desc')
const { data: musicList = [], isLoading } = useQuery({
const loadMoreRef = useRef<HTMLDivElement>(null)
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useInfiniteQuery({
queryKey: ['music', sortBy, sortOrder],
queryFn: async () => {
const response = await musicApi.getAll(sortBy, sortOrder)
queryFn: async ({ pageParam = 0 }) => {
const response = await musicApi.getAll(sortBy, sortOrder, pageParam, 50)
return response.data
},
getNextPageParam: (lastPage, allPages) => {
return lastPage.length === 50 ? allPages.length * 50 : undefined
},
initialPageParam: 0,
})
const musicList = useMemo(() => {
return data?.pages.flatMap(page => page) ?? []
}, [data])
const { data: stats } = useQuery({
queryKey: ['music-stats'],
queryFn: async () => {
@@ -45,11 +60,34 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
},
})
// Infinite scroll observer
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ threshold: 0.1 }
)
const currentRef = loadMoreRef.current
if (currentRef) {
observer.observe(currentRef)
}
return () => {
if (currentRef) {
observer.unobserve(currentRef)
}
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const filteredMusic = useMemo(() => {
if (!filterText.trim()) return musicList
const searchTerm = filterText.toLowerCase()
return musicList.filter((music: Music) =>
return musicList.filter((music: Music) =>
music.title.toLowerCase().includes(searchTerm) ||
music.artist?.toLowerCase().includes(searchTerm) ||
music.album?.toLowerCase().includes(searchTerm)
@@ -61,14 +99,13 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
}
return (
<div className="max-w-screen-xl mx-auto p-4">
<div className="flex items-center justify-between mb-4">
<div className="max-w-screen-xl mx-auto p-2 md:p-4">
<div className="flex items-center justify-between mb-2 md:mb-4">
<div>
<h2 className="text-2xl font-bold">Your Library</h2>
<p className="text-sm text-muted-foreground mt-1">
{stats?.total_songs !== undefined ? (
<>
{stats.total_songs} {stats.total_songs === 1 ? 'song' : 'songs'} total
{stats.total_songs} {stats.total_songs === 1 ? 'song' : 'songs'}
{filterText && filteredMusic.length !== stats.total_songs && (
<span> {filteredMusic.length} shown</span>
)}
@@ -97,7 +134,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
<SelectItem value="duration">Duration</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
@@ -105,7 +142,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
>
<ArrowUpDown className="h-4 w-4" />
</Button>
<div className="flex gap-1">
<Button
size="icon"
@@ -124,8 +161,8 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
</div>
</div>
</div>
<div className="relative mb-4">
<div className="relative mb-2 md:mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
@@ -141,11 +178,12 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
No music found matching "{filterText}"
</div>
) : layoutMode === 'list' ? (
<div className="grid grid-cols-1 gap-2">
<div className="grid grid-cols-1 gap-1 md:gap-2">
{filteredMusic.map((music: Music) => (
<div
key={music.id}
className="flex items-center gap-4 p-3 rounded-lg hover:bg-accent transition-colors"
className="flex items-center gap-2 md:gap-4 p-2 md:p-3 rounded-lg hover:bg-accent transition-colors cursor-pointer"
onClick={() => music.file_exists && onPlayMusic(music, filteredMusic)}
>
{music.thumbnail ? (
<img
@@ -158,18 +196,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
<Play className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div className="flex-shrink-0">
<Button
size="icon"
variant="ghost"
onClick={() => onPlayMusic(music, filteredMusic)}
disabled={!music.file_exists}
>
<Play className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{music.title}</h3>
@@ -190,7 +217,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
</p>
)}
</div>
<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')}`}
@@ -248,7 +275,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
</div>
)}
</div>
<div className="min-w-0">
<h3 className="font-medium truncate text-sm">{music.title}</h3>
{music.artist && music.artist !== 'Unknown' ? (
@@ -273,8 +300,17 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
))}
</div>
)}
<MusicDetailModal
{/* Infinite scroll loader */}
{!filterText && (
<div ref={loadMoreRef} className="flex justify-center py-8">
{isFetchingNextPage && (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
)}
</div>
)}
<MusicDetailModal
musicId={selectedMusicId}
open={selectedMusicId !== null}
onClose={() => setSelectedMusicId(null)}
+2 -23
View File
@@ -1,13 +1,7 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, Search, ListMusic, Download, Music2, Sun, Moon, Settings } from 'lucide-react'
import { Button } from './ui/button'
import { Home, Search, ListMusic, Download, Music2, Settings } from 'lucide-react'
interface NavigationProps {
onToggleTheme: () => void
theme: 'light' | 'dark'
}
export default function Navigation({ onToggleTheme, theme }: NavigationProps) {
export default function Navigation() {
const location = useLocation()
const navItems = [
@@ -40,21 +34,6 @@ export default function Navigation({ onToggleTheme, theme }: NavigationProps) {
<span className="hidden md:inline">{label}</span>
</Link>
))}
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
onClick={onToggleTheme}
className="ml-2"
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
@@ -266,19 +266,19 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) {
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleAutoDownload(song)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
title="Download this song"
>
<Download className="h-4 w-4" />
</Button>
<div className="text-sm text-muted-foreground">
{formatDuration(song.duration || 0)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleAutoDownload(song)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</div>
))}
</div>
@@ -8,7 +8,9 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2, FileAudio, HardDrive, Clock, CheckCircle, XCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Loader2, FileAudio, HardDrive, Clock, CheckCircle, XCircle, Download } from 'lucide-react'
import { toast } from 'sonner'
interface MusicDetailModalProps {
musicId: number | null
@@ -41,14 +43,55 @@ export default function MusicDetailModal({ musicId, open, onClose }: MusicDetail
return `${mins}:${String(secs).padStart(2, '0')}`
}
const handleDownload = async () => {
if (!musicInfo) return
try {
const response = await fetch(`/api/music/file/${musicInfo.id}`)
if (!response.ok) {
throw new Error('Download failed')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${musicInfo.artist ? musicInfo.artist + ' - ' : ''}${musicInfo.title}.${musicInfo.file_format || 'mp3'}`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.success('Download started!')
} catch (error) {
toast.error('Failed to download file')
}
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Music Details</DialogTitle>
<DialogDescription>
Detailed information about this music file
</DialogDescription>
<div className="flex items-center justify-between">
<div>
<DialogTitle>Music Details</DialogTitle>
<DialogDescription>
Detailed information about this music file
</DialogDescription>
</div>
{musicInfo?.file_exists && (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
Download
</Button>
)}
</div>
</DialogHeader>
{isLoading ? (
@@ -10,11 +10,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Slider } from '@/components/ui/slider'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toast } from 'sonner'
import { Loader2, FolderOpen, RefreshCw, CheckCircle, Clock, History } from 'lucide-react'
import { Loader2, FolderOpen, RefreshCw, CheckCircle, Clock, History, Sun, Moon, Palette } from 'lucide-react'
import { Progress } from '@/components/ui/progress'
import APIKeysManagement from './APIKeysManagement'
export default function SettingsPage() {
interface SettingsPageProps {
theme: 'light' | 'dark'
onThemeChange: (isDark: boolean) => void
}
export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps) {
const queryClient = useQueryClient()
const [localSettings, setLocalSettings] = useState<Partial<Settings>>({})
const [scanIntervalHours, setScanIntervalHours] = useState(1)
@@ -130,8 +135,9 @@ export default function SettingsPage() {
<h1 className="text-3xl font-bold mb-6">Settings</h1>
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="appearance">Appearance</TabsTrigger>
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
</TabsList>
@@ -429,6 +435,39 @@ export default function SettingsPage() {
</div>
</TabsContent>
<TabsContent value="appearance" className="space-y-6">
{/* Theme Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Theme
</CardTitle>
<CardDescription>
Customize the appearance of the application
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="dark-mode" className="flex items-center gap-2">
{theme === 'dark' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
Dark Mode
</Label>
<p className="text-sm text-muted-foreground mt-1">
{theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
</p>
</div>
<Switch
id="dark-mode"
checked={theme === 'dark'}
onCheckedChange={onThemeChange}
/>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="api-keys">
<APIKeysManagement />
</TabsContent>