diff --git a/app.go b/app.go index 86cddbf..0e04fe6 100644 --- a/app.go +++ b/app.go @@ -1049,3 +1049,7 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR func (a *App) SkipDownloadItem(itemID, filePath string) { backend.SkipDownloadItem(itemID, filePath) } + +func (a *App) GetPreviewURL(trackID string) (string, error) { + return backend.GetPreviewURL(trackID) +} diff --git a/backend/lyrics.go b/backend/lyrics.go index db44f1f..4fe542c 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -403,6 +403,25 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa outputDir = NormalizePath(outputDir) } + safeArtist := sanitizeFilename(req.AlbumArtist) + if safeArtist == "" { + safeArtist = sanitizeFilename(req.ArtistName) + } + safeAlbum := sanitizeFilename(req.AlbumName) + + if safeArtist != "" && safeAlbum != "" { + artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum) + if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() { + outputDir = artistAlbumPath + } else { + + artistPath := filepath.Join(outputDir, safeArtist) + if info, err := os.Stat(artistPath); err == nil && info.IsDir() { + outputDir = artistPath + } + } + } + if err := os.MkdirAll(outputDir, 0755); err != nil { return &LyricsDownloadResponse{ Success: false, diff --git a/backend/spotfetch.go b/backend/spotfetch.go index 71054fc..a8922b5 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -1049,6 +1049,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { albumData := getMap(trackData, "albumOfTrack") albumName := "" albumID := "" + albumArtistsString := "" var trackCover interface{} if len(albumData) > 0 { @@ -1069,19 +1070,29 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { trackCover = getString(coverObj, "large") } } + + albumArtists := extractArtists(getMap(albumData, "artists")) + if len(albumArtists) > 0 { + albumArtistNames := []string{} + for _, artist := range albumArtists { + albumArtistNames = append(albumArtistNames, getString(artist, "name")) + } + albumArtistsString = strings.Join(albumArtistNames, ", ") + } } trackInfo := map[string]interface{}{ - "id": trackID, - "cover": trackCover, - "title": getString(trackData, "name"), - "artist": artistsString, - "artistIds": artistIDs, - "plays": rank, - "status": status, - "album": albumName, - "albumId": albumID, - "duration": durationString, + "id": trackID, + "cover": trackCover, + "title": getString(trackData, "name"), + "artist": artistsString, + "artistIds": artistIDs, + "plays": rank, + "status": status, + "album": albumName, + "albumArtist": albumArtistsString, + "albumId": albumID, + "duration": durationString, } tracks = append(tracks, trackInfo) } diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index af67aac..ecbeff2 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -5,8 +5,10 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" + "regexp" "strconv" "strings" "time" @@ -44,6 +46,7 @@ type TrackMetadata struct { Copyright string `json:"copyright,omitempty"` Publisher string `json:"publisher,omitempty"` Plays string `json:"plays,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` } type ArtistSimple struct { @@ -75,6 +78,7 @@ type AlbumTrackMetadata struct { ArtistsData []ArtistSimple `json:"artists_data,omitempty"` Plays string `json:"plays,omitempty"` Status string `json:"status,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` } type TrackResponse struct { @@ -225,16 +229,17 @@ type apiPlaylistResponse struct { Count int `json:"count"` Followers int `json:"followers"` Tracks []struct { - ID string `json:"id"` - Cover string `json:"cover"` - Title string `json:"title"` - Artist string `json:"artist"` - ArtistIds []string `json:"artistIds"` - Plays string `json:"plays"` - Status string `json:"status"` - Album string `json:"album"` - AlbumID string `json:"albumId"` - Duration string `json:"duration"` + ID string `json:"id"` + Cover string `json:"cover"` + Title string `json:"title"` + Artist string `json:"artist"` + ArtistIds []string `json:"artistIds"` + Plays string `json:"plays"` + Status string `json:"status"` + Album string `json:"album"` + AlbumArtist string `json:"albumArtist"` + AlbumID string `json:"albumId"` + Duration string `json:"duration"` } `json:"tracks"` } @@ -942,7 +947,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla Artists: item.Artist, Name: item.Title, AlbumName: item.Album, - AlbumArtist: item.Artist, + AlbumArtist: item.AlbumArtist, DurationMS: durationMS, Images: item.Cover, ReleaseDate: "", @@ -1400,3 +1405,37 @@ func SearchSpotifyByType(ctx context.Context, query string, searchType string, l client := NewSpotifyMetadataClient() return client.SearchByType(ctx, query, searchType, limit, offset) } + +func GetPreviewURL(trackID string) (string, error) { + if trackID == "" { + return "", errors.New("track ID cannot be empty") + } + + embedURL := fmt.Sprintf("https://open.spotify.com/embed/track/%s", trackID) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(embedURL) + if err != nil { + return "", fmt.Errorf("failed to fetch embed page: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("embed page returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + html := string(body) + re := regexp.MustCompile(`https://p\.scdn\.co/mp3-preview/[a-zA-Z0-9]+`) + match := re.FindString(html) + + if match == "" { + return "", errors.New("preview URL not found") + } + + return match, nil +} diff --git a/frontend/package.json b/frontend/package.json index 45d3cf8..694c143 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", - "motion": "^12.25.0", + "motion": "^12.26.1", "next-themes": "^0.4.6", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -37,7 +37,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@types/node": "^25.0.6", + "@types/node": "^25.0.7", "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", @@ -48,7 +48,7 @@ "sharp": "^0.34.5", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.52.0", + "typescript-eslint": "^8.53.0", "vite": "^7.3.1" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index e0bb2c9..6876de5 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -6f2a6dc27f7d8d215283f6d07b4eaa54 \ No newline at end of file +65caca63c4f7ac1740046770c7a945b0 \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9d25865..be0ff33 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -54,8 +54,8 @@ importers: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) motion: - specifier: ^12.25.0 - version: 12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^12.26.1 + version: 12.26.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -79,8 +79,8 @@ importers: specifier: ^9.39.2 version: 9.39.2 '@types/node': - specifier: ^25.0.6 - version: 25.0.6 + specifier: ^25.0.7 + version: 25.0.7 '@types/react': specifier: ^19.2.8 version: 19.2.8 @@ -89,7 +89,7 @@ importers: version: 19.2.3(@types/react@19.2.8) '@vitejs/plugin-react': specifier: ^5.1.2 - version: 5.1.2(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 5.1.2(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2)) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -112,50 +112,50 @@ importers: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.52.0 - version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.53.0 + version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2) + version: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2) packages: - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.5': - resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': @@ -170,12 +170,12 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -191,16 +191,16 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} '@emnapi/runtime@1.8.1': @@ -1259,8 +1259,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.0.6': - resolution: {integrity: sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==} + '@types/node@25.0.7': + resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1270,63 +1270,63 @@ packages: '@types/react@19.2.8': resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} - '@typescript-eslint/eslint-plugin@8.52.0': - resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} + '@typescript-eslint/eslint-plugin@8.53.0': + resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.52.0 + '@typescript-eslint/parser': ^8.53.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.52.0': - resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==} + '@typescript-eslint/parser@8.53.0': + resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==} 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.52.0': - resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==} + '@typescript-eslint/project-service@8.53.0': + resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.52.0': - resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==} + '@typescript-eslint/scope-manager@8.53.0': + resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.52.0': - resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==} + '@typescript-eslint/tsconfig-utils@8.53.0': + resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.52.0': - resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==} + '@typescript-eslint/type-utils@8.53.0': + resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==} 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.52.0': - resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} + '@typescript-eslint/types@8.53.0': + resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.52.0': - resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==} + '@typescript-eslint/typescript-estree@8.53.0': + resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.52.0': - resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==} + '@typescript-eslint/utils@8.53.0': + resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==} 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.52.0': - resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==} + '@typescript-eslint/visitor-keys@8.53.0': + resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@5.1.2': @@ -1540,8 +1540,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - framer-motion@12.25.0: - resolution: {integrity: sha512-mlWqd0rApIjeyhTCSNCqPYsUAEhkcUukZxH3ke6KbstBRPcxhEpuIjmiUQvB+1E9xkEm5SpNHBgHCapH/QHTWg==} + framer-motion@12.26.1: + resolution: {integrity: sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1757,8 +1757,8 @@ packages: motion-utils@12.24.10: resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==} - motion@12.25.0: - resolution: {integrity: sha512-jBFohEYklpZ+TL64zv03sHdqr1Tsc8/yDy7u68hVzi7hTJYtv53AduchqCiY3aWi4vY1hweS8DWtgCuckusYdQ==} + motion@12.26.1: + resolution: {integrity: sha512-IVhzx9HOQTiJ9ykthMOlZPnLwrkXziN5Q/yebsqBYlFJb2rHP8yhmKc8O/YUT9byPJlxOeqkzfNYCrVKZx8vqg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1958,8 +1958,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.52.0: - resolution: {integrity: sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==} + typescript-eslint@8.53.0: + resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2069,25 +2069,25 @@ packages: snapshots: - '@babel/code-frame@7.27.1': + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.5': {} + '@babel/compat-data@7.28.6': {} - '@babel/core@7.28.5': + '@babel/core@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -2097,17 +2097,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.5': + '@babel/generator@7.28.6': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.28.5 + '@babel/compat-data': 7.28.6 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.1 lru-cache: 5.1.1 @@ -2115,23 +2115,23 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} @@ -2139,44 +2139,44 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.4': + '@babel/helpers@7.28.6': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 - '@babel/parser@7.28.5': + '@babel/parser@7.28.6': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/template@7.27.2': + '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 - '@babel/traverse@7.28.5': + '@babel/traverse@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': + '@babel/types@7.28.6': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 @@ -3016,39 +3016,39 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2) '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.6 '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} - '@types/node@25.0.6': + '@types/node@25.0.7': dependencies: undici-types: 7.16.0 @@ -3060,14 +3060,14 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.52.0 - '@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.52.0 + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.53.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -3076,41 +3076,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.52.0 - '@typescript-eslint/types': 8.52.0 - '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.52.0 + '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.53.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.52.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) - '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.52.0': + '@typescript-eslint/scope-manager@8.53.0': dependencies: - '@typescript-eslint/types': 8.52.0 - '@typescript-eslint/visitor-keys': 8.52.0 + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/visitor-keys': 8.53.0 - '@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.52.0 - '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -3118,14 +3118,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.52.0': {} + '@typescript-eslint/types@8.53.0': {} - '@typescript-eslint/typescript-estree@8.52.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.52.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) - '@typescript-eslint/types': 8.52.0 - '@typescript-eslint/visitor-keys': 8.52.0 + '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3) + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/visitor-keys': 8.53.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 @@ -3135,31 +3135,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.52.0 - '@typescript-eslint/types': 8.52.0 - '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.52.0': + '@typescript-eslint/visitor-keys@8.53.0': dependencies: - '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/types': 8.53.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.7)(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) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@babel/core': 7.28.6 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6) '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color @@ -3292,8 +3292,8 @@ snapshots: eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.5 @@ -3399,7 +3399,7 @@ snapshots: flatted@3.3.3: {} - framer-motion@12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.26.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.24.11 motion-utils: 12.24.10 @@ -3560,9 +3560,9 @@ snapshots: motion-utils@12.24.10: {} - motion@12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + motion@12.26.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.26.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: react: 19.2.3 @@ -3768,12 +3768,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -3808,7 +3808,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.8 - vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -3817,7 +3817,7 @@ snapshots: rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.0.6 + '@types/node': 25.0.7 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 bed6d0b..818a75b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,7 +50,7 @@ function App() { const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "7.0.4"; + const CURRENT_VERSION = "7.0.5"; const download = useDownload(); const metadata = useMetadata(); const lyrics = useLyrics(); @@ -190,7 +190,7 @@ function App() { url: spotifyUrl, type: "album", name: album_info.name, - artist: `${album_info.total_tracks} tracks`, + artist: `${album_info.total_tracks.toLocaleString()} tracks`, image: album_info.images, }; } @@ -200,7 +200,7 @@ function App() { url: spotifyUrl, type: "playlist", name: playlist_info.owner.name, - artist: `${playlist_info.tracks.total} tracks`, + artist: `${playlist_info.tracks.total.toLocaleString()} tracks`, image: playlist_info.cover || playlist_info.owner.images || "", }; } @@ -210,7 +210,7 @@ function App() { url: spotifyUrl, type: "artist", name: artist_info.name, - artist: `${artist_info.total_albums} albums`, + artist: `${artist_info.total_albums.toLocaleString()} albums`, image: artist_info.images, }; } diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index acbb7e7..97641f7 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -90,7 +90,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT {albumInfo.release_date} - {albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"} + {albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"} @@ -101,7 +101,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT {selectedTracks.length > 0 && ()} {onDownloadAllLyrics && ( diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index 71e14e4..73a6c1c 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -415,7 +415,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort {selectedTracks.length > 0 && ()} {onDownloadAllLyrics && ( diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index c291986..de3c825 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -97,7 +97,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel - {playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"} + {playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"} {playlistInfo.followers.total.toLocaleString()} followers @@ -110,7 +110,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel {selectedTracks.length > 0 && ()} {onDownloadAllLyrics && ( diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index 92a136a..3610191 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -1,10 +1,11 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react"; +import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { usePreview } from "@/hooks/usePreview"; interface TrackInfoProps { track: TrackMetadata & { album_name: string; @@ -32,6 +33,7 @@ interface TrackInfoProps { onOpenFolder: () => void; } export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) { + const { playPreview, loadingPreview, playingTrack } = usePreview(); const formatDuration = (ms: number) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -44,96 +46,106 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded return num.toLocaleString(); }; return ( - -
-
- {track.images && (
- {track.name} -
- {formatDuration(track.duration_ms)} -
-
)} -
-
-
-
-

{track.name}

- {isSkipped ? () : isDownloaded ? () : isFailed ? () : null} -
-

{track.artists}

+ +
+
+ {track.images && (
+ {track.name} +
+ {formatDuration(track.duration_ms)}
-
-
-
-

Album

-

{track.album_name}

-
- {track.plays && (
-

Total Plays

-

{formatPlays(track.plays)}

-
)} -
-
-
-

Release Date

-

{track.release_date}

-
- {track.copyright && (
-

Copyright

-

- {track.copyright} -

-
)} -
-
- {track.isrc && (
- - {track.spotify_id && onDownloadLyrics && ( - - - - -

Download Lyric

-
-
)} - {track.images && onDownloadCover && ( - - - - -

Download Cover

-
-
)} - {track.spotify_id && onCheckAvailability && ( - - - - - {availability ? (
- - - -
) : (

Check Availability

)} -
-
)} - {isDownloaded && ()} -
)} -
+
)}
-
- ); +
+
+
+

{track.name}

+ {isSkipped ? () : isDownloaded ? () : isFailed ? () : null} +
+

{track.artists}

+
+
+
+
+

Album

+

{track.album_name}

+
+ {track.plays && (
+

Total Plays

+

{formatPlays(track.plays)}

+
)} +
+
+
+

Release Date

+

{track.release_date}

+
+ {track.copyright && (
+

Copyright

+

+ {track.copyright} +

+
)} +
+
+ {track.isrc && (
+ + {track.spotify_id && ( + + + + +

{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}

+
+
)} + {track.spotify_id && onDownloadLyrics && ( + + + + +

Download Lyric

+
+
)} + {track.images && onDownloadCover && ( + + + + +

Download Cover

+
+
)} + {track.spotify_id && onCheckAvailability && ( + + + + + {availability ? (
+ + + +
) : (

Check Availability

)} +
+
)} + {isDownloaded && ()} +
)} +
+
+ + ); } diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 0f0bd07..e89db0e 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -1,11 +1,12 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react"; +import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; +import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { usePreview } from "@/hooks/usePreview"; interface TrackListProps { tracks: TrackMetadata[]; searchQuery: string; @@ -52,6 +53,7 @@ interface TrackListProps { onTrackClick?: (track: TrackMetadata) => void; } export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) { + const { playPreview, loadingPreview, playingTrack } = usePreview(); let filteredTracks = tracks.filter((track) => { if (!searchQuery) return true; @@ -118,6 +120,35 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; const paginatedTracks = filteredTracks.slice(startIndex, endIndex); + const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => { + if (total <= 10) { + return Array.from({ length: total }, (_, i) => i + 1); + } + const pages: (number | 'ellipsis')[] = []; + pages.push(1); + if (current <= 7) { + for (let i = 2; i <= 10; i++) { + pages.push(i); + } + pages.push('ellipsis'); + pages.push(total); + } + else if (current >= total - 7) { + pages.push('ellipsis'); + for (let i = total - 9; i <= total; i++) { + pages.push(i); + } + } + else { + pages.push('ellipsis'); + pages.push(current - 1); + pages.push(current); + pages.push(current + 1); + pages.push('ellipsis'); + pages.push(total); + } + return pages; + }; const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); const allSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); @@ -135,192 +166,204 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa return num.toLocaleString(); }; return (
-
-
- - - - {showCheckboxes && ()} - - - {!hideAlbumColumn && ()} - - - - - - - {paginatedTracks.map((track, index) => ( - {showCheckboxes && ()} - + ))} + +
- onToggleSelectAll(filteredTracks)}/> - - # - - Title - - Album - - Duration - - Plays - - Actions -
- {track.isrc && ( onToggleTrack(track.isrc)}/>)} - -
- {startIndex + index + 1} - {track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && ( +
+ + + + {showCheckboxes && ()} + + + {!hideAlbumColumn && ()} + + + + + + + {paginatedTracks.map((track, index) => ( + {showCheckboxes && ()} + + - - {!hideAlbumColumn && ( + {!hideAlbumColumn && ()} - - - )} + + + - ))} - -
+ onToggleSelectAll(filteredTracks)}/> + + # + + Title + + Album + + Duration + + Plays + + Actions +
+ {track.isrc && ( onToggleTrack(track.isrc)}/>)} + +
+ {startIndex + index + 1} + {track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && ( - {track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"} - )} + {track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"} + )} +
+
+
+ {track.images && ({track.name})} +
+
+ {onTrackClick ? ( onTrackClick(track)}> + {track.name} + ) : ({track.name})} + {skippedTracks.has(track.isrc) ? () : downloadedTracks.has(track.isrc) ? () : failedTracks.has(track.isrc) ? () : null}
-
-
- {track.images && ({track.name})} -
-
- {onTrackClick ? ( onTrackClick(track)}> - {track.name} - ) : ({track.name})} - {skippedTracks.has(track.isrc) ? () : downloadedTracks.has(track.isrc) ? () : failedTracks.has(track.isrc) ? () : null} -
- - {track.artists_data && track.artists_data.length > 0 ? ((() => { + + {track.artists_data && track.artists_data.length > 0 ? ((() => { const artistNames = track.artists.split(", ").map(name => name.trim()); return artistNames.map((name, i) => { const artistData = track.artists_data![i]; const hasArtistData = artistData && artistData.id && artistData.external_urls; return ( - {onArtistClick && hasArtistData ? ( onArtistClick({ + {onArtistClick && hasArtistData ? ( onArtistClick({ id: artistData.id, name: name, external_urls: artistData.external_urls, })}> - {name} - ) : (name)} - {i < artistNames.length - 1 && ", "} - ); + {name} + ) : (name)} + {i < artistNames.length - 1 && ", "} + ); }); })()) : onArtistClick && track.artist_id && track.artist_url ? ( onArtistClick({ id: track.artist_id!, name: track.artists, external_urls: track.artist_url!, })}> - {track.artists} - ) : (track.artists)} - -
-
-
- {onAlbumClick && track.album_id && track.album_url ? ( onAlbumClick({ + {track.artists} + ) : (track.artists)} + + + + + {onAlbumClick && track.album_id && track.album_url ? ( onAlbumClick({ id: track.album_id!, name: track.album_name, external_urls: track.album_url!, })}> - {track.album_name} - ) : (track.album_name)} - - {formatDuration(track.duration_ms)} - - {track.plays ? formatPlays(track.plays) : ""} - -
- {track.isrc && ( - - - - - {downloadingTrack === track.isrc ? (

Downloading...

) : skippedTracks.has(track.isrc) ? (

Already exists

) : downloadedTracks.has(track.isrc) ? (

Downloaded

) : failedTracks.has(track.isrc) ? (

Failed

) : (

Download Track

)} -
-
)} - {track.spotify_id && onDownloadLyrics && ( - - - - -

Download Lyric

-
-
)} - {track.images && onDownloadCover && ( - -
+ {formatDuration(track.duration_ms)} + + {track.plays ? formatPlays(track.plays) : ""} + +
+ {track.isrc && ( + + + + + {downloadingTrack === track.isrc ? (

Downloading...

) : skippedTracks.has(track.isrc) ? (

Already exists

) : downloadedTracks.has(track.isrc) ? (

Downloaded

) : failedTracks.has(track.isrc) ? (

Failed

) : (

Download Track

)} +
+
)} + {track.spotify_id && ( + + + + +

{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}

+
+
)} + {track.spotify_id && onDownloadLyrics && ( + + + + +

Download Lyric

+
+
)} + {track.images && onDownloadCover && ( + + - - -

Download Cover

-
-
)} - {track.spotify_id && onCheckAvailability && ( - - - - - {availabilityMap?.has(track.spotify_id) ? (
- - - -
) : (

Check Availability

)} -
-
)} -
-
-
+ }} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}> + {downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? () : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? () : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? () : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? () : ()} + + + +

Download Cover

+
+ )} + {track.spotify_id && onCheckAvailability && ( + + + + + {availabilityMap?.has(track.spotify_id) ? (
+ + + +
) : (

Check Availability

)} +
+
)} +
+
+
- {totalPages > 1 && ( - - - { + {totalPages > 1 && ( + + + { e.preventDefault(); if (currentPage > 1) onPageChange(currentPage - 1); }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - + - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - { + {getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? ( + + ) : ( + { e.preventDefault(); onPageChange(page); }} isActive={currentPage === page} className="cursor-pointer"> - {page} - - ))} + {page} + + )))} - - { + + { e.preventDefault(); if (currentPage < totalPages) onPageChange(currentPage + 1); }} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - - - )} -
); + + + )} +
); } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 03ad68a..a6bb15e 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -16,9 +16,9 @@ const buttonVariants = cva("inline-flex items-center justify-center gap-2 whites default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", + icon: "h-9 w-9 p-0", + "icon-sm": "h-8 w-8 p-0", + "icon-lg": "h-10 w-10 p-0", }, }, defaultVariants: { diff --git a/frontend/src/hooks/usePreview.ts b/frontend/src/hooks/usePreview.ts new file mode 100644 index 0000000..f9ff87a --- /dev/null +++ b/frontend/src/hooks/usePreview.ts @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { GetPreviewURL } from "@/../wailsjs/go/main/App"; +import { toast } from "sonner"; +export function usePreview() { + const [loadingPreview, setLoadingPreview] = useState(null); + const [currentAudio, setCurrentAudio] = useState(null); + const [playingTrack, setPlayingTrack] = useState(null); + const playPreview = async (trackId: string, trackName: string) => { + try { + if (playingTrack === trackId && currentAudio) { + currentAudio.pause(); + currentAudio.currentTime = 0; + setPlayingTrack(null); + setCurrentAudio(null); + return; + } + if (currentAudio) { + currentAudio.pause(); + currentAudio.currentTime = 0; + setCurrentAudio(null); + setPlayingTrack(null); + } + setLoadingPreview(trackId); + const previewURL = await GetPreviewURL(trackId); + if (!previewURL) { + toast.error("Preview not available", { + description: `No preview found for "${trackName}"`, + }); + setLoadingPreview(null); + return; + } + const audio = new Audio(previewURL); + audio.addEventListener("loadeddata", () => { + setLoadingPreview(null); + setPlayingTrack(trackId); + }); + audio.addEventListener("ended", () => { + setPlayingTrack(null); + setCurrentAudio(null); + }); + audio.addEventListener("error", () => { + toast.error("Failed to play preview", { + description: `Could not play preview for "${trackName}"`, + }); + setLoadingPreview(null); + setPlayingTrack(null); + setCurrentAudio(null); + }); + setCurrentAudio(audio); + await audio.play(); + } + catch (error: any) { + console.error("Preview error:", error); + toast.error("Preview not available", { + description: error?.message || `Could not load preview for "${trackName}"`, + }); + setLoadingPreview(null); + setPlayingTrack(null); + } + }; + const stopPreview = () => { + if (currentAudio) { + currentAudio.pause(); + currentAudio.currentTime = 0; + setCurrentAudio(null); + setPlayingTrack(null); + } + }; + return { + playPreview, + stopPreview, + loadingPreview, + playingTrack, + }; +} diff --git a/wails.json b/wails.json index ce9d075..50e95a6 100644 --- a/wails.json +++ b/wails.json @@ -12,9 +12,8 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.0.4", - "copyright": "© 2026 afkarxyz", - "comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required." + "productVersion": "7.0.5", + "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend", "assetdir": "./frontend/dist",