diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..1537d50 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae6202c..99b29a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,13 @@ "name": "youmusic-frontend", "version": "1.0.0", "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@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-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", @@ -26,7 +27,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", "sonner": "^1.5.0", - "tailwind-merge": "^2.5.2", + "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7" }, "devDependencies": { @@ -1096,6 +1097,34 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "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-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.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-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 90269e8..814be6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,13 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@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-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", @@ -28,7 +29,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", "sonner": "^1.5.0", - "tailwind-merge": "^2.5.2", + "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1363dde..0c7f2e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -161,7 +161,7 @@ function App() {
- } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index c6822b3..75d0068 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -3,12 +3,18 @@ import MusicLibrary from './MusicLibrary' interface HomePageProps { onPlayMusic: (music: Music, playlist: Music[]) => void + currentMusic?: Music | null + onPlayNext?: () => void } -export default function HomePage({ onPlayMusic }: HomePageProps) { +export default function HomePage({ onPlayMusic, currentMusic, onPlayNext }: HomePageProps) { return (
- +
) } diff --git a/frontend/src/components/MusicLibrary.tsx b/frontend/src/components/MusicLibrary.tsx index 39470d3..de98263 100644 --- a/frontend/src/components/MusicLibrary.tsx +++ b/frontend/src/components/MusicLibrary.tsx @@ -19,9 +19,11 @@ type LayoutMode = 'list' | 'grid' interface MusicLibraryProps { onPlayMusic: (music: Music, playlist: Music[]) => void + currentMusic?: Music | null + onPlayNext?: () => void } -export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) { +export default function MusicLibrary({ onPlayMusic, currentMusic, onPlayNext }: MusicLibraryProps) { const navigate = useNavigate() const [selectedMusicId, setSelectedMusicId] = useState(null) const [filterText, setFilterText] = useState('') @@ -314,6 +316,8 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) { musicId={selectedMusicId} open={selectedMusicId !== null} onClose={() => setSelectedMusicId(null)} + currentPlayingId={currentMusic?.id} + onPlayNext={onPlayNext} /> ) diff --git a/frontend/src/components/music/MusicDetailModal.tsx b/frontend/src/components/music/MusicDetailModal.tsx index 46778c6..669aa8d 100644 --- a/frontend/src/components/music/MusicDetailModal.tsx +++ b/frontend/src/components/music/MusicDetailModal.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { musicApi } from '@/api/client' import { MusicDetailInfo } from '@/types' import { @@ -8,17 +8,39 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' -import { Loader2, FileAudio, HardDrive, Clock, CheckCircle, XCircle, Download } from 'lucide-react' +import { Loader2, FileAudio, HardDrive, Clock, CheckCircle, XCircle, Download, Trash2 } from 'lucide-react' import { toast } from 'sonner' +import { useState } from 'react' interface MusicDetailModalProps { musicId: number | null open: boolean onClose: () => void + currentPlayingId?: number | null + onPlayNext?: () => void } -export default function MusicDetailModal({ musicId, open, onClose }: MusicDetailModalProps) { +export default function MusicDetailModal({ + musicId, + open, + onClose, + currentPlayingId, + onPlayNext +}: MusicDetailModalProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const queryClient = useQueryClient() + const { data: musicInfo, isLoading } = useQuery({ queryKey: ['music-detail', musicId], queryFn: async () => { @@ -29,6 +51,27 @@ export default function MusicDetailModal({ musicId, open, onClose }: MusicDetail enabled: !!musicId && open }) + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + await musicApi.delete(id) + }, + onSuccess: (_, deletedId) => { + toast.success('Music deleted successfully') + + // If the deleted song is currently playing, play next + if (currentPlayingId === deletedId && onPlayNext) { + onPlayNext() + } + + queryClient.invalidateQueries({ queryKey: ['music'] }) + queryClient.invalidateQueries({ queryKey: ['playlists'] }) + onClose() + }, + onError: () => { + toast.error('Failed to delete music') + } + }) + const formatFileSize = (bytes: number | null | undefined) => { if (!bytes) return '0 B' const sizes = ['B', 'KB', 'MB', 'GB'] @@ -69,6 +112,12 @@ export default function MusicDetailModal({ musicId, open, onClose }: MusicDetail } } + const handleDelete = () => { + if (!musicInfo) return + deleteMutation.mutate(musicInfo.id) + setShowDeleteConfirm(false) + } + return ( @@ -80,17 +129,29 @@ export default function MusicDetailModal({ musicId, open, onClose }: MusicDetail Detailed information about this music file - {musicInfo?.file_exists && ( +
+ {musicInfo?.file_exists && ( + + )} - )} +
@@ -234,6 +295,27 @@ export default function MusicDetailModal({ musicId, open, onClose }: MusicDetail ) : null}
+ + {/* Delete Confirmation Dialog */} + + + + Delete Music File + + Are you sure you want to delete "{musicInfo?.title}"? This will permanently remove the music file from your library and cannot be undone. + + + + Cancel + + Delete + + + +
) } diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..fa2b442 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 208363e..65d4fcd 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,25 +1,30 @@ 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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", }, }, defaultVariants: { diff --git a/frontend/src/index.css b/frontend/src/index.css index 052e619..08ea249 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,47 +5,57 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --foreground: 0 0% 3.9%; --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 221.2 83.2% 53.3%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 224.3 76.3% 48%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; } } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 1699111..2c653c8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { type ClassValue, clsx } from "clsx" +import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { @@ -6,7 +6,8 @@ export function cn(...inputs: ClassValue[]) { } export function formatDuration(seconds: number): string { + if (!seconds || seconds < 0) return '0:00' const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) - return `${mins}:${secs.toString().padStart(2, '0')}` + return `${mins}:${String(secs).padStart(2, '0')}` } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 208aed3..25e4c02 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -5,69 +5,84 @@ export default { "./src/**/*.{js,ts,jsx,tsx}", ], theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } }, plugins: [require("tailwindcss-animate")], }