mirror of
https://github.com/FranP-code/spooky-spotify-showcase.git
synced 2025-10-13 00:02:36 +00:00
Charts
This commit is contained in:
@@ -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",
|
||||
|
||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
171
src/app/_components/charts.tsx
Normal file
171
src/app/_components/charts.tsx
Normal file
@@ -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: () => (
|
||||
<Bar
|
||||
className="w-full"
|
||||
data={chartData}
|
||||
options={{
|
||||
indexAxis: "y",
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "#94a3b8",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "#94a3b8",
|
||||
stepSize: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
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: () => (
|
||||
<Bar
|
||||
className="w-full"
|
||||
data={chartData}
|
||||
options={{
|
||||
indexAxis: "y",
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "#94a3b8",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
color: "#94a3b8",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<VerticalSelector
|
||||
className="w-fit justify-start"
|
||||
options={options}
|
||||
selectedOption={selectedOption}
|
||||
setSelectedOption={setSelectedOption}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="chart-container"
|
||||
style={{ width: "100%", height: "1500px" }}
|
||||
>
|
||||
<ChartComponent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Charts;
|
||||
@@ -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<string, any>;
|
||||
tracksByAlbum: Record<string, any>;
|
||||
artists: any[];
|
||||
// longTermArtistData
|
||||
// longTermTracksData
|
||||
// longTermTracksByAlbum
|
||||
}) {
|
||||
const [spookify, setSpookify] = useState(true);
|
||||
const [lastSpookyImageLoaded, setLastSpookyImageLoaded] = useState(0);
|
||||
@@ -76,6 +83,23 @@ export function Showcase({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{longTermArtistData && longTermTracksData && longTermTracksByAlbum && (
|
||||
<>
|
||||
<ScrollSlider className="w-full">
|
||||
<TypographyH1 className="mb-2 mt-8 self-center text-center text-3xl lg:text-4xl">
|
||||
Charts
|
||||
</TypographyH1>
|
||||
<p className="text-center text-lg text-white text-opacity-40 backdrop-blur-lg backdrop-filter">
|
||||
Let's see what's trending in your world.
|
||||
</p>
|
||||
<Charts
|
||||
longTermArtistData={longTermArtistData}
|
||||
longTermTracksData={longTermTracksData}
|
||||
longTermTracksByAlbum={longTermTracksByAlbum}
|
||||
/>
|
||||
</ScrollSlider>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <div>Error fetching data</div>;
|
||||
@@ -198,6 +208,9 @@ export default async function SpotifyData({
|
||||
userData={userData?.body}
|
||||
tracksByAlbum={tracksByAlbum}
|
||||
artists={artists}
|
||||
longTermArtistData={longTermArtistData}
|
||||
longTermTracksData={longTermTracksData}
|
||||
longTermTracksByAlbum={longTermTracksByAlbum}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
37
src/app/_components/vertical-selector.tsx
Normal file
37
src/app/_components/vertical-selector.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export const VerticalSelector = ({
|
||||
className,
|
||||
options,
|
||||
selectedOption,
|
||||
setSelectedOption,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`rounded-lg bg-white p-2 shadow ${className}`}>
|
||||
<ul className="space-y-2">
|
||||
{options.map((option) => (
|
||||
<li key={option}>
|
||||
<button
|
||||
className={`relative w-full overflow-hidden rounded-md p-3 text-left transition-colors duration-200 ${
|
||||
selectedOption === option
|
||||
? "bg-black text-white"
|
||||
: "bg-transparent text-gray-800 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => setSelectedOption(option)}
|
||||
aria-pressed={selectedOption === option}
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: selectedOption === option ? 1 : 0,
|
||||
transition: "background-color 0.3s ease, color 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<span className="relative">{option}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerticalSelector;
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, TrackByAlbum>, 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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user