From 5c1d6619b544977a54bc34e07b319a3dcd209671 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Sat, 13 Dec 2025 11:43:17 +0700 Subject: [PATCH] v6.8 --- app.go | 100 +- backend/amazon.go | 19 +- backend/deezer.go | 60 +- backend/ffmpeg.go | 544 ++++++++++ backend/file_dialog.go | 52 + backend/lyrics.go | 64 +- backend/metadata.go | 261 +++++ backend/qobuz.go | 36 +- backend/songlink.go | 39 +- backend/tidal.go | 9 +- frontend/package.json | 16 +- frontend/package.json.md5 | 2 +- frontend/pnpm-lock.yaml | 988 +++++++++--------- frontend/src/App.tsx | 11 +- frontend/src/components/AlbumInfo.tsx | 22 +- frontend/src/components/ArtistInfo.tsx | 23 +- frontend/src/components/AudioAnalysisPage.tsx | 34 +- .../src/components/AudioConverterPage.tsx | 629 +++++++++++ frontend/src/components/DebugLogger.tsx | 132 --- frontend/src/components/PlaylistInfo.tsx | 22 +- frontend/src/components/Settings.tsx | 457 -------- frontend/src/components/SettingsPage.tsx | 93 +- frontend/src/components/Sidebar.tsx | 5 +- frontend/src/components/ui/toggle-group.tsx | 83 ++ frontend/src/components/ui/toggle.tsx | 47 + frontend/src/hooks/useDownload.ts | 26 +- frontend/src/hooks/useLyrics.ts | 126 ++- frontend/src/index.css | 39 - frontend/src/lib/settings.ts | 2 + frontend/src/lib/themes.ts | 544 ++++++++-- frontend/src/types/api.ts | 1 + go.mod | 2 + go.sum | 25 + tidal.json | 10 +- version.json | 3 - wails.json | 2 +- 36 files changed, 3174 insertions(+), 1354 deletions(-) create mode 100644 backend/ffmpeg.go create mode 100644 backend/file_dialog.go create mode 100644 frontend/src/components/AudioConverterPage.tsx delete mode 100644 frontend/src/components/DebugLogger.tsx delete mode 100644 frontend/src/components/Settings.tsx create mode 100644 frontend/src/components/ui/toggle-group.tsx create mode 100644 frontend/src/components/ui/toggle.tsx delete mode 100644 version.json diff --git a/app.go b/app.go index e540274..31c98f9 100644 --- a/app.go +++ b/app.go @@ -51,6 +51,7 @@ type DownloadRequest struct { Position int `json:"position,omitempty"` // Position in playlist/album (1-based) UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID + EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking @@ -299,8 +300,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { filename = strings.TrimPrefix(filename, "EXISTS:") } - // Embed lyrics after successful download (only for new downloads with Spotify ID) - if !alreadyExists && req.SpotifyID != "" && strings.HasSuffix(filename, ".flac") { + // Embed lyrics after successful download (only for new downloads with Spotify ID and if embedLyrics is enabled) + if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") { go func(filePath, spotifyID, trackName, artistName string) { fmt.Printf("\n========== LYRICS FETCH START ==========\n") fmt.Printf("Spotify ID: %s\n", spotifyID) @@ -313,24 +314,24 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { // Try all sources with fallbacks lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName) if err != nil { - fmt.Printf("❌ All sources failed: %v\n", err) + fmt.Printf("All sources failed: %v\n", err) fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") return } if lyricsResp == nil || len(lyricsResp.Lines) == 0 { - fmt.Println("❌ No lyrics content found") + fmt.Println("No lyrics content found") fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") return } - fmt.Printf("✓ Lyrics found from: %s\n", source) - fmt.Printf("✓ Sync type: %s\n", lyricsResp.SyncType) - fmt.Printf("✓ Total lines: %d\n", len(lyricsResp.Lines)) + fmt.Printf("Lyrics found from: %s\n", source) + fmt.Printf("Sync type: %s\n", lyricsResp.SyncType) + fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines)) lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName) if lyrics == "" { - fmt.Println("❌ No lyrics content to embed") + fmt.Println("No lyrics content to embed") fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") return } @@ -342,10 +343,10 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { fmt.Printf("Embedding into: %s\n", filePath) if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil { - fmt.Printf("❌ Failed to embed lyrics: %v\n", err) + fmt.Printf("Failed to embed lyrics: %v\n", err) fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") } else { - fmt.Printf("✓ Lyrics embedded successfully!\n") + fmt.Printf("Lyrics embedded successfully!\n") fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n") } }(filename, req.SpotifyID, req.TrackName, req.ArtistName) @@ -598,3 +599,82 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string return string(jsonData), nil } + +// IsFFmpegInstalled checks if ffmpeg is installed +func (a *App) IsFFmpegInstalled() (bool, error) { + return backend.IsFFmpegInstalled() +} + +// GetFFmpegPath returns the path to ffmpeg +func (a *App) GetFFmpegPath() (string, error) { + return backend.GetFFmpegPath() +} + +// DownloadFFmpegRequest represents a request to download ffmpeg +type DownloadFFmpegRequest struct{} + +// DownloadFFmpegResponse represents the response from downloading ffmpeg +type DownloadFFmpegResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} + +// DownloadFFmpeg downloads and installs ffmpeg +func (a *App) DownloadFFmpeg() DownloadFFmpegResponse { + err := backend.DownloadFFmpeg(func(progress int) { + fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress) + }) + if err != nil { + return DownloadFFmpegResponse{ + Success: false, + Error: err.Error(), + } + } + + return DownloadFFmpegResponse{ + Success: true, + Message: "FFmpeg installed successfully", + } +} + +// InstallFFmpegFromFile installs ffmpeg from a local file path +func (a *App) InstallFFmpegFromFile(filePath string) DownloadFFmpegResponse { + err := backend.InstallFFmpegFromFile(filePath) + if err != nil { + return DownloadFFmpegResponse{ + Success: false, + Error: err.Error(), + } + } + return DownloadFFmpegResponse{ + Success: true, + Message: "FFmpeg installed successfully from file", + } +} + +// ConvertAudioRequest represents a request to convert audio files +type ConvertAudioRequest struct { + InputFiles []string `json:"input_files"` + OutputFormat string `json:"output_format"` + Bitrate string `json:"bitrate"` +} + +// ConvertAudio converts audio files using ffmpeg +func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResult, error) { + backendReq := backend.ConvertAudioRequest{ + InputFiles: req.InputFiles, + OutputFormat: req.OutputFormat, + Bitrate: req.Bitrate, + } + return backend.ConvertAudio(backendReq) +} + +// SelectAudioFiles opens a file dialog to select audio files for conversion +func (a *App) SelectAudioFiles() ([]string, error) { + files, err := backend.SelectMultipleFiles(a.ctx) + if err != nil { + return nil, err + } + return files, nil +} diff --git a/backend/amazon.go b/backend/amazon.go index 991d361..74bb27d 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -142,9 +142,24 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin } defer resp.Body.Close() + // Read body first to handle encoding issues and provide better error messages + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return "", fmt.Errorf("API returned empty response") + } + var songLinkResp SongLinkResponse - if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) + if err := json.Unmarshal(body, &songLinkResp); err != nil { + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"] diff --git a/backend/deezer.go b/backend/deezer.go index 01f0bf4..2211ba0 100644 --- a/backend/deezer.go +++ b/backend/deezer.go @@ -82,13 +82,28 @@ func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (strin return "", fmt.Errorf("API returned status %d", resp.StatusCode) } + // Read body first to handle encoding issues + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return "", fmt.Errorf("API returned empty response") + } + var songLinkResp struct { LinksByPlatform map[string]struct { URL string `json:"url"` } `json:"linksByPlatform"` } - if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) + if err := json.Unmarshal(body, &songLinkResp); err != nil { + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } deezerLink, ok := songLinkResp.LinksByPlatform["deezer"] @@ -134,12 +149,28 @@ func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Read body first to handle encoding issues and provide better error messages + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return nil, fmt.Errorf("API returned empty response") } var track DeezerTrack - if err := json.NewDecoder(resp.Body).Decode(&track); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := json.Unmarshal(body, &track); err != nil { + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } if track.ID == 0 { @@ -160,9 +191,24 @@ func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) { } defer resp.Body.Close() + // Read body first to handle encoding issues and provide better error messages + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return "", fmt.Errorf("API returned empty response") + } + var apiResp DeezMateResponse - if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { - return "", fmt.Errorf("failed to decode API response: %w", err) + if err := json.Unmarshal(body, &apiResp); err != nil { + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return "", fmt.Errorf("failed to decode API response: %w (response: %s)", err, bodyStr) } if !apiResp.Success || apiResp.Links.FLAC == "" { diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go new file mode 100644 index 0000000..39401ff --- /dev/null +++ b/backend/ffmpeg.go @@ -0,0 +1,544 @@ +package backend + +import ( + "archive/tar" + "archive/zip" + "encoding/base64" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/ulikunitz/xz" +) + +// decodeBase64 decodes a base64 encoded string +func decodeBase64(encoded string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + return string(decoded), nil +} + +const ( + ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA==" + ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6" + ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZm1wZWcvemlw" +) + +// GetFFmpegDir returns the directory where ffmpeg should be stored +func GetFFmpegDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".spotiflac"), nil +} + +// GetFFmpegPath returns the full path to the ffmpeg executable +func GetFFmpegPath() (string, error) { + ffmpegDir, err := GetFFmpegDir() + if err != nil { + return "", err + } + + ffmpegName := "ffmpeg" + if runtime.GOOS == "windows" { + ffmpegName = "ffmpeg.exe" + } + + return filepath.Join(ffmpegDir, ffmpegName), nil +} + +// IsFFmpegInstalled checks if ffmpeg is installed in the app directory +func IsFFmpegInstalled() (bool, error) { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return false, err + } + + _, err = os.Stat(ffmpegPath) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + + // Verify it's executable + cmd := exec.Command(ffmpegPath, "-version") + err = cmd.Run() + return err == nil, nil +} + +// DownloadFFmpeg downloads and extracts ffmpeg to the app directory +func DownloadFFmpeg(progressCallback func(int)) error { + ffmpegDir, err := GetFFmpegDir() + if err != nil { + return err + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(ffmpegDir, 0755); err != nil { + return fmt.Errorf("failed to create ffmpeg directory: %w", err) + } + + // Get the appropriate URL for the current OS + var encodedURL string + switch runtime.GOOS { + case "windows": + encodedURL = ffmpegWindowsURL + case "linux": + encodedURL = ffmpegLinuxURL + case "darwin": + encodedURL = ffmpegMacOSURL + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + // Decode URL + url, err := decodeBase64(encodedURL) + if err != nil { + return fmt.Errorf("failed to decode ffmpeg URL: %w", err) + } + + fmt.Printf("[FFmpeg] Downloading from: %s\n", url) + + // Create temporary file for download + tmpFile, err := os.CreateTemp("", "ffmpeg-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Download the file + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download ffmpeg: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download ffmpeg: HTTP %d", resp.StatusCode) + } + + totalSize := resp.ContentLength + var downloaded int64 + + // Create a progress reader + buf := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + _, writeErr := tmpFile.Write(buf[:n]) + if writeErr != nil { + return fmt.Errorf("failed to write to temp file: %w", writeErr) + } + downloaded += int64(n) + if totalSize > 0 && progressCallback != nil { + progress := int(float64(downloaded) / float64(totalSize) * 100) + progressCallback(progress) + } + } + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + } + + tmpFile.Close() + + fmt.Printf("[FFmpeg] Download complete, extracting...\n") + + // Extract the archive + switch runtime.GOOS { + case "windows", "darwin": + return extractZip(tmpFile.Name(), ffmpegDir) + case "linux": + return extractTarXz(tmpFile.Name(), ffmpegDir) + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } +} + +// extractZip extracts ffmpeg from a zip archive +func extractZip(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer r.Close() + + ffmpegName := "ffmpeg" + if runtime.GOOS == "windows" { + ffmpegName = "ffmpeg.exe" + } + + destPath := filepath.Join(destDir, ffmpegName) + + for _, f := range r.File { + // Look for ffmpeg executable in any subdirectory + baseName := filepath.Base(f.Name) + if baseName == ffmpegName && !f.FileInfo().IsDir() { + fmt.Printf("[FFmpeg] Found: %s\n", f.Name) + + rc, err := f.Open() + if err != nil { + return fmt.Errorf("failed to open file in zip: %w", err) + } + defer rc.Close() + + outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + _, err = io.Copy(outFile, rc) + if err != nil { + return fmt.Errorf("failed to extract file: %w", err) + } + + fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath) + return nil + } + } + + return fmt.Errorf("ffmpeg executable not found in archive") +} + +// extractTarXz extracts ffmpeg from a tar.xz archive +func extractTarXz(tarXzPath, destDir string) error { + file, err := os.Open(tarXzPath) + if err != nil { + return fmt.Errorf("failed to open tar.xz: %w", err) + } + defer file.Close() + + xzReader, err := xz.NewReader(file) + if err != nil { + return fmt.Errorf("failed to create xz reader: %w", err) + } + + tarReader := tar.NewReader(xzReader) + + ffmpegName := "ffmpeg" + destPath := filepath.Join(destDir, ffmpegName) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar: %w", err) + } + + baseName := filepath.Base(header.Name) + if baseName == ffmpegName && header.Typeflag == tar.TypeReg { + fmt.Printf("[FFmpeg] Found: %s\n", header.Name) + + outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + _, err = io.Copy(outFile, tarReader) + if err != nil { + return fmt.Errorf("failed to extract file: %w", err) + } + + fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath) + return nil + } + } + + return fmt.Errorf("ffmpeg executable not found in archive") +} + +// ConvertAudioRequest represents a request to convert audio files +type ConvertAudioRequest struct { + InputFiles []string `json:"input_files"` + OutputFormat string `json:"output_format"` // mp3, m4a + Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" +} + +// ConvertAudioResult represents the result of a single file conversion +type ConvertAudioResult struct { + InputFile string `json:"input_file"` + OutputFile string `json:"output_file"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// ConvertAudio converts audio files using ffmpeg while preserving metadata +func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return nil, fmt.Errorf("failed to get ffmpeg path: %w", err) + } + + installed, err := IsFFmpegInstalled() + if err != nil || !installed { + return nil, fmt.Errorf("ffmpeg is not installed") + } + + results := make([]ConvertAudioResult, len(req.InputFiles)) + var wg sync.WaitGroup + var mu sync.Mutex + + // Convert files in parallel + for i, inputFile := range req.InputFiles { + wg.Add(1) + go func(idx int, inputFile string) { + defer wg.Done() + + result := ConvertAudioResult{ + InputFile: inputFile, + } + + // Get input file info + inputExt := strings.ToLower(filepath.Ext(inputFile)) + baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt) + inputDir := filepath.Dir(inputFile) + + // Determine output directory: same as input file location + subfolder (MP3 or M4A) + outputFormatUpper := strings.ToUpper(req.OutputFormat) + outputDir := filepath.Join(inputDir, outputFormatUpper) + + // Create output directory if it doesn't exist + if err := os.MkdirAll(outputDir, 0755); err != nil { + result.Error = fmt.Sprintf("failed to create output directory: %v", err) + result.Success = false + mu.Lock() + results[idx] = result + mu.Unlock() + return + } + + // Determine output path + outputExt := "." + strings.ToLower(req.OutputFormat) + outputFile := filepath.Join(outputDir, baseName+outputExt) + + // Skip if same format + if inputExt == outputExt { + result.Error = "Input and output formats are the same" + result.Success = false + mu.Lock() + results[idx] = result + mu.Unlock() + return + } + + result.OutputFile = outputFile + + // Extract cover art and lyrics from input file before conversion + var coverArtPath string + var lyrics string + + coverArtPath, _ = ExtractCoverArt(inputFile) + lyrics, _ = ExtractLyrics(inputFile) + + // Build ffmpeg command + args := []string{ + "-i", inputFile, + "-y", // Overwrite output + } + + // Add codec and bitrate based on output format + switch req.OutputFormat { + case "mp3": + args = append(args, + "-codec:a", "libmp3lame", + "-b:a", req.Bitrate, + "-map", "0:a", // Map audio stream + "-map_metadata", "0", // Copy all metadata + "-id3v2_version", "3", // Use ID3v2.3 for better compatibility + ) + // Map video stream if exists (for cover art) + args = append(args, "-map", "0:v?", "-c:v", "copy") + case "m4a": + args = append(args, + "-codec:a", "aac", + "-b:a", req.Bitrate, + "-map", "0:a", // Map audio stream + "-map_metadata", "0", // Copy all metadata + ) + // Map video stream for cover art in M4A + args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic") + } + + args = append(args, outputFile) + + fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile) + + cmd := exec.Command(ffmpegPath, args...) + output, err := cmd.CombinedOutput() + if err != nil { + result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output)) + result.Success = false + mu.Lock() + results[idx] = result + mu.Unlock() + // Clean up temp cover art file if exists + if coverArtPath != "" { + os.Remove(coverArtPath) + } + return + } + + // Embed cover art and lyrics after conversion if they were extracted + if coverArtPath != "" { + if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil { + fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err) + } else { + fmt.Printf("[FFmpeg] Cover art embedded successfully\n") + } + os.Remove(coverArtPath) // Clean up temp file + } + + if lyrics != "" { + if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil { + fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err) + } else { + fmt.Printf("[FFmpeg] Lyrics embedded successfully\n") + } + } + + result.Success = true + fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile) + + mu.Lock() + results[idx] = result + mu.Unlock() + }(i, inputFile) + } + + wg.Wait() + return results, nil +} + +// GetAudioInfo returns information about an audio file +type AudioFileInfo struct { + Path string `json:"path"` + Filename string `json:"filename"` + Format string `json:"format"` + Size int64 `json:"size"` +} + +// GetAudioFileInfo gets information about an audio file +func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) { + info, err := os.Stat(filePath) + if err != nil { + return nil, err + } + + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), ".")) + return &AudioFileInfo{ + Path: filePath, + Filename: filepath.Base(filePath), + Format: ext, + Size: info.Size(), + }, nil +} + +// InstallFFmpegFromFile installs ffmpeg from a local file path +func InstallFFmpegFromFile(filePath string) error { + // Check if file exists + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("file does not exist: %w", err) + } + + // Check if it's a regular file (not a directory) + if info.IsDir() { + return fmt.Errorf("path is a directory, not a file") + } + + // Verify it's likely an ffmpeg executable by checking the filename + fileName := strings.ToLower(filepath.Base(filePath)) + expectedName := "ffmpeg" + if runtime.GOOS == "windows" { + expectedName = "ffmpeg.exe" + } + + if fileName != expectedName && !strings.Contains(fileName, "ffmpeg") { + return fmt.Errorf("file does not appear to be an ffmpeg executable (expected name containing 'ffmpeg')") + } + + // Get destination path + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return fmt.Errorf("failed to get ffmpeg path: %w", err) + } + + ffmpegDir := filepath.Dir(ffmpegPath) + + // Create directory if it doesn't exist + if err := os.MkdirAll(ffmpegDir, 0755); err != nil { + return fmt.Errorf("failed to create ffmpeg directory: %w", err) + } + + // Copy file to destination + sourceFile, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + + destFile, err := os.OpenFile(ffmpegPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + sourceFile.Close() + return fmt.Errorf("failed to create destination file: %w", err) + } + + _, err = io.Copy(destFile, sourceFile) + sourceFile.Close() + if err != nil { + destFile.Close() + return fmt.Errorf("failed to copy file: %w", err) + } + + // Ensure all data is written to disk + if err := destFile.Sync(); err != nil { + destFile.Close() + return fmt.Errorf("failed to sync file: %w", err) + } + destFile.Close() + + // On Windows, file may still be locked by antivirus or system + // Wait a bit and retry verification + maxRetries := 3 + retryDelay := 500 * time.Millisecond + + var verifyErr error + for i := 0; i < maxRetries; i++ { + if i > 0 { + time.Sleep(retryDelay) + } + + cmd := exec.Command(ffmpegPath, "-version") + verifyErr = cmd.Run() + if verifyErr == nil { + break + } + } + + if verifyErr != nil { + return fmt.Errorf("file copied but ffmpeg verification failed after %d attempts: %w", maxRetries, verifyErr) + } + + fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath) + return nil +} + diff --git a/backend/file_dialog.go b/backend/file_dialog.go new file mode 100644 index 0000000..c315bdb --- /dev/null +++ b/backend/file_dialog.go @@ -0,0 +1,52 @@ +package backend + +import ( + "context" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// SelectMultipleFiles opens a file dialog to select multiple audio files +func SelectMultipleFiles(ctx context.Context) ([]string, error) { + files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{ + Title: "Select Audio Files", + Filters: []runtime.FileFilter{ + { + DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)", + Pattern: "*.mp3;*.m4a;*.flac", + }, + { + DisplayName: "MP3 Files (*.mp3)", + Pattern: "*.mp3", + }, + { + DisplayName: "M4A Files (*.m4a)", + Pattern: "*.m4a", + }, + { + DisplayName: "FLAC Files (*.flac)", + Pattern: "*.flac", + }, + { + DisplayName: "All Files (*.*)", + Pattern: "*.*", + }, + }, + }) + if err != nil { + return nil, err + } + return files, nil +} + +// SelectOutputDirectory opens a directory dialog to select output folder +func SelectOutputDirectory(ctx context.Context) (string, error) { + dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{ + Title: "Select Output Directory", + }) + if err != nil { + return "", err + } + return dir, nil +} + diff --git a/backend/lyrics.go b/backend/lyrics.go index 7af70ae..efeb6e4 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -74,10 +74,12 @@ func NewLyricsClient() *LyricsClient { } } -// FetchLyricsWithMetadata fetches lyrics using track name and artist (for LRCLIB fallback) +// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) { // Try LRCLIB API - apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s", + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9") + apiURL := fmt.Sprintf("%s%s&track_name=%s", + string(apiBase), url.QueryEscape(artistName), url.QueryEscape(trackName)) @@ -174,7 +176,8 @@ func lrcTimestampToMs(timestamp string) int64 { // FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) { query := fmt.Sprintf("%s %s", artistName, trackName) - apiURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query)) + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=") + apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query)) resp, err := c.httpClient.Get(apiURL) if err != nil { @@ -232,35 +235,26 @@ func simplifyTrackName(name string) string { return name } -// FetchLyricsAllSources tries all sources to get lyrics +// FetchLyricsAllSources tries all LRCLIB sources to get lyrics func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) { - // 1. Try Spotify API - if spotifyID != "" { - resp, err := c.FetchLyrics(spotifyID) - if err == nil && resp != nil && len(resp.Lines) > 0 { - return resp, "Spotify", nil - } - fmt.Printf(" ↳ Spotify API: %v\n", err) - } - - // 2. Try LRCLIB exact match + // 1. Try LRCLIB exact match resp, err := c.FetchLyricsWithMetadata(trackName, artistName) if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { return resp, "LRCLIB", nil } - fmt.Printf(" ↳ LRCLIB exact: %v\n", err) + fmt.Printf(" LRCLIB exact: %v\n", err) - // 3. Try LRCLIB search + // 2. Try LRCLIB search resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName) if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { return resp, "LRCLIB Search", nil } - fmt.Printf(" ↳ LRCLIB search: %v\n", err) + fmt.Printf(" LRCLIB search: %v\n", err) - // 4. Try with simplified track name (remove parentheses, subtitles) + // 3. Try with simplified track name (remove parentheses, subtitles) simplifiedTrack := simplifyTrackName(trackName) if simplifiedTrack != trackName { - fmt.Printf(" ↳ Trying simplified name: %s\n", simplifiedTrack) + fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack) resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName) if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { @@ -276,34 +270,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return nil, "", fmt.Errorf("lyrics not found in any source") } -// FetchLyrics fetches lyrics from the Spotify Lyrics API with LRCLIB fallback -func (c *LyricsClient) FetchLyrics(spotifyID string) (*LyricsResponse, error) { - // Decode base64 API URL - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9zcG90aWZ5LWx5cmljcy1hcGktcGkudmVyY2VsLmFwcC8/dHJhY2tpZD0=") - apiURL := fmt.Sprintf("%s%s", string(apiBase), spotifyID) - - resp, err := c.httpClient.Get(apiURL) - if err != nil { - return nil, fmt.Errorf("failed to fetch lyrics: %v", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %v", err) - } - - var lyricsResp LyricsResponse - if err := json.Unmarshal(body, &lyricsResp); err != nil { - return nil, fmt.Errorf("failed to parse lyrics response: %v", err) - } - - if lyricsResp.Error { - return nil, fmt.Errorf("lyrics not found for this track") - } - - return &lyricsResp, nil -} // ConvertToLRC converts lyrics response to LRC format func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string { @@ -426,8 +392,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa }, nil } - // Fetch lyrics - lyrics, err := c.FetchLyrics(req.SpotifyID) + // Fetch lyrics from LRCLIB + lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) if err != nil { return &LyricsDownloadResponse{ Success: false, diff --git a/backend/metadata.go b/backend/metadata.go index ba1b666..7adbcf9 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -3,9 +3,11 @@ package backend import ( "fmt" "os" + pathfilepath "path/filepath" "strconv" "strings" + id3v2 "github.com/bogem/id3v2/v2" "github.com/go-flac/flacpicture" "github.com/go-flac/flacvorbis" "github.com/go-flac/go-flac" @@ -247,3 +249,262 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) { return "", false } + +// ExtractCoverArt extracts cover art from an audio file and saves it to a temporary file +func ExtractCoverArt(filePath string) (string, error) { + ext := strings.ToLower(pathfilepath.Ext(filePath)) + + switch ext { + case ".mp3": + return extractCoverFromMp3(filePath) + case ".m4a", ".flac": + return extractCoverFromM4AOrFlac(filePath) + default: + return "", fmt.Errorf("unsupported file format: %s", ext) + } +} + +// extractCoverFromMp3 extracts cover art from MP3 file +func extractCoverFromMp3(filePath string) (string, error) { + tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) + if err != nil { + return "", fmt.Errorf("failed to open MP3 file: %w", err) + } + defer tag.Close() + + pictures := tag.GetFrames(tag.CommonID("Attached picture")) + if len(pictures) == 0 { + return "", fmt.Errorf("no cover art found") + } + + pic, ok := pictures[0].(id3v2.PictureFrame) + if !ok { + return "", fmt.Errorf("invalid picture frame") + } + + // Create temporary file + tmpFile, err := os.CreateTemp("", "cover-*.jpg") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer tmpFile.Close() + + if _, err := tmpFile.Write(pic.Picture); err != nil { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("failed to write cover art: %w", err) + } + + return tmpFile.Name(), nil +} + +// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file +func extractCoverFromM4AOrFlac(filePath string) (string, error) { + ext := strings.ToLower(pathfilepath.Ext(filePath)) + + if ext == ".flac" { + f, err := flac.ParseFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to parse FLAC file: %w", err) + } + + for _, block := range f.Meta { + if block.Type == flac.Picture { + pic, err := flacpicture.ParseFromMetaDataBlock(*block) + if err != nil { + continue + } + + // Create temporary file + tmpFile, err := os.CreateTemp("", "cover-*.jpg") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer tmpFile.Close() + + if _, err := tmpFile.Write(pic.ImageData); err != nil { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("failed to write cover art: %w", err) + } + + return tmpFile.Name(), nil + } + } + return "", fmt.Errorf("no cover art found") + } + + // For M4A, try to extract using ffmpeg or return empty + // M4A cover art should be preserved by ffmpeg during conversion + return "", nil +} + +// ExtractLyrics extracts lyrics from an audio file +func ExtractLyrics(filePath string) (string, error) { + ext := strings.ToLower(pathfilepath.Ext(filePath)) + + switch ext { + case ".mp3": + return extractLyricsFromMp3(filePath) + case ".flac": + return extractLyricsFromFlac(filePath) + case ".m4a": + // M4A lyrics extraction would need different approach + return "", nil + default: + return "", fmt.Errorf("unsupported file format: %s", ext) + } +} + +// extractLyricsFromMp3 extracts lyrics from MP3 file +func extractLyricsFromMp3(filePath string) (string, error) { + tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) + if err != nil { + return "", fmt.Errorf("failed to open MP3 file: %w", err) + } + defer tag.Close() + + usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) + if len(usltFrames) == 0 { + return "", nil + } + + uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame) + if !ok { + return "", nil + } + + return uslt.Lyrics, nil +} + +// extractLyricsFromFlac extracts lyrics from FLAC file +func extractLyricsFromFlac(filePath string) (string, error) { + f, err := flac.ParseFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to parse FLAC file: %w", err) + } + + for _, block := range f.Meta { + if block.Type == flac.VorbisComment { + cmt, err := flacvorbis.ParseFromMetaDataBlock(*block) + if err != nil { + continue + } + + // Search through comments for lyrics + for _, comment := range cmt.Comments { + parts := strings.SplitN(comment, "=", 2) + if len(parts) == 2 { + fieldName := strings.ToUpper(parts[0]) + if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" { + return parts[1], nil + } + } + } + } + } + + return "", nil +} + +// EmbedCoverArtOnly embeds cover art into an audio file +func EmbedCoverArtOnly(filePath string, coverPath string) error { + if coverPath == "" || !fileExists(coverPath) { + return nil + } + + ext := strings.ToLower(pathfilepath.Ext(filePath)) + + switch ext { + case ".mp3": + return embedCoverToMp3(filePath, coverPath) + case ".m4a": + // M4A cover art should be handled by ffmpeg during conversion + // If not, we can try to embed using atomicparsley or similar tool + // For now, return nil as ffmpeg should handle it + return nil + default: + return fmt.Errorf("unsupported file format: %s", ext) + } +} + +// embedCoverToMp3 embeds cover art into MP3 file +func embedCoverToMp3(filePath string, coverPath string) error { + tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) + if err != nil { + return fmt.Errorf("failed to open MP3 file: %w", err) + } + defer tag.Close() + + // Remove existing cover art + tag.DeleteFrames(tag.CommonID("Attached picture")) + + // Read cover art + artwork, err := os.ReadFile(coverPath) + if err != nil { + return fmt.Errorf("failed to read cover art: %w", err) + } + + // Add new cover art + pic := id3v2.PictureFrame{ + Encoding: id3v2.EncodingUTF8, + MimeType: "image/jpeg", + PictureType: id3v2.PTFrontCover, + Description: "Front cover", + Picture: artwork, + } + tag.AddAttachedPicture(pic) + + if err := tag.Save(); err != nil { + return fmt.Errorf("failed to save MP3 tags: %w", err) + } + + return nil +} + +// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame +func EmbedLyricsOnlyMP3(filepath string, lyrics string) error { + if lyrics == "" { + return nil + } + + tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true}) + if err != nil { + return fmt.Errorf("failed to open MP3 file: %w", err) + } + defer tag.Close() + + // Remove existing USLT frames + tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) + + // Add new USLT frame with lyrics + // Use UTF-8 encoding for better compatibility with AIMP and other players + usltFrame := id3v2.UnsynchronisedLyricsFrame{ + Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding + Language: "eng", + ContentDescriptor: "", // Empty descriptor for better compatibility + Lyrics: lyrics, + } + tag.AddUnsynchronisedLyricsFrame(usltFrame) + + if err := tag.Save(); err != nil { + return fmt.Errorf("failed to save MP3 tags: %w", err) + } + + return nil +} + +// EmbedLyricsOnlyUniversal embeds lyrics to MP3 or FLAC file +func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { + if lyrics == "" { + return nil + } + + ext := strings.ToLower(pathfilepath.Ext(filepath)) + switch ext { + case ".mp3": + return EmbedLyricsOnlyMP3(filepath, lyrics) + case ".flac": + return EmbedLyricsOnly(filepath, lyrics) + default: + return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext) + } +} diff --git a/backend/qobuz.go b/backend/qobuz.go index 9fa3771..2b688d6 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -93,8 +93,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { } var searchResp QobuzSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + // Read body first to handle encoding issues + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return nil, fmt.Errorf("API returned empty response") + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } if len(searchResp.Tracks.Items) == 0 { @@ -151,12 +166,25 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("API returned status %d", resp.StatusCode) } - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return "", fmt.Errorf("API returned empty response") + } + fmt.Printf("Fallback API response: %s\n", string(body)) var streamResp QobuzStreamResponse if err := json.Unmarshal(body, &streamResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } if streamResp.URL == "" { diff --git a/backend/songlink.go b/backend/songlink.go index 2e3008e..6b7e9d1 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "net/url" "time" @@ -126,8 +127,23 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink URL string `json:"url"` } `json:"linksByPlatform"` } - if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + // Read body first to handle encoding issues + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return nil, fmt.Errorf("API returned empty response") + } + + if err := json.Unmarshal(body, &songLinkResp); err != nil { + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } urls := &SongLinkURLs{} @@ -245,8 +261,23 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri URL string `json:"url"` } `json:"linksByPlatform"` } - if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + // Read body first to handle encoding issues + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if len(body) == 0 { + return nil, fmt.Errorf("API returned empty response") + } + + if err := json.Unmarshal(body, &songLinkResp); err != nil { + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } availability := &TrackAvailability{ diff --git a/backend/tidal.go b/backend/tidal.go index 046bd12..95be9fa 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -571,8 +571,13 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, // Fallback to v1 format (array with OriginalTrackUrl) var apiResponses []TidalAPIResponse if err := json.Unmarshal(body, &apiResponses); err != nil { - fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err) - return "", fmt.Errorf("failed to decode response: %w", err) + // Truncate body for error message (max 200 chars) + bodyStr := string(body) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + fmt.Printf("✗ Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr) + return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) } if len(apiResponses) == 0 { diff --git a/frontend/package.json b/frontend/package.json index b0926b5..211fd45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,21 +23,23 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", - "@tailwindcss/vite": "^4.1.17", + "@tailwindcss/vite": "^4.1.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.556.0", + "lucide-react": "^0.561.0", "next-themes": "^0.4.6", - "react": "^19.2.1", - "react-dom": "^19.2.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.17" + "tailwindcss": "^4.1.18" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/node": "^24.10.1", + "@types/node": "^25.0.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", @@ -48,7 +50,7 @@ "sharp": "^0.34.5", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.1", + "typescript-eslint": "^8.49.0", "vite": "^7.2.7" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index dd4b7ad..672605c 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -58400d5c8f7b03bac8ab784b5e775687 \ No newline at end of file +0cfdd24cb906bd58f1194d05e38654ae \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 58d02af..0e1284b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,43 +10,49 @@ importers: dependencies: '@radix-ui/react-checkbox': specifier: ^1.3.3 - version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-context-menu': specifier: ^2.2.16 - version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-label': specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-progress': specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-radio-group': specifier: ^1.3.8 - version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-scroll-area': specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-select': specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-slot': specifier: ^1.2.4 - version: 1.2.4(@types/react@19.2.7)(react@19.2.1) + version: 1.2.4(@types/react@19.2.7)(react@19.2.3) '@radix-ui/react-switch': specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-tabs': specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle-group': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-tooltip': specifier: ^1.2.8 - version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tailwindcss/vite': - specifier: ^4.1.17 - version: 4.1.17(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) + specifier: ^4.1.18 + version: 4.1.18(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -54,33 +60,33 @@ importers: specifier: ^2.1.1 version: 2.1.1 lucide-react: - specifier: ^0.556.0 - version: 0.556.0(react@19.2.1) + specifier: ^0.561.0 + version: 0.561.0(react@19.2.3) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: - specifier: ^19.2.1 - version: 19.2.1 + specifier: ^19.2.3 + version: 19.2.3 react-dom: - specifier: ^19.2.1 - version: 19.2.1(react@19.2.1) + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) sonner: specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 tailwindcss: - specifier: ^4.1.17 - version: 4.1.17 + specifier: ^4.1.18 + version: 4.1.18 devDependencies: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.0.1 + version: 25.0.1 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -89,7 +95,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.2 - version: 5.1.2(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 5.1.2(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)) eslint: specifier: ^9.39.1 version: 9.39.1(jiti@2.6.1) @@ -112,11 +118,11 @@ importers: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.48.1 - version: 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.49.0 + version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.2.7 - version: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + version: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -935,6 +941,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + 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 + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + 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 + '@radix-ui/react-tooltip@1.2.8': resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} peerDependencies: @@ -1149,65 +1181,65 @@ packages: cpu: [x64] os: [win32] - '@tailwindcss/node@4.1.17': - resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - '@tailwindcss/oxide-android-arm64@4.1.17': - resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==} + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.17': - resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==} + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.17': - resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==} + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.17': - resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==} + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': - resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': - resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.1.17': - resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.1.17': - resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.1.17': - resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.1.17': - resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1218,24 +1250,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': - resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==} + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.17': - resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==} + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.17': - resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==} + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} engines: {node: '>= 10'} - '@tailwindcss/vite@4.1.17': - resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==} + '@tailwindcss/vite@4.1.18': + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 @@ -1257,8 +1289,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@25.0.1': + resolution: {integrity: sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1268,63 +1300,63 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - '@typescript-eslint/eslint-plugin@8.48.1': - resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==} + '@typescript-eslint/eslint-plugin@8.49.0': + resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.48.1 + '@typescript-eslint/parser': ^8.49.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.48.1': - resolution: {integrity: sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==} + '@typescript-eslint/parser@8.49.0': + resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.48.1': - resolution: {integrity: sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==} + '@typescript-eslint/project-service@8.49.0': + resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.48.1': - resolution: {integrity: sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==} + '@typescript-eslint/scope-manager@8.49.0': + resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.48.1': - resolution: {integrity: sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==} + '@typescript-eslint/tsconfig-utils@8.49.0': + resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.48.1': - resolution: {integrity: sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==} + '@typescript-eslint/type-utils@8.49.0': + resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.48.1': - resolution: {integrity: sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==} + '@typescript-eslint/types@8.49.0': + resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.48.1': - resolution: {integrity: sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==} + '@typescript-eslint/typescript-estree@8.49.0': + resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.48.1': - resolution: {integrity: sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==} + '@typescript-eslint/utils@8.49.0': + resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.48.1': - resolution: {integrity: sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==} + '@typescript-eslint/visitor-keys@8.49.0': + resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@5.1.2': @@ -1360,8 +1392,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.9.4: - resolution: {integrity: sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==} + baseline-browser-mapping@2.9.7: + resolution: {integrity: sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==} hasBin: true brace-expansion@1.1.12: @@ -1379,8 +1411,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001759: - resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + caniuse-lite@1.0.30001760: + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1432,11 +1464,11 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - electron-to-chromium@1.5.266: - resolution: {integrity: sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==} + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} esbuild@0.25.12: @@ -1566,9 +1598,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1723,8 +1752,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.556.0: - resolution: {integrity: sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==} + lucide-react@0.561.0: + resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1801,10 +1830,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - react-dom@19.2.1: - resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: - react: ^19.2.1 + react: ^19.2.3 react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} @@ -1840,8 +1869,8 @@ packages: '@types/react': optional: true - react@19.2.1: - resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} resolve-from@4.0.0: @@ -1898,8 +1927,8 @@ packages: tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} - tailwindcss@4.1.17: - resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -1925,8 +1954,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.48.1: - resolution: {integrity: sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==} + typescript-eslint@8.49.0: + resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2286,11 +2315,11 @@ snapshots: '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@floating-ui/dom': 1.7.4 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) '@floating-ui/utils@0.2.10': {} @@ -2424,446 +2453,472 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.3)': dependencies: - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.3)': dependencies: - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-context@1.1.3(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-context@1.1.3(@types/react@19.2.7)(react@19.2.3)': dependencies: - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) aria-hidden: 1.2.6 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.7)(react@19.2.3)': dependencies: - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) aria-hidden: 1.2.6 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.1) + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3) '@radix-ui/rect': 1.1.1 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) aria-hidden: 1.2.6 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-slot@1.2.4(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-slot@1.2.4(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - react: 19.2.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.3)': + dependencies: + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.2.1 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.2.1)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1) - react: 19.2.1 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.7 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) @@ -2938,73 +2993,73 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true - '@tailwindcss/node@4.1.17': + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.3 + enhanced-resolve: 5.18.4 jiti: 2.6.1 lightningcss: 1.30.2 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.17 + tailwindcss: 4.1.18 - '@tailwindcss/oxide-android-arm64@4.1.17': + '@tailwindcss/oxide-android-arm64@4.1.18': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.17': + '@tailwindcss/oxide-darwin-arm64@4.1.18': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.17': + '@tailwindcss/oxide-darwin-x64@4.1.18': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.17': + '@tailwindcss/oxide-freebsd-x64@4.1.18': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.17': + '@tailwindcss/oxide-linux-x64-musl@4.1.18': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.17': + '@tailwindcss/oxide-wasm32-wasi@4.1.18': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': optional: true - '@tailwindcss/oxide@4.1.17': + '@tailwindcss/oxide@4.1.18': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.17 - '@tailwindcss/oxide-darwin-arm64': 4.1.17 - '@tailwindcss/oxide-darwin-x64': 4.1.17 - '@tailwindcss/oxide-freebsd-x64': 4.1.17 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 - '@tailwindcss/oxide-linux-x64-musl': 4.1.17 - '@tailwindcss/oxide-wasm32-wasi': 4.1.17 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.17(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: - '@tailwindcss/node': 4.1.17 - '@tailwindcss/oxide': 4.1.17 - tailwindcss: 4.1.17 - vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + tailwindcss: 4.1.18 + vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) '@types/babel__core@7.20.5': dependencies: @@ -3031,7 +3086,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@24.10.1': + '@types/node@25.0.1': dependencies: undici-types: 7.16.0 @@ -3043,16 +3098,15 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.48.1 - '@typescript-eslint/type-utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.48.1 + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 eslint: 9.39.1(jiti@2.6.1) - graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.1.0(typescript@5.9.3) @@ -3060,41 +3114,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.48.1 - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.48.1 + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.48.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3) - '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.48.1': + '@typescript-eslint/scope-manager@8.49.0': dependencies: - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/visitor-keys': 8.48.1 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 - '@typescript-eslint/tsconfig-utils@8.48.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -3102,14 +3156,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.48.1': {} + '@typescript-eslint/types@8.49.0': {} - '@typescript-eslint/typescript-estree@8.48.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.48.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3) - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/visitor-keys': 8.48.1 + '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 @@ -3119,23 +3173,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.48.1 - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.48.1': + '@typescript-eslint/visitor-keys@8.49.0': dependencies: - '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/types': 8.49.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.2(vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@5.1.2(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -3143,7 +3197,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color @@ -3172,7 +3226,7 @@ snapshots: balanced-match@1.0.2: {} - baseline-browser-mapping@2.9.4: {} + baseline-browser-mapping@2.9.7: {} brace-expansion@1.1.12: dependencies: @@ -3185,15 +3239,15 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.4 - caniuse-lite: 1.0.30001759 - electron-to-chromium: 1.5.266 + baseline-browser-mapping: 2.9.7 + caniuse-lite: 1.0.30001760 + electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.2(browserslist@4.28.1) callsites@3.1.0: {} - caniuse-lite@1.0.30001759: {} + caniuse-lite@1.0.30001760: {} chalk@4.1.2: dependencies: @@ -3234,9 +3288,9 @@ snapshots: detect-node-es@1.1.0: {} - electron-to-chromium@1.5.266: {} + electron-to-chromium@1.5.267: {} - enhanced-resolve@5.18.3: + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -3400,8 +3454,6 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - has-flag@4.0.0: {} hermes-estree@0.25.1: {} @@ -3515,9 +3567,9 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.556.0(react@19.2.1): + lucide-react@0.561.0(react@19.2.3): dependencies: - react: 19.2.1 + react: 19.2.3 magic-string@0.30.21: dependencies: @@ -3537,10 +3589,10 @@ snapshots: natural-compare@1.4.0: {} - next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) node-releases@2.0.27: {} @@ -3583,41 +3635,41 @@ snapshots: punycode@2.3.1: {} - react-dom@19.2.1(react@19.2.1): + react-dom@19.2.3(react@19.2.3): dependencies: - react: 19.2.1 + react: 19.2.3 scheduler: 0.27.0 react-refresh@0.18.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.1): + react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): dependencies: - react: 19.2.1 - react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.1) + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll@2.7.2(@types/react@19.2.7)(react@19.2.1): + react-remove-scroll@2.7.2(@types/react@19.2.7)(react@19.2.3): dependencies: - react: 19.2.1 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.1) - react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.1) + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.2.1) - use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.1) + use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 - react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.1): + react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.3): dependencies: get-nonce: 1.0.1 - react: 19.2.1 + react: 19.2.3 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react@19.2.1: {} + react@19.2.3: {} resolve-from@4.0.0: {} @@ -3692,10 +3744,10 @@ snapshots: shebang-regex@3.0.0: {} - sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) source-map-js@1.2.1: {} @@ -3707,7 +3759,7 @@ snapshots: tailwind-merge@3.4.0: {} - tailwindcss@4.1.17: {} + tailwindcss@4.1.18: {} tapable@2.3.0: {} @@ -3728,12 +3780,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -3753,22 +3805,22 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.1): + use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): dependencies: - react: 19.2.1 + react: 19.2.3 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - use-sidecar@1.1.3(@types/react@19.2.7)(react@19.2.1): + use-sidecar@1.1.3(@types/react@19.2.7)(react@19.2.3): dependencies: detect-node-es: 1.1.0 - react: 19.2.1 + react: 19.2.3 tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - vite@7.2.7(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -3777,7 +3829,7 @@ snapshots: rollup: 4.53.3 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d938d16..658cd99 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import { ArtistInfo } from "@/components/ArtistInfo"; import { DownloadQueue } from "@/components/DownloadQueue"; import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; +import { AudioConverterPage } from "@/components/AudioConverterPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import type { HistoryItem } from "@/components/FetchHistory"; @@ -55,7 +56,7 @@ function App() { const [fetchHistory, setFetchHistory] = useState([]); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "6.7"; + const CURRENT_VERSION = "6.8"; const download = useDownload(); const metadata = useMetadata(); @@ -319,6 +320,7 @@ function App() { skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} + isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} @@ -331,6 +333,7 @@ function App() { cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId) } onCheckAvailability={availability.checkAvailability} + onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)} onDownloadSelected={() => @@ -385,6 +388,7 @@ function App() { skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} + isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} @@ -397,6 +401,7 @@ function App() { cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId) } onCheckAvailability={availability.checkAvailability} + onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => @@ -457,6 +462,7 @@ function App() { skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} + isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} @@ -469,6 +475,7 @@ function App() { cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId) } onCheckAvailability={availability.checkAvailability} + onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => @@ -506,6 +513,8 @@ function App() { return ; case "audio-analysis": return ; + case "audio-converter": + return ; default: return ( <> diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index a10ac86..cd26b5c 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, ImageDown } from "lucide-react"; +import { Download, FolderOpen, ImageDown, FileText } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { SearchAndSort } from "./SearchAndSort"; @@ -46,6 +46,7 @@ interface AlbumInfoProps { skippedCovers?: Set; downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; + isBulkDownloadingLyrics?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (isrc: string) => void; @@ -54,6 +55,7 @@ interface AlbumInfoProps { onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onCheckAvailability?: (spotifyId: string) => void; + onDownloadAllLyrics?: () => void; onDownloadAllCovers?: () => void; onDownloadAll: () => void; onDownloadSelected: () => void; @@ -91,6 +93,7 @@ export function AlbumInfo({ skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, + isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, @@ -99,6 +102,7 @@ export function AlbumInfo({ onDownloadLyrics, onDownloadCover, onCheckAvailability, + onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, @@ -170,6 +174,22 @@ export function AlbumInfo({ Download Selected ({selectedTracks.length}) )} + {onDownloadAllLyrics && ( + + + + + +

Download All Lyrics

+
+
+ )} {onDownloadAllCovers && ( diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index 5eb2032..9db7d38 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, ImageDown } from "lucide-react"; +import { Download, FolderOpen, ImageDown, FileText } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { SearchAndSort } from "./SearchAndSort"; @@ -51,6 +51,7 @@ interface ArtistInfoProps { skippedCovers?: Set; downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; + isBulkDownloadingLyrics?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (isrc: string) => void; @@ -59,6 +60,7 @@ interface ArtistInfoProps { onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onCheckAvailability?: (spotifyId: string) => void; + onDownloadAllLyrics?: () => void; onDownloadAllCovers?: () => void; onDownloadAll: () => void; onDownloadSelected: () => void; @@ -98,6 +100,7 @@ export function ArtistInfo({ skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, + isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, @@ -106,6 +109,7 @@ export function ArtistInfo({ onDownloadLyrics, onDownloadCover, onCheckAvailability, + onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, @@ -212,6 +216,23 @@ export function ArtistInfo({ Download Selected ({selectedTracks.length}) )} + {onDownloadAllLyrics && ( + + + + + +

Download All Lyrics

+
+
+ )} {onDownloadAllCovers && ( diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index 52e1512..36f2a69 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Activity, Upload, ArrowLeft } from "lucide-react"; +import { Upload, ArrowLeft } from "lucide-react"; import { AudioAnalysis } from "@/components/AudioAnalysis"; import { SpectrumVisualization } from "@/components/SpectrumVisualization"; import { useAudioAnalysis } from "@/hooks/useAudioAnalysis"; @@ -82,10 +82,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { {/* File Selection */} {!result && !analyzing && (
{ e.preventDefault(); @@ -101,11 +101,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties} > - -

Analyze FLAC Audio Quality

-

+

+ +
+

{isDragging ? "Drop your FLAC file here" : "Drag and drop a FLAC file here, or click the button below to select"} @@ -128,10 +127,13 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { {/* Analysis Results */} {result && (

- {/* File Info */} -
-

Analyzing file:

-

{selectedFilePath}

+ {/* File Info with Analyze Another Button */} +
+

{selectedFilePath}

+
{/* Spectrum Visualization */} @@ -144,14 +146,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { {/* Detailed Analysis */} - - {/* Actions */} -
- -
)}
diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx new file mode 100644 index 0000000..63f99e1 --- /dev/null +++ b/frontend/src/components/AudioConverterPage.tsx @@ -0,0 +1,629 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group"; +import { + Upload, + Download, + X, + CheckCircle2, + AlertCircle, + Loader2, + Trash2, + FileMusic, +} from "lucide-react"; +import { + IsFFmpegInstalled, + DownloadFFmpeg, + InstallFFmpegFromFile, + ConvertAudio, + SelectAudioFiles, +} from "../../wailsjs/go/main/App"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; + +interface AudioFile { + path: string; + name: string; + format: string; + status: "pending" | "converting" | "success" | "error"; + error?: string; + outputPath?: string; +} + +const BITRATE_OPTIONS = [ + { value: "320k", label: "320k" }, + { value: "256k", label: "256k" }, + { value: "192k", label: "192k" }, + { value: "128k", label: "128k" }, +]; + +const STORAGE_KEY = "spotiflac_audio_converter_state"; + +export function AudioConverterPage() { + const [ffmpegInstalled, setFfmpegInstalled] = useState(false); + const [installingFfmpeg, setInstallingFfmpeg] = useState(false); + const [files, setFiles] = useState(() => { + // Initialize from localStorage synchronously + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) { + return parsed.files; + } + } + } catch (err) { + console.error("Failed to load saved state:", err); + } + return []; + }); + const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") { + return parsed.outputFormat; + } + } + } catch (err) { + // Ignore + } + return "mp3"; + }); + const [bitrate, setBitrate] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.bitrate) { + return parsed.bitrate; + } + } + } catch (err) { + // Ignore + } + return "320k"; + }); + const [converting, setConverting] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false); + const isInitialMount = useRef(true); + + // Helper function to save state + const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string }) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); + } catch (err) { + console.error("Failed to save state:", err); + } + }, []); + + // Load saved state from localStorage on mount (only for ffmpeg check) + useEffect(() => { + checkFfmpegInstallation(); + }, []); + + // Save state to localStorage whenever files, outputFormat, or bitrate changes + // Skip on initial mount to avoid overwriting with empty state + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + saveState({ files, outputFormat, bitrate }); + }, [files, outputFormat, bitrate, saveState]); + + // Save state on unmount as well + useEffect(() => { + return () => { + saveState({ files, outputFormat, bitrate }); + }; + }, [files, outputFormat, bitrate, saveState]); + + const checkFfmpegInstallation = async () => { + try { + const installed = await IsFFmpegInstalled(); + setFfmpegInstalled(installed); + } catch (err) { + console.error("Failed to check ffmpeg:", err); + setFfmpegInstalled(false); + } + }; + + const handleInstallFfmpeg = async () => { + setInstallingFfmpeg(true); + try { + const result = await DownloadFFmpeg(); + if (result.success) { + toast.success("FFmpeg Installed", { + description: "FFmpeg has been installed successfully", + }); + setFfmpegInstalled(true); + } else { + toast.error("Installation Failed", { + description: result.error || "Failed to install FFmpeg", + }); + } + } catch (err) { + toast.error("Installation Failed", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setInstallingFfmpeg(false); + } + }; + + const handleFFmpegFileDrop = useCallback( + async (_x: number, _y: number, paths: string[]) => { + setIsDraggingFFmpeg(false); + + if (paths.length === 0) return; + + // Only process the first file + const filePath = paths[0]; + const fileName = filePath.split(/[/\\]/).pop()?.toLowerCase() || ""; + + // Check if it's likely an ffmpeg executable + if (!fileName.includes("ffmpeg")) { + toast.error("Invalid File", { + description: "Please drop an FFmpeg executable file", + }); + return; + } + + setInstallingFfmpeg(true); + try { + const result = await InstallFFmpegFromFile(filePath); + if (result.success) { + toast.success("FFmpeg Installed", { + description: "FFmpeg has been installed successfully from file", + }); + setFfmpegInstalled(true); + } else { + toast.error("Installation Failed", { + description: result.error || "Failed to install FFmpeg", + }); + } + } catch (err) { + toast.error("Installation Failed", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setInstallingFfmpeg(false); + } + }, + [] + ); + + useEffect(() => { + if (ffmpegInstalled === false) { + // Set up drag and drop for FFmpeg installation + OnFileDrop((x, y, paths) => { + handleFFmpegFileDrop(x, y, paths); + }, true); + + return () => { + OnFileDropOff(); + }; + } + }, [ffmpegInstalled, handleFFmpegFileDrop]); + + const handleSelectFiles = async () => { + try { + const selectedFiles = await SelectAudioFiles(); + if (selectedFiles && selectedFiles.length > 0) { + addFiles(selectedFiles); + } + } catch (err) { + toast.error("File Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select files", + }); + } + }; + + const addFiles = useCallback((paths: string[]) => { + const validExtensions = [".mp3", ".m4a", ".flac"]; + setFiles((prev) => { + const newFiles: AudioFile[] = paths + .filter((path) => { + const ext = path.toLowerCase().slice(path.lastIndexOf(".")); + return validExtensions.includes(ext); + }) + .filter((path) => !prev.some((f) => f.path === path)) + .map((path) => { + const name = path.split(/[/\\]/).pop() || path; + const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase(); + return { + path, + name, + format: ext, + status: "pending" as const, + }; + }); + + if (newFiles.length > 0) { + if (paths.length > newFiles.length) { + const skipped = paths.length - newFiles.length; + toast.info("Some files skipped", { + description: `${skipped} file(s) were skipped (unsupported format or already added)`, + }); + } + + return [...prev, ...newFiles]; + } + + if (paths.length > 0) { + toast.info("No new files added", { + description: "All files were already added or have unsupported format", + }); + } + + return prev; + }); + }, []); + + const handleFileDrop = useCallback( + async (_x: number, _y: number, paths: string[]) => { + setIsDragging(false); + + if (paths.length === 0) return; + + addFiles(paths); + }, + [addFiles] + ); + + useEffect(() => { + // Only enable drag and drop for audio files if FFmpeg is installed + if (ffmpegInstalled === true) { + OnFileDrop((x, y, paths) => { + handleFileDrop(x, y, paths); + }, true); + + return () => { + OnFileDropOff(); + }; + } + }, [handleFileDrop, ffmpegInstalled]); + + + const removeFile = (path: string) => { + setFiles((prev) => prev.filter((f) => f.path !== path)); + }; + + const clearFiles = () => { + setFiles([]); + }; + + const handleConvert = async () => { + if (files.length === 0) { + toast.error("No files selected", { + description: "Please add audio files to convert", + }); + return; + } + + setConverting(true); + + try { + // Include all files (including previously successful ones) for conversion + const inputPaths = files.map((f) => f.path); + + // Mark all files as converting (including previously successful ones) + setFiles((prev) => + prev.map((f) => { + if (inputPaths.includes(f.path)) { + return { ...f, status: "converting" as const, error: undefined }; + } + return f; + }) + ); + + const results = await ConvertAudio({ + input_files: inputPaths, + output_format: outputFormat, + bitrate: bitrate, + }); + + // Update file statuses based on results + setFiles((prev) => + prev.map((f) => { + const result = results.find((r) => r.input_file === f.path); + if (result) { + return { + ...f, + status: result.success ? "success" : "error", + error: result.error, + outputPath: result.output_file, + }; + } + return f; + }) + ); + + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + + if (successCount > 0) { + toast.success("Conversion Complete", { + description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } else if (failCount > 0) { + toast.error("Conversion Failed", { + description: `All ${failCount} file(s) failed to convert`, + }); + } + } catch (err) { + toast.error("Conversion Error", { + description: err instanceof Error ? err.message : "Unknown error", + }); + setFiles((prev) => + prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" })) + ); + } finally { + setConverting(false); + } + }; + + const getStatusIcon = (status: AudioFile["status"]) => { + switch (status) { + case "converting": + return ; + case "success": + return ; + case "error": + return ; + default: + return ; + } + }; + + // Count files that can be converted (pending + success files that can be re-converted) + const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; + const successCount = files.filter((f) => f.status === "success").length; + + // Show FFmpeg installation prompt if not installed + if (ffmpegInstalled === false) { + return ( +
+
+

Audio Converter

+
+ +
{ + e.preventDefault(); + setIsDraggingFFmpeg(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDraggingFFmpeg(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDraggingFFmpeg(false); + }} + style={{ "--wails-drop-target": "drop" } as React.CSSProperties} + > +
+ +
+

+ FFmpeg is required to convert audio files. +

+

+ {isDraggingFFmpeg + ? "Drop your FFmpeg executable here" + : "Drag and drop your FFmpeg executable here, or click the button below to download automatically."} +

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Audio Converter

+
+ + {/* Drop Zone / File List */} +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + style={{ "--wails-drop-target": "drop" } as React.CSSProperties} + > + {files.length === 0 ? ( + <> +
+ +
+

+ {isDragging + ? "Drop your audio files here" + : "Drag and drop audio files here, or click the button below to select"} +

+

+ Supported formats: MP3, M4A, FLAC +

+ + + ) : ( +
+ {/* Settings Row - Only show when files exist */} +
+ {/* Format and Bitrate in one line */} +
+
+ + { + if (value) setOutputFormat(value as "mp3" | "m4a"); + }} + > + + MP3 + + + M4A + + +
+
+ + { + if (value) setBitrate(value); + }} + > + {BITRATE_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+
+ + +
+
+
+ + {/* File List Header */} +
+
+ {files.length} file(s) • {successCount} converted +
+
+ + {/* File List */} +
+ {files.map((file) => ( +
+ {getStatusIcon(file.status)} +
+

{file.name}

+ {file.error && ( +

+ {file.error} +

+ )} +
+ + {file.format} + + {file.status !== "converting" && ( + + )} +
+ ))} +
+ + {/* Convert Button */} +
+ +
+
+ )} +
+
+ ); +} + + + diff --git a/frontend/src/components/DebugLogger.tsx b/frontend/src/components/DebugLogger.tsx deleted file mode 100644 index dd3ea32..0000000 --- a/frontend/src/components/DebugLogger.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { Bug, Trash2, X, Copy, Check } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { logger, type LogEntry } from "@/lib/logger"; - -const levelColors: Record = { - info: "text-blue-500", - success: "text-green-500", - warning: "text-yellow-500", - error: "text-red-500", - debug: "text-gray-500", -}; - -function formatTime(date: Date): string { - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -export function DebugLogger() { - const [open, setOpen] = useState(false); - const [logs, setLogs] = useState([]); - const [copied, setCopied] = useState(false); - const scrollRef = useRef(null); - - useEffect(() => { - const unsubscribe = logger.subscribe(() => { - setLogs(logger.getLogs()); - }); - setLogs(logger.getLogs()); - return () => { - unsubscribe(); - }; - }, []); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [logs]); - - const handleClear = () => { - logger.clear(); - }; - - const handleCopy = async () => { - const logText = logs - .map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`) - .join("\n"); - - try { - await navigator.clipboard.writeText(logText); - setCopied(true); - setTimeout(() => setCopied(false), 500); - } catch (err) { - console.error("Failed to copy logs:", err); - } - }; - - return ( - - - - - - Debug Logs -
- - - -
-
- {logs.length === 0 ? ( -

no logs yet...

- ) : ( - logs.map((log, i) => ( -
- - [{formatTime(log.timestamp)}] - - - [{log.level}] - - {log.message} -
- )) - )} -
-
-
- ); -} diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index 3c0cfaa..bc5f1b2 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, ImageDown } from "lucide-react"; +import { Download, FolderOpen, ImageDown, FileText } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { SearchAndSort } from "./SearchAndSort"; @@ -50,6 +50,7 @@ interface PlaylistInfoProps { skippedCovers?: Set; downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; + isBulkDownloadingLyrics?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (isrc: string) => void; @@ -58,6 +59,7 @@ interface PlaylistInfoProps { onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onCheckAvailability?: (spotifyId: string) => void; + onDownloadAllLyrics?: () => void; onDownloadAllCovers?: () => void; onDownloadAll: () => void; onDownloadSelected: () => void; @@ -96,6 +98,7 @@ export function PlaylistInfo({ skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, + isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, @@ -104,6 +107,7 @@ export function PlaylistInfo({ onDownloadLyrics, onDownloadCover, onCheckAvailability, + onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, @@ -161,6 +165,22 @@ export function PlaylistInfo({ Download Selected ({selectedTracks.length}) )} + {onDownloadAllLyrics && ( + + + + + +

Download All Lyrics

+
+
+ )} {onDownloadAllCovers && ( diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx deleted file mode 100644 index 5101547..0000000 --- a/frontend/src/components/Settings.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { InputWithContext } from "@/components/ui/input-with-context"; -import { Label } from "@/components/ui/label"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react"; -import { Switch } from "@/components/ui/switch"; -import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FolderPreset, type FilenamePreset } from "@/lib/settings"; -import { themes, applyTheme } from "@/lib/themes"; -import { SelectFolder } from "../../wailsjs/go/main/App"; - -// Service Icons -const TidalIcon = () => ( - - - - -); - -const DeezerIcon = () => ( - - - -); - -const QobuzIcon = () => ( - - - - -); - -const AmazonIcon = () => ( - - - - -); - -export function Settings() { - const [open, setOpen] = useState(false); - const [savedSettings, setSavedSettings] = useState(getSettings()); - const [tempSettings, setTempSettings] = useState(savedSettings); - const [, setIsLoadingDefaults] = useState(false); - const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); - - // Apply saved settings - useEffect(() => { - applyThemeMode(savedSettings.themeMode); - applyTheme(savedSettings.theme); - - // Setup listener for system theme changes - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - if (savedSettings.themeMode === "auto") { - applyThemeMode("auto"); - applyTheme(savedSettings.theme); - } - }; - - mediaQuery.addEventListener("change", handleChange); - - return () => { - mediaQuery.removeEventListener("change", handleChange); - }; - }, [savedSettings.themeMode, savedSettings.theme]); - - // Apply temp settings for preview when dialog is open - useEffect(() => { - if (open) { - applyThemeMode(tempSettings.themeMode); - applyTheme(tempSettings.theme); - - // Update isDark state after theme is applied - setTimeout(() => { - setIsDark(document.documentElement.classList.contains('dark')); - }, 0); - - // Setup listener for system theme changes during preview - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - if (tempSettings.themeMode === "auto") { - applyThemeMode("auto"); - applyTheme(tempSettings.theme); - setTimeout(() => { - setIsDark(document.documentElement.classList.contains('dark')); - }, 0); - } - }; - - mediaQuery.addEventListener("change", handleChange); - - return () => { - mediaQuery.removeEventListener("change", handleChange); - }; - } - }, [open, tempSettings.themeMode, tempSettings.theme]); - - useEffect(() => { - // Load settings with defaults from backend on mount - const loadDefaults = async () => { - if (!savedSettings.downloadPath) { - setIsLoadingDefaults(true); - const settingsWithDefaults = await getSettingsWithDefaults(); - setSavedSettings(settingsWithDefaults); - setTempSettings(settingsWithDefaults); - setIsLoadingDefaults(false); - } - }; - loadDefaults(); - }, []); - - // Reset temp settings when dialog opens - useEffect(() => { - if (open) { - setTempSettings(savedSettings); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - - const handleSave = () => { - saveSettings(tempSettings); - setSavedSettings(tempSettings); - setOpen(false); - }; - - const handleReset = async () => { - const defaultSettings = await resetToDefaultSettings(); - setTempSettings(defaultSettings); - setSavedSettings(defaultSettings); - - // Apply default theme mode and theme - applyThemeMode(defaultSettings.themeMode); - applyTheme(defaultSettings.theme); - }; - - const handleCancel = () => { - // Revert to saved settings - applyThemeMode(savedSettings.themeMode); - applyTheme(savedSettings.theme); - - setTempSettings(savedSettings); - setOpen(false); - }; - - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen) { - // Dialog is closing, revert to saved settings - applyThemeMode(savedSettings.themeMode); - applyTheme(savedSettings.theme); - setTempSettings(savedSettings); - } - setOpen(newOpen); - }; - - const handleDownloadPathChange = (value: string) => { - setTempSettings((prev) => ({ ...prev, downloadPath: value })); - }; - - const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => { - setTempSettings((prev) => ({ ...prev, downloader: value })); - }; - - const handleThemeChange = (value: string) => { - setTempSettings((prev) => ({ ...prev, theme: value })); - }; - - const handleThemeModeChange = (value: "auto" | "light" | "dark") => { - setTempSettings((prev) => ({ ...prev, themeMode: value })); - }; - - const handleBrowseFolder = async () => { - try { - // Call backend to open folder selection dialog - const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); - - if (selectedPath && selectedPath.trim() !== "") { - setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); - } - } catch (error) { - console.error("Error selecting folder:", error); - alert(`Error selecting folder: ${error}`); - } - }; - - return ( - - - - - -
- -
- Settings -
- {/* Left Column */} -
- {/* Download Path */} -
- -
- handleDownloadPathChange(e.target.value)} - placeholder="C:\Users\YourUsername\Music" - /> - -
-
- - {/* Source Selection */} -
- - -
- - {/* Theme Mode Selection */} -
- - -
- - {/* Accent Selection */} -
- - -
-
- - {/* Right Column */} -
- {/* Folder Structure */} -
-
- - - - - - -

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

-
-
-
- - {tempSettings.folderPreset === "custom" && ( - setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} - placeholder="{artist}/{album}" - className="h-9 text-sm" - /> - )} - {tempSettings.folderTemplate && ( -

- Preview: {tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/ -

- )} -
- -
- - {/* Filename Format */} -
-
- - - - - - -

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

-
-
-
- - {tempSettings.filenamePreset === "custom" && ( - setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} - placeholder="{track}. {title}" - className="h-9 text-sm" - /> - )} - {tempSettings.filenameTemplate && ( -

- Preview: {tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac -

- )} -
- -
- - {/* Sound Effects */} -
-
- - -
- setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))} - /> -
-
-
- - -
- - -
-
- -
- ); -} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index e9da0cb..71adde1 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react"; +import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { themes, applyTheme } from "@/lib/themes"; @@ -139,34 +139,6 @@ export function SettingsPage() {
- {/* Source Selection */} -
- - -
- {/* Theme Mode */}
@@ -232,10 +204,60 @@ export function SettingsPage() {
+ + {/* Sound Effects */} +
+ + setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))} + /> +
{/* Right Column */}
+ {/* Source Selection */} +
+ + +
+ + {/* Embed Lyrics */} +
+ + setTempSettings(prev => ({ ...prev, embedLyrics: checked }))} + /> +
+ +
+ {/* Folder Structure */}
@@ -333,19 +355,6 @@ export function SettingsPage() {

)}
- -
- - {/* Sound Effects */} -
- - - setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))} - /> -
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0de6efc..a673e59 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, Settings, Bug, Activity, LayoutGrid } from "lucide-react"; +import { Home, Settings, Bug, Activity, FileMusic, LayoutGrid } from "lucide-react"; import { Tooltip, TooltipContent, @@ -7,7 +7,7 @@ import { import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; -export type PageType = "main" | "settings" | "debug" | "audio-analysis"; +export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter"; interface SidebarProps { currentPage: PageType; @@ -19,6 +19,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { { id: "main" as PageType, icon: Home, label: "Home" }, { id: "settings" as PageType, icon: Settings, label: "Settings" }, { id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" }, + { id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" }, { id: "debug" as PageType, icon: Bug, label: "Debug Logs" }, ]; diff --git a/frontend/src/components/ui/toggle-group.tsx b/frontend/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..24a4850 --- /dev/null +++ b/frontend/src/components/ui/toggle-group.tsx @@ -0,0 +1,83 @@ +"use client" + +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + } +>({ + size: "default", + variant: "default", + spacing: 0, +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + children, + ...props +}: React.ComponentProps & + VariantProps & { + spacing?: number + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/frontend/src/components/ui/toggle.tsx b/frontend/src/components/ui/toggle.tsx new file mode 100644 index 0000000..94ec8f5 --- /dev/null +++ b/frontend/src/components/ui/toggle.tsx @@ -0,0 +1,47 @@ +"use client" + +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 6074d47..4a3714a 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -111,6 +111,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, service_url: streamingURLs.tidal_url, duration: durationSeconds, item_id: itemID, // Pass the same itemID through all attempts @@ -143,6 +144,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, service_url: streamingURLs.deezer_url, item_id: itemID, }); @@ -174,6 +176,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, service_url: streamingURLs.amazon_url, item_id: itemID, }); @@ -202,10 +205,11 @@ export function useDownload() { track_number: settings.trackNumber, position, use_album_track_number: useAlbumTrackNumber, - spotify_id: spotifyId, - duration: durationMs ? Math.round(durationMs / 1000) : undefined, - item_id: itemID, - }); + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + duration: durationMs ? Math.round(durationMs / 1000) : undefined, + item_id: itemID, + }); // If Qobuz also failed, mark the item as failed if (!qobuzResponse.success) { @@ -233,6 +237,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, duration: durationSecondsForFallback, item_id: itemID, // Pass itemID for tracking }); @@ -335,6 +340,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, service_url: streamingURLs.tidal_url, duration: durationSeconds, item_id: itemID, @@ -364,6 +370,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, service_url: streamingURLs.deezer_url, item_id: itemID, }); @@ -392,6 +399,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, service_url: streamingURLs.amazon_url, item_id: itemID, }); @@ -417,10 +425,11 @@ export function useDownload() { track_number: settings.trackNumber, position, use_album_track_number: useAlbumTrackNumber, - spotify_id: spotifyId, - duration: durationMs ? Math.round(durationMs / 1000) : undefined, - item_id: itemID, - }); + spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, + duration: durationMs ? Math.round(durationMs / 1000) : undefined, + item_id: itemID, + }); // If Qobuz also failed, mark the item as failed if (!qobuzResponse.success) { @@ -447,6 +456,7 @@ export function useDownload() { position, use_album_track_number: useAlbumTrackNumber, spotify_id: spotifyId, + embed_lyrics: settings.embedLyrics, duration: durationSecondsForFallback, item_id: itemID, }); diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts index 4a4abc8..637806d 100644 --- a/frontend/src/hooks/useLyrics.ts +++ b/frontend/src/hooks/useLyrics.ts @@ -1,15 +1,19 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { downloadLyrics } from "@/lib/api"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath } from "@/lib/utils"; import { logger } from "@/lib/logger"; +import type { TrackMetadata } from "@/types/api"; export function useLyrics() { const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState(null); const [downloadedLyrics, setDownloadedLyrics] = useState>(new Set()); const [failedLyrics, setFailedLyrics] = useState>(new Set()); const [skippedLyrics, setSkippedLyrics] = useState>(new Set()); + const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false); + const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0); + const stopBulkDownloadRef = useRef(false); const handleDownloadLyrics = async ( spotifyId: string, @@ -95,6 +99,122 @@ export function useLyrics() { } }; + const handleDownloadAllLyrics = async ( + tracks: TrackMetadata[], + playlistName?: string, + _isArtistDiscography?: boolean + ) => { + const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id); + + if (tracksWithSpotifyId.length === 0) { + toast.error("No tracks with Spotify ID available for lyrics download"); + return; + } + + const settings = getSettings(); + setIsBulkDownloadingLyrics(true); + setLyricsDownloadProgress(0); + stopBulkDownloadRef.current = false; + + let completed = 0; + let success = 0; + let failed = 0; + let skipped = 0; + const total = tracksWithSpotifyId.length; + + for (const track of tracksWithSpotifyId) { + if (stopBulkDownloadRef.current) { + toast.info("Lyrics download stopped by user"); + break; + } + + const id = track.spotify_id!; + setDownloadingLyricsTrack(id); + setLyricsDownloadProgress(Math.round((completed / total) * 100)); + + try { + const os = settings.operatingSystem; + let outputDir = settings.downloadPath; + + // Build output path using template system + const templateData: TemplateData = { + artist: track.artists, + album: track.album_name, + title: track.name, + track: track.track_number, + playlist: playlistName, + }; + + // For playlist/discography, prepend the folder name + if (playlistName) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); + } + + // Apply folder template + if (settings.folderTemplate) { + const folderPath = parseTemplate(settings.folderTemplate, templateData); + if (folderPath) { + const parts = folderPath.split("/").filter((p: string) => p.trim()); + for (const part of parts) { + outputDir = joinPath(os, outputDir, sanitizePath(part, os)); + } + } + } + + const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; + + const response = await downloadLyrics({ + spotify_id: id, + track_name: track.name, + artist_name: track.artists, + output_dir: outputDir, + filename_format: settings.filenameTemplate || "{title}", + track_number: settings.trackNumber, + position: track.track_number || 0, + use_album_track_number: useAlbumTrackNumber, + }); + + if (response.success) { + if (response.already_exists) { + skipped++; + setSkippedLyrics((prev) => new Set(prev).add(id)); + } else { + success++; + setDownloadedLyrics((prev) => new Set(prev).add(id)); + } + setFailedLyrics((prev) => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); + } else { + failed++; + setFailedLyrics((prev) => new Set(prev).add(id)); + } + } catch (err) { + failed++; + logger.error(`error downloading lyrics: ${track.name} - ${err}`); + setFailedLyrics((prev) => new Set(prev).add(id)); + } + + completed++; + } + + setDownloadingLyricsTrack(null); + setIsBulkDownloadingLyrics(false); + setLyricsDownloadProgress(0); + + if (!stopBulkDownloadRef.current) { + toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`); + } + }; + + const handleStopLyricsDownload = () => { + logger.info("lyrics download stopped by user"); + stopBulkDownloadRef.current = true; + toast.info("Stopping lyrics download..."); + }; + const resetLyricsState = () => { setDownloadedLyrics(new Set()); setFailedLyrics(new Set()); @@ -106,7 +226,11 @@ export function useLyrics() { downloadedLyrics, failedLyrics, skippedLyrics, + isBulkDownloadingLyrics, + lyricsDownloadProgress, handleDownloadLyrics, + handleDownloadAllLyrics, + handleStopLyricsDownload, resetLyricsState, }; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 1923432..3902da5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,19 +26,6 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); } :root { @@ -61,19 +48,6 @@ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); } .dark { @@ -95,19 +69,6 @@ --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); } @layer base { diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 71ace7a..cb48cef 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -25,6 +25,7 @@ export interface Settings { albumSubfolder?: boolean; trackNumber: boolean; sfxEnabled: boolean; + embedLyrics: boolean; operatingSystem: "Windows" | "linux/MacOS" } @@ -81,6 +82,7 @@ export const DEFAULT_SETTINGS: Settings = { filenameTemplate: "{title} - {artist}", trackNumber: false, sfxEnabled: true, + embedLyrics: false, operatingSystem: detectOS() }; diff --git a/frontend/src/lib/themes.ts b/frontend/src/lib/themes.ts index e4f548a..3f3deea 100644 --- a/frontend/src/lib/themes.ts +++ b/frontend/src/lib/themes.ts @@ -10,7 +10,7 @@ export interface Theme { export const themes: Theme[] = [ { name: "neutral", - label: "Default", + label: "Neutral", cssVars: { light: { background: "oklch(1 0 0)", @@ -31,11 +31,6 @@ export const themes: Theme[] = [ border: "oklch(0.922 0 0)", input: "oklch(0.922 0 0)", ring: "oklch(0.708 0 0)", - "chart-1": "oklch(0.646 0.222 41.116)", - "chart-2": "oklch(0.6 0.118 184.704)", - "chart-3": "oklch(0.398 0.07 227.392)", - "chart-4": "oklch(0.828 0.189 84.429)", - "chart-5": "oklch(0.769 0.188 70.08)", }, dark: { background: "oklch(0.145 0 0)", @@ -56,11 +51,6 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.556 0 0)", - "chart-1": "oklch(0.488 0.243 264.376)", - "chart-2": "oklch(0.696 0.17 162.48)", - "chart-3": "oklch(0.769 0.188 70.08)", - "chart-4": "oklch(0.627 0.265 303.9)", - "chart-5": "oklch(0.645 0.246 16.439)", }, }, }, @@ -87,11 +77,6 @@ export const themes: Theme[] = [ border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.708 0 0)", - "chart-1": "oklch(0.809 0.105 251.813)", - "chart-2": "oklch(0.623 0.214 259.815)", - "chart-3": "oklch(0.546 0.245 262.881)", - "chart-4": "oklch(0.488 0.243 264.376)", - "chart-5": "oklch(0.424 0.199 265.638)", }, dark: { background: "oklch(0.141 0.005 285.823)", @@ -112,11 +97,6 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.556 0 0)", - "chart-1": "oklch(0.809 0.105 251.813)", - "chart-2": "oklch(0.623 0.214 259.815)", - "chart-3": "oklch(0.546 0.245 262.881)", - "chart-4": "oklch(0.488 0.243 264.376)", - "chart-5": "oklch(0.424 0.199 265.638)", }, }, }, @@ -143,11 +123,6 @@ export const themes: Theme[] = [ border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.841 0.238 128.85)", - "chart-1": "oklch(0.871 0.15 154.449)", - "chart-2": "oklch(0.723 0.219 149.579)", - "chart-3": "oklch(0.627 0.194 149.214)", - "chart-4": "oklch(0.527 0.154 150.069)", - "chart-5": "oklch(0.448 0.119 151.328)", }, dark: { background: "oklch(0.141 0.005 285.823)", @@ -168,11 +143,6 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.405 0.101 131.063)", - "chart-1": "oklch(0.871 0.15 154.449)", - "chart-2": "oklch(0.723 0.219 149.579)", - "chart-3": "oklch(0.627 0.194 149.214)", - "chart-4": "oklch(0.527 0.154 150.069)", - "chart-5": "oklch(0.448 0.119 151.328)", }, }, }, @@ -199,11 +169,6 @@ export const themes: Theme[] = [ border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.75 0.183 55.934)", - "chart-1": "oklch(0.837 0.128 66.29)", - "chart-2": "oklch(0.705 0.213 47.604)", - "chart-3": "oklch(0.646 0.222 41.116)", - "chart-4": "oklch(0.553 0.195 38.402)", - "chart-5": "oklch(0.47 0.157 37.304)", }, dark: { background: "oklch(0.141 0.005 285.823)", @@ -224,11 +189,6 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.408 0.123 38.172)", - "chart-1": "oklch(0.837 0.128 66.29)", - "chart-2": "oklch(0.705 0.213 47.604)", - "chart-3": "oklch(0.646 0.222 41.116)", - "chart-4": "oklch(0.553 0.195 38.402)", - "chart-5": "oklch(0.47 0.157 37.304)", }, }, }, @@ -255,11 +215,6 @@ export const themes: Theme[] = [ border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.704 0.191 22.216)", - "chart-1": "oklch(0.808 0.114 19.571)", - "chart-2": "oklch(0.637 0.237 25.331)", - "chart-3": "oklch(0.577 0.245 27.325)", - "chart-4": "oklch(0.505 0.213 27.518)", - "chart-5": "oklch(0.444 0.177 26.899)", }, dark: { background: "oklch(0.141 0.005 285.823)", @@ -280,11 +235,6 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.396 0.141 25.723)", - "chart-1": "oklch(0.808 0.114 19.571)", - "chart-2": "oklch(0.637 0.237 25.331)", - "chart-3": "oklch(0.577 0.245 27.325)", - "chart-4": "oklch(0.505 0.213 27.518)", - "chart-5": "oklch(0.444 0.177 26.899)", }, }, }, @@ -311,11 +261,6 @@ export const themes: Theme[] = [ border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.712 0.194 13.428)", - "chart-1": "oklch(0.81 0.117 11.638)", - "chart-2": "oklch(0.645 0.246 16.439)", - "chart-3": "oklch(0.586 0.253 17.585)", - "chart-4": "oklch(0.514 0.222 16.935)", - "chart-5": "oklch(0.455 0.188 13.697)", }, dark: { background: "oklch(0.141 0.005 285.823)", @@ -336,11 +281,6 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.41 0.159 10.272)", - "chart-1": "oklch(0.81 0.117 11.638)", - "chart-2": "oklch(0.645 0.246 16.439)", - "chart-3": "oklch(0.586 0.253 17.585)", - "chart-4": "oklch(0.514 0.222 16.935)", - "chart-5": "oklch(0.455 0.188 13.697)", }, }, }, @@ -367,11 +307,6 @@ export const themes: Theme[] = [ border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.702 0.183 293.541)", - "chart-1": "oklch(0.811 0.111 293.571)", - "chart-2": "oklch(0.606 0.25 292.717)", - "chart-3": "oklch(0.541 0.281 293.009)", - "chart-4": "oklch(0.491 0.27 292.581)", - "chart-5": "oklch(0.432 0.232 292.759)", }, dark: { background: "oklch(0.141 0.005 285.823)", @@ -392,11 +327,6 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.38 0.189 293.745)", - "chart-1": "oklch(0.811 0.111 293.571)", - "chart-2": "oklch(0.606 0.25 292.717)", - "chart-3": "oklch(0.541 0.281 293.009)", - "chart-4": "oklch(0.491 0.27 292.581)", - "chart-5": "oklch(0.432 0.232 292.759)", }, }, }, @@ -423,11 +353,6 @@ export const themes: Theme[] = [ border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.852 0.199 91.936)", - "chart-1": "oklch(0.905 0.182 98.111)", - "chart-2": "oklch(0.795 0.184 86.047)", - "chart-3": "oklch(0.681 0.162 75.834)", - "chart-4": "oklch(0.554 0.135 66.442)", - "chart-5": "oklch(0.476 0.114 61.907)", }, dark: { background: "oklch(0.141 0.005 285.823)", @@ -448,15 +373,470 @@ export const themes: Theme[] = [ border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.421 0.095 57.708)", - "chart-1": "oklch(0.905 0.182 98.111)", - "chart-2": "oklch(0.795 0.184 86.047)", - "chart-3": "oklch(0.681 0.162 75.834)", - "chart-4": "oklch(0.554 0.135 66.442)", - "chart-5": "oklch(0.476 0.114 61.907)", }, }, }, -]; + { + name: "amber", + label: "Amber", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.75 0.15 70)", + "primary-foreground": "oklch(0.15 0.01 70)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.75 0.15 70)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.85 0.15 70)", + "primary-foreground": "oklch(0.15 0.01 70)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.85 0.15 70)", + }, + }, + }, + { + name: "cyan", + label: "Cyan", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.55 0.22 200)", + "primary-foreground": "oklch(0.98 0.01 200)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.55 0.22 200)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.65 0.22 200)", + "primary-foreground": "oklch(0.15 0.01 200)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.65 0.22 200)", + }, + }, + }, + { + name: "emerald", + label: "Emerald", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.55 0.18 160)", + "primary-foreground": "oklch(0.98 0.01 160)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.55 0.18 160)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.65 0.18 160)", + "primary-foreground": "oklch(0.15 0.01 160)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.65 0.18 160)", + }, + }, + }, + { + name: "fuchsia", + label: "Fuchsia", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.6 0.25 320)", + "primary-foreground": "oklch(0.98 0.01 320)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.6 0.25 320)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.7 0.25 320)", + "primary-foreground": "oklch(0.15 0.01 320)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.7 0.25 320)", + }, + }, + }, + { + name: "indigo", + label: "Indigo", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.5 0.22 270)", + "primary-foreground": "oklch(0.98 0.01 270)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.5 0.22 270)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.6 0.22 270)", + "primary-foreground": "oklch(0.15 0.01 270)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.6 0.22 270)", + }, + }, + }, + { + name: "lime", + label: "Lime", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.75 0.2 120)", + "primary-foreground": "oklch(0.2 0.01 120)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.75 0.2 120)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.8 0.2 120)", + "primary-foreground": "oklch(0.2 0.01 120)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.8 0.2 120)", + }, + }, + }, + { + name: "pink", + label: "Pink", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.6 0.22 350)", + "primary-foreground": "oklch(0.98 0.01 350)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.6 0.22 350)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.7 0.22 350)", + "primary-foreground": "oklch(0.15 0.01 350)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.7 0.22 350)", + }, + }, + }, + { + name: "purple", + label: "Purple", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.55 0.25 280)", + "primary-foreground": "oklch(0.98 0.01 280)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.55 0.25 280)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.65 0.25 280)", + "primary-foreground": "oklch(0.15 0.01 280)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.65 0.25 280)", + }, + }, + }, + { + name: "sky", + label: "Sky", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.6 0.18 210)", + "primary-foreground": "oklch(0.98 0.01 210)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.6 0.18 210)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.7 0.18 210)", + "primary-foreground": "oklch(0.15 0.01 210)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.7 0.18 210)", + }, + }, + }, + { + name: "teal", + label: "Teal", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.55 0.2 180)", + "primary-foreground": "oklch(0.98 0.01 180)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.55 0.2 180)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.65 0.2 180)", + "primary-foreground": "oklch(0.15 0.01 180)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.65 0.2 180)", + }, + }, + }, +].sort((a, b) => a.name.localeCompare(b.name)); export function applyTheme(themeName: string) { const theme = themes.find((t) => t.name === themeName) || themes[0]; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 16f77c1..30085b2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -123,6 +123,7 @@ export interface DownloadRequest { position?: number; use_album_track_number?: boolean; spotify_id?: string; + embed_lyrics?: boolean; // Whether to embed lyrics into the audio file service_url?: string; duration?: number; // Track duration in seconds for better matching item_id?: string; // Optional queue item ID for multi-service fallback tracking diff --git a/go.mod b/go.mod index 80b70ce..7e447a1 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module spotiflac go 1.25.4 require ( + github.com/bogem/id3v2/v2 v2.1.4 github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 github.com/mewkiz/flac v1.0.13 + github.com/ulikunitz/xz v0.5.15 github.com/wailsapp/wails/v2 v2.11.0 ) diff --git a/go.sum b/go.sum index bb640ea..bf3672d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= +github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= @@ -64,6 +66,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -74,22 +78,43 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tidal.json b/tidal.json index 3e4d651..91bea55 100644 --- a/tidal.json +++ b/tidal.json @@ -10,13 +10,5 @@ "tidal-api.binimum.org", "tidal-api-2.binimum.org", "dev-api.squid.wtf", - "triton.squid.wtf", - "ohio.monochrome.tf", - "virginia.monochrome.tf", - "oregon.monochrome.tf", - "california.monochrome.tf", - "frankfurt.monochrome.tf", - "london.monochrome.tf", - "singapore.monochrome.tf", - "jakarta.monochrome.tf" + "triton.squid.wtf" ] diff --git a/version.json b/version.json deleted file mode 100644 index 88a5287..0000000 --- a/version.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": "6.7" -} diff --git a/wails.json b/wails.json index 27c5777..b4c537f 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "6.7" + "productVersion": "6.8" }, "wailsjsdir": "./frontend", "assetdir": "./frontend/dist",