diff --git a/package.json b/package.json index 475ce0c..dedf6fe 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "@trpc/client": "^11.0.0-rc.446", "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", + "chart.js": "^4.4.5", "cloudinary": "^2.5.1", "framer-motion": "^11.11.9", "geist": "^1.3.0", "ldrs": "^1.0.2", "next": "^14.2.4", "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.3.1", "react-parallax-tilt": "^1.7.246", "server-only": "^0.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9290956..97d482c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@trpc/server': specifier: ^11.0.0-rc.446 version: 11.0.0-rc.566 + chart.js: + specifier: ^4.4.5 + version: 4.4.5 cloudinary: specifier: ^2.5.1 version: 2.5.1 @@ -44,6 +47,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-chartjs-2: + specifier: ^5.2.0 + version: 5.2.0(chart.js@4.4.5)(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -171,6 +177,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@kurkle/color@0.3.2': + resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + '@next/env@14.2.15': resolution: {integrity: sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==} @@ -555,6 +564,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chart.js@4.4.5: + resolution: {integrity: sha512-CVVjg1RYTJV9OCC8WeJPMx8gsV8K6WIyIEQUE3ui4AR9Hfgls9URri6Ja3hyMVBbTF8Q2KFa19PE815gWcWhng==} + engines: {pnpm: '>=8'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1539,6 +1552,12 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-chartjs-2@5.2.0: + resolution: {integrity: sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==} + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1946,6 +1965,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@kurkle/color@0.3.2': {} + '@next/env@14.2.15': {} '@next/eslint-plugin-next@14.2.15': @@ -2336,6 +2357,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chart.js@4.4.5: + dependencies: + '@kurkle/color': 0.3.2 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3409,6 +3434,11 @@ snapshots: queue-microtask@1.2.3: {} + react-chartjs-2@5.2.0(chart.js@4.4.5)(react@18.3.1): + dependencies: + chart.js: 4.4.5 + react: 18.3.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 diff --git a/src/app/_components/charts.tsx b/src/app/_components/charts.tsx new file mode 100644 index 0000000..2c59d26 --- /dev/null +++ b/src/app/_components/charts.tsx @@ -0,0 +1,171 @@ +import Chart from "chart.js/auto"; +import { CategoryScale } from "chart.js"; +import { Bar } from "react-chartjs-2"; +import VerticalSelector from "./vertical-selector"; +import { useMemo, useState } from "react"; + +Chart.register(CategoryScale); + +export const Charts = ({ + longTermArtistData, + longTermTracksData, + longTermTracksByAlbum, +}) => { + const options = ["Top albums", "Artist popularity"]; + const [selectedOption, setSelectedOption] = useState(options[0]); + const a = ""; + console.log({ + longTermArtistData, + longTermTracksData, + longTermTracksByAlbum, + }); + const topAlbums = Object.values(longTermTracksByAlbum).sort( + (a, b) => b.tracks.length - a.tracks.length, + ); + const topAlbumsData = topAlbums.map((album, i) => { + const label = album.album.name; + return { + album: label, + position: album.tracks.length, + }; + }); + const topArtistsData = longTermArtistData.body.items.sort( + (a, b) => b.popularity - a.popularity, + ); + // debugger; + const { ChartComponent } = useMemo(() => { + if (selectedOption === options[0]) { + const chartData = { + labels: topAlbumsData.map((data, i) => { + const label = `#${i + 1} ${data.album}`; + return label.length > 20 ? label.slice(0, 20) + "..." : label; + }), + datasets: [ + { + label: "Top songs in this album", + data: topAlbumsData.map((data) => data.position), + backgroundColor: "rgba(75, 192, 192, 0.6)", + borderColor: "rgba(75, 192, 192, 1)", + borderWidth: 1, + barThickness: 27, + borderRadius: 2, + }, + ], + }; + return { + ChartComponent: () => ( + + ), + chartData, + }; + } + if (selectedOption === options[1]) { + const chartData = { + labels: topArtistsData.map((data, i) => { + const label = `#${i + 1} ${data.name}`; + return label.length > 20 ? label.slice(0, 20) + "..." : label; + }), + datasets: [ + { + label: "Top artists by popularity", + data: topArtistsData.map((data) => data.popularity), + backgroundColor: "rgba(75, 192, 192, 0.6)", + borderColor: "rgba(75, 192, 192, 1)", + borderWidth: 1, + barThickness: 27, + borderRadius: 2, + }, + ], + }; + return { + ChartComponent: () => ( + + ), + }; + } + }, [options]); + + return ( +
+ + +
+ +
+
+ ); +}; + +export default Charts; diff --git a/src/app/_components/showcase.tsx b/src/app/_components/showcase.tsx index bed4664..cc670ad 100644 --- a/src/app/_components/showcase.tsx +++ b/src/app/_components/showcase.tsx @@ -8,15 +8,22 @@ import UserShowcase from "./user-showcase"; import { TypographyH2 } from "./h2"; import { TypographyH1 } from "./h1"; import ScrollSlider from "./scroll-slider"; +import Charts from "./charts"; export function Showcase({ userData, tracksByAlbum, artists, + longTermArtistData, + longTermTracksData, + longTermTracksByAlbum, }: { userData: Record; tracksByAlbum: Record; artists: any[]; + // longTermArtistData + // longTermTracksData + // longTermTracksByAlbum }) { const [spookify, setSpookify] = useState(true); const [lastSpookyImageLoaded, setLastSpookyImageLoaded] = useState(0); @@ -76,6 +83,23 @@ export function Showcase({ ); })} + {longTermArtistData && longTermTracksData && longTermTracksByAlbum && ( + <> + + + Charts + +

+ Let's see what's trending in your world. +

+ +
+ + )} ); } diff --git a/src/app/_components/spotify-data.tsx b/src/app/_components/spotify-data.tsx index 08d76fc..5b9b030 100644 --- a/src/app/_components/spotify-data.tsx +++ b/src/app/_components/spotify-data.tsx @@ -47,6 +47,9 @@ export default async function SpotifyData({ }, artists: userData?.artists, tracksByAlbum: userData?.tracksByAlbum, + longTermArtistData: false, + longTermTracksData: false, + longTermTracksByAlbum: false, })); } else { return getSpotifyData({ @@ -56,7 +59,14 @@ export default async function SpotifyData({ }); } }; - const { artists, tracksByAlbum, userData } = await fetchData(); + const { + artists, + tracksByAlbum, + userData, + longTermArtistData, + longTermTracksData, + longTermTracksByAlbum, + } = await fetchData(); if (!artists || !tracksByAlbum || !userData?.body) { return
Error fetching data
; @@ -198,6 +208,9 @@ export default async function SpotifyData({ userData={userData?.body} tracksByAlbum={tracksByAlbum} artists={artists} + longTermArtistData={longTermArtistData} + longTermTracksData={longTermTracksData} + longTermTracksByAlbum={longTermTracksByAlbum} /> ); diff --git a/src/app/_components/vertical-selector.tsx b/src/app/_components/vertical-selector.tsx new file mode 100644 index 0000000..28a8573 --- /dev/null +++ b/src/app/_components/vertical-selector.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; + +export const VerticalSelector = ({ + className, + options, + selectedOption, + setSelectedOption, +}) => { + return ( +
+
    + {options.map((option) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +export default VerticalSelector; diff --git a/src/app/api/spotify-login/route.ts b/src/app/api/spotify-login/route.ts index be3b492..380ebb6 100644 --- a/src/app/api/spotify-login/route.ts +++ b/src/app/api/spotify-login/route.ts @@ -11,7 +11,12 @@ const spotifyApi = new SpotifyWebApi({ const state = process.env.SPOTIFY_STATE as string; export async function GET(req: NextApiRequest, res: NextApiResponse) { - const scopes = ["user-library-read", "user-read-private", "user-top-read"]; + const scopes = [ + "user-library-read", + "user-read-private", + "user-top-read", + "user-read-recently-played", + ]; const authorizeURL = spotifyApi.createAuthorizeURL(scopes, state); console.log(authorizeURL); diff --git a/src/app/utils/getSpotifyData.ts b/src/app/utils/getSpotifyData.ts index b40ec85..9f0fc75 100644 --- a/src/app/utils/getSpotifyData.ts +++ b/src/app/utils/getSpotifyData.ts @@ -2,6 +2,100 @@ import SpotifyWebApi from "spotify-web-api-node"; import { FETCH_ARTISTS_LIMIT, FETCH_TRACKS_LIMIT } from "./contants"; import { TrackByAlbum } from "../_components/spotify-data"; +// const getRecentlyPlayedSongs = async ({ +// spotifyApi, +// accessToken, +// }: { +// spotifyApi: SpotifyWebApi; +// accessToken: string; +// }) => { +// const now = Date.now(); +// const oneMonthAgo = now - 30 * 24 * 60 * 60 * 1000; + +// let allTracks: SpotifyApi.PlayHistoryObject[] = []; + +// const response = await spotifyApi.getMyRecentlyPlayedTracks({ +// after: oneMonthAgo, +// limit: 50, +// }); +// allTracks = response.body.items; + +// let newUrl = response.body.next; + +// try { +// while (newUrl) { +// const fetchResponse = await fetch(newUrl, { +// headers: { +// Authorization: `Bearer ${accessToken}`, +// }, +// }).then((res) => res.json()); + +// if (!fetchResponse?.data?.items) { +// break; +// } +// allTracks = allTracks.concat(fetchResponse.data.items); + +// newUrl = response?.data?.next; +// } +// } catch (error) {} + +// return allTracks; +// }; + +const getRecentlyPlayedSongs = async ({ + spotifyApi, + accessToken, +}: { + spotifyApi: SpotifyWebApi; + accessToken: string; +}) => { + const now = Date.now(); + const oneMonthAgo = now - 30 * 24 * 60 * 60 * 1000; + + let allTracks: SpotifyApi.PlayHistoryObject[] = []; + + try { + // Initial API request using the SDK + const response = await spotifyApi.getMyRecentlyPlayedTracks({ + after: oneMonthAgo, + limit: 50, + }); + + // Collect the first batch of tracks + allTracks = response.body.items; + + // Check if there are more pages of results + let newUrl = response.body.next; + + // Fetch additional pages of results if they exist + while (newUrl) { + const fetchResponse = await fetch(newUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + // Parse the response JSON + const data = await fetchResponse.json(); + + // If no items are returned, exit the loop + if (!data.items) { + break; + } + + // Concatenate the newly fetched tracks to the list + allTracks = allTracks.concat(data.items); + + // Update the newUrl for the next request + newUrl = data.next; + } + } catch (error) { + console.error("Error fetching recently played tracks:", error); + } + + return allTracks; +}; + export const getSpotifyData = async ({ accessToken, tracksLimit, @@ -18,7 +112,13 @@ export const getSpotifyData = async ({ }); spotifyApi.setAccessToken(accessToken); - const [artistsData, tracksData, userData] = await Promise.all([ + const [ + artistsData, + tracksData, + userData, + longTermArtistData, + longTermTracksData, + ] = await Promise.all([ spotifyApi.getMyTopArtists({ limit: Math.min( ...[FETCH_ARTISTS_LIMIT, artistsLimit].filter( @@ -36,6 +136,14 @@ export const getSpotifyData = async ({ time_range: "short_term", }), spotifyApi.getMe(), + spotifyApi.getMyTopArtists({ + limit: 50, + time_range: "long_term", + }), + spotifyApi.getMyTopTracks({ + limit: 50, + time_range: "long_term", + }), ]); const artists = artistsData.body.items; @@ -66,9 +174,39 @@ export const getSpotifyData = async ({ {}, ); + const longTermTracks = longTermTracksData.body.items.map((track, i) => ({ + ...track, + position: i + 1, + })); + + const longTermTracksByAlbum = longTermTracksData.body.items.reduce( + (acc: Record, track) => { + if (!acc[track.album.id]) { + const tracksWithAlbum = longTermTracks.filter( + (t) => t.album.id === track.album.id, + ); + acc[track.album.id] = { + album: track.album, + position: + tracksWithAlbum.reduce( + (acc, _track) => 50 / _track.position + acc, + 0, + ) / tracksWithAlbum.length, + tracks: [], + }; + } + (acc[track.album.id] || ({ tracks: [] } as any)).tracks.push(track); + return acc; + }, + {}, + ); + return { userData, tracksByAlbum, artists, + longTermArtistData, + longTermTracksData, + longTermTracksByAlbum, }; };