From c7fbfcd732c38cbd1b5f421cbab912d1ba79e944 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Fri, 31 Oct 2025 21:58:47 +1100 Subject: [PATCH] UI updates --- frontend/src/App.tsx | 55 +++++---- frontend/src/api/client.ts | 4 +- frontend/src/components/HomePage.tsx | 2 +- frontend/src/components/MusicLibrary.tsx | 106 ++++++++++++------ frontend/src/components/Navigation.tsx | 25 +---- .../components/artist/ArtistDetailPage.tsx | 20 ++-- .../src/components/music/MusicDetailModal.tsx | 53 ++++++++- .../src/components/settings/SettingsPage.tsx | 45 +++++++- 8 files changed, 209 insertions(+), 101 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f75a21e..6c4309c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,22 +18,30 @@ function App() { const [currentMusic, setCurrentMusic] = useState(null) const [isPlaying, setIsPlaying] = useState(false) const [playlist, setPlaylist] = useState([]) - 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(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() { {/* Public share route - no navigation or player */} } /> - + {/* Main app routes */} - - + +
} /> @@ -173,7 +184,7 @@ function App() { } /> } /> } /> - } /> + } />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a53b1b4..68f2b27 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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}`), diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index cffc479..c6822b3 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -7,7 +7,7 @@ interface HomePageProps { export default function HomePage({ onPlayMusic }: HomePageProps) { return ( -
+
) diff --git a/frontend/src/components/MusicLibrary.tsx b/frontend/src/components/MusicLibrary.tsx index 6b11f5d..39470d3 100644 --- a/frontend/src/components/MusicLibrary.tsx +++ b/frontend/src/components/MusicLibrary.tsx @@ -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('list') const [sortBy, setSortBy] = useState('created_at') const [sortOrder, setSortOrder] = useState('desc') - - const { data: musicList = [], isLoading } = useQuery({ + const loadMoreRef = useRef(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 ( -
-
+
+
-

Your Library

{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 && ( • {filteredMusic.length} shown )} @@ -97,7 +134,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) { Duration - + - +

- -
+ +
) : layoutMode === 'list' ? ( -
+
{filteredMusic.map((music: Music) => (
music.file_exists && onPlayMusic(music, filteredMusic)} > {music.thumbnail ? (
)} - -
- -
- +

{music.title}

@@ -190,7 +217,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {

)}
- +
{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) {
)}
- +

{music.title}

{music.artist && music.artist !== 'Unknown' ? ( @@ -273,8 +300,17 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) { ))}
)} - - + {isFetchingNextPage && ( + + )} +
+ )} + + setSelectedMusicId(null)} diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index 13fda8f..396141b 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -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) { {label} ))} - - {/* Theme Toggle */} -
diff --git a/frontend/src/components/artist/ArtistDetailPage.tsx b/frontend/src/components/artist/ArtistDetailPage.tsx index 27951e1..94f3a2e 100644 --- a/frontend/src/components/artist/ArtistDetailPage.tsx +++ b/frontend/src/components/artist/ArtistDetailPage.tsx @@ -266,19 +266,19 @@ export default function ArtistDetailPage({ onPlayMusic }: ArtistDetailProps) { )}
+ +
{formatDuration(song.duration || 0)}
- -
))}
diff --git a/frontend/src/components/music/MusicDetailModal.tsx b/frontend/src/components/music/MusicDetailModal.tsx index 8500ce3..46778c6 100644 --- a/frontend/src/components/music/MusicDetailModal.tsx +++ b/frontend/src/components/music/MusicDetailModal.tsx @@ -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 ( - Music Details - - Detailed information about this music file - +
+
+ Music Details + + Detailed information about this music file + +
+ {musicInfo?.file_exists && ( + + )} +
{isLoading ? ( diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index d3f6976..81d1e05 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -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>({}) const [scanIntervalHours, setScanIntervalHours] = useState(1) @@ -130,8 +135,9 @@ export default function SettingsPage() {

Settings

- + General + Appearance API Keys @@ -429,6 +435,39 @@ export default function SettingsPage() {
+ + {/* Theme Settings */} + + + + + Theme + + + Customize the appearance of the application + + + +
+
+ +

+ {theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} +

+
+ +
+
+
+
+