├── src
├── app
│ ├── favicon.ico
│ ├── you
│ │ ├── artists
│ │ │ └── page.tsx
│ │ ├── tracks
│ │ │ └── page.tsx
│ │ └── recents
│ │ │ └── page.tsx
│ ├── globals.css
│ ├── api
│ │ ├── logout
│ │ │ └── route.js
│ │ ├── validate
│ │ │ └── route.js
│ │ ├── you
│ │ │ ├── recent
│ │ │ │ └── route.js
│ │ │ ├── artists
│ │ │ │ └── [period]
│ │ │ │ │ └── route.js
│ │ │ └── tracks
│ │ │ │ └── [period]
│ │ │ │ └── route.js
│ │ └── auth
│ │ │ └── route.js
│ ├── logout
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── auth
│ │ └── page.tsx
├── components
│ ├── Footer.tsx
│ ├── featured
│ │ ├── LoadingCard.js
│ │ ├── ArtistCard.js
│ │ └── TrackCard.js
│ ├── other-cards
│ │ ├── OtherLoadingCard.js
│ │ ├── OtherArtistCard.js
│ │ └── OtherTrackCard.js
│ ├── UpdateBanner.tsx
│ ├── gust-ui
│ │ └── SlidingTabs.js
│ ├── layout
│ │ └── TopPage.js
│ └── Navbar.tsx
└── lib
│ └── spotify.js
├── public
└── assets
│ ├── homepage-art.png
│ └── homepage-browser.png
├── postcss.config.mjs
├── .env.sample
├── next.config.ts
├── next-env.d.ts
├── .gitignore
├── eslint.config.mjs
├── tsconfig.json
├── package.json
└── README.md
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makandz/myspotify/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/public/assets/homepage-art.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makandz/myspotify/HEAD/public/assets/homepage-art.png
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/public/assets/homepage-browser.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/makandz/myspotify/HEAD/public/assets/homepage-browser.png
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | # Spotify API details
2 | NEXT_PUBLIC_CLIENT_ID=
3 | CLIENT_SECRET=
4 | NEXT_PUBLIC_REDIRECT_URL=http://127.0.0.1:3000/auth
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/src/app/you/artists/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import TopPage from "@/components/layout/TopPage";
4 |
5 | export default function ArtistsPage() {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/you/tracks/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import TopPage from "@/components/layout/TopPage";
4 |
5 | export default function ArtistsPage() {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // NOTE: This file should not be edited
6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
7 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | :root {
4 | --background: #ffffff;
5 | --foreground: #171717;
6 | }
7 |
8 | body {
9 | background: var(--background);
10 | color: var(--foreground);
11 | font-family: var(--font-roboto), sans-serif;
12 | }
13 |
14 | .font-display {
15 | font-family: var(--font-manrope), sans-serif;
16 | }
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | export default function Footer() {
2 | return (
3 |
4 |
5 | All assets related to artists, albums, and songs are retrieved from
6 | Spotify. This website is not affiliated with Spotify.
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/api/logout/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | export async function POST() {
4 | const isProduction = process.env.NODE_ENV === "production";
5 |
6 | const response = NextResponse.json({ success: true });
7 | response.cookies.set("sp-at", "", {
8 | httpOnly: true,
9 | secure: isProduction,
10 | sameSite: "lax",
11 | path: "/",
12 | maxAge: 0, // Immediately expire the cookie
13 | });
14 |
15 | return response;
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/featured/LoadingCard.js:
--------------------------------------------------------------------------------
1 | export default function LoadingCard() {
2 | return (
3 |
13 | );
14 | }
--------------------------------------------------------------------------------
/src/lib/spotify.js:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 | import SpotifyWebApi from "spotify-web-api-node";
3 |
4 | export function getSpotify() {
5 | const token = cookies().get("sp-at")?.value ?? "";
6 |
7 | const spotify = new SpotifyWebApi({
8 | clientId: process.env.NEXT_PUBLIC_CLIENT_ID,
9 | clientSecret: process.env.CLIENT_SECRET,
10 | redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URL,
11 | });
12 |
13 | spotify.setAccessToken(token);
14 | return spotify;
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/logout/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 |
5 | export default function LogoutPage() {
6 | useEffect(() => {
7 | const logout = async () => {
8 | // Clear the httpOnly cookie via the logout API
9 | await fetch("/api/logout", { method: "POST", credentials: "include" });
10 | localStorage.removeItem("ms-user-name");
11 | localStorage.removeItem("ms-user-img");
12 | window.location.href = "/";
13 | };
14 | logout();
15 | }, []);
16 |
17 | return <>>;
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/api/validate/route.js:
--------------------------------------------------------------------------------
1 | import { getSpotify } from "@/lib/spotify";
2 | import { NextResponse } from "next/server";
3 |
4 | export async function GET() {
5 | try {
6 | const spotify = getSpotify();
7 | const data = await spotify.getMe();
8 | const resBody = data.body;
9 |
10 | return NextResponse.json({
11 | id: resBody.id,
12 | name: resBody.display_name,
13 | image: resBody.images?.length > 0 ? resBody.images[0].url : "",
14 | });
15 | } catch {
16 | return NextResponse.json("Invalid token", { status: 401 });
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 | .vscode/
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # other
38 | .idea/
39 | .env
40 | tsconfig.tsbuildinfo
41 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | {
15 | ignores: [
16 | "node_modules/**",
17 | ".next/**",
18 | "out/**",
19 | "build/**",
20 | "next-env.d.ts",
21 | ],
22 | },
23 | ];
24 |
25 | export default eslintConfig;
26 |
--------------------------------------------------------------------------------
/src/components/other-cards/OtherLoadingCard.js:
--------------------------------------------------------------------------------
1 | export default function OtherLoadingCard() {
2 | return (
3 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/api/you/recent/route.js:
--------------------------------------------------------------------------------
1 | import { getSpotify } from "@/lib/spotify";
2 | import { NextResponse } from "next/server";
3 |
4 | export async function GET() {
5 | try {
6 | const spotify = getSpotify();
7 | const data = await spotify.getMyRecentlyPlayedTracks({ limit: 50 });
8 |
9 | const responseData = data.body.items.map((item) => {
10 | const t = item.track || {};
11 | return {
12 | name: t.name || "",
13 | artist: t.artists?.[0]?.name || "",
14 | album: t.album?.name || "",
15 | image: t.album?.images?.[0]?.url || "",
16 | href: t.album?.external_urls?.spotify || "",
17 | };
18 | });
19 |
20 | return NextResponse.json(responseData);
21 | } catch {
22 | return NextResponse.json("Invalid token", { status: 401 });
23 | }
24 | }
--------------------------------------------------------------------------------
/src/components/featured/ArtistCard.js:
--------------------------------------------------------------------------------
1 | export default function ArtistCard(props) {
2 | return (
3 | <>
4 |
5 |
10 |
11 |
12 |
16 | {`${props.rank}. ${props.artistData.name}`}
17 |
18 |
22 | {props.artistData.genre}
23 |
24 |
25 | >
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "newtunes",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build --turbopack",
8 | "start": "next start",
9 | "lint": "eslint"
10 | },
11 | "dependencies": {
12 | "@headlessui/react": "^2.2.9",
13 | "@heroicons/react": "^2.2.0",
14 | "axios": "^1.12.2",
15 | "next": "15.5.4",
16 | "nookies": "^2.5.2",
17 | "react": "19.1.0",
18 | "react-dom": "19.1.0",
19 | "spotify-web-api-node": "^5.0.2"
20 | },
21 | "devDependencies": {
22 | "@eslint/eslintrc": "^3",
23 | "@tailwindcss/postcss": "^4",
24 | "@types/node": "^20",
25 | "@types/react": "^19",
26 | "@types/react-dom": "^19",
27 | "eslint": "^9",
28 | "eslint-config-next": "15.5.4",
29 | "tailwindcss": "^4",
30 | "typescript": "^5"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/featured/TrackCard.js:
--------------------------------------------------------------------------------
1 | export default function TrackCard(props) {
2 | return (
3 | <>
4 |
5 |
10 |
11 |
12 |
16 | {`${props.rank}. ${props.songData.name}`}
17 |
18 |
19 | {props.songData.artist}
20 |
21 |
22 | {props.songData.album}
23 |
24 |
25 | >
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/api/you/artists/[period]/route.js:
--------------------------------------------------------------------------------
1 | import { getSpotify } from "@/lib/spotify";
2 | import { NextResponse } from "next/server";
3 |
4 | const VALID = ["short_term", "medium_term", "long_term"];
5 |
6 | export async function GET(_req, { params }) {
7 | const { period } = await params;
8 | if (!VALID.includes(period)) {
9 | return NextResponse.json("Bad time period", { status: 405 });
10 | }
11 |
12 | try {
13 | const spotify = getSpotify();
14 | const data = await spotify.getMyTopArtists({
15 | limit: 50,
16 | time_range: period,
17 | });
18 |
19 | const responseData = data.body.items.map((artist) => ({
20 | name: artist.name,
21 | image: artist.images?.[0]?.url || "",
22 | genre: artist.genres?.[0] || "",
23 | href: artist.external_urls?.spotify || "",
24 | }));
25 |
26 | return NextResponse.json(responseData);
27 | } catch {
28 | return NextResponse.json("Invalid token", { status: 401 });
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/other-cards/OtherArtistCard.js:
--------------------------------------------------------------------------------
1 | export default function OtherArtistCard(props) {
2 | return (
3 |
4 |
13 |
14 |
18 | {props.rank}. {props.artistData.name}
19 |
20 |
24 | {props.artistData.genre}
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/api/you/tracks/[period]/route.js:
--------------------------------------------------------------------------------
1 | import { getSpotify } from "@/lib/spotify";
2 | import { NextResponse } from "next/server";
3 |
4 | const VALID = ["short_term", "medium_term", "long_term"];
5 |
6 | export async function GET(_req, { params }) {
7 | const { period } = await params;
8 | if (!VALID.includes(period)) {
9 | return NextResponse.json("Bad time period", { status: 405 });
10 | }
11 |
12 | try {
13 | const spotify = getSpotify();
14 | const data = await spotify.getMyTopTracks({
15 | limit: 50,
16 | time_range: period,
17 | });
18 |
19 | const responseData = data.body.items.map((track) => ({
20 | name: track.name,
21 | artist: track.artists?.[0]?.name || "",
22 | album: track.album?.name || "",
23 | image: track.album?.images?.[0]?.url || "",
24 | href: track.album?.external_urls?.spotify || "",
25 | }));
26 |
27 | return NextResponse.json(responseData);
28 | } catch {
29 | return NextResponse.json("Invalid token", { status: 401 });
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/other-cards/OtherTrackCard.js:
--------------------------------------------------------------------------------
1 | export default function OtherTrackCard(props) {
2 | return (
3 |
4 |
13 |
14 |
18 | {props.rank ? `${props.rank}. ` : null}
19 | {props.songData.name}
20 |
21 |
22 | {props.songData.artist}
23 |
24 |
25 | {props.songData.album}
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "@/components/Footer";
2 | import Navbar from "@/components/Navbar";
3 | import UpdateBanner from "@/components/UpdateBanner";
4 | import { Manrope, Roboto } from "next/font/google";
5 | import "./globals.css";
6 |
7 | export const metadata = {
8 | title: "NewTunes – Your Spotify Stats",
9 | description:
10 | "Discover your top tracks, artists, and recent listens with NewTunes. Connect with Spotify to see how your music speaks.",
11 | };
12 |
13 | // Roboto for body
14 | const roboto = Roboto({
15 | subsets: ["latin"],
16 | weight: ["400", "500", "700"],
17 | variable: "--font-roboto",
18 | });
19 |
20 | // Manrope for display
21 | const manrope = Manrope({
22 | subsets: ["latin"],
23 | weight: ["400", "600", "700"],
24 | variable: "--font-manrope",
25 | });
26 |
27 | export default function RootLayout({
28 | children,
29 | }: Readonly<{
30 | children: React.ReactNode;
31 | }>) {
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
{children}
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/api/auth/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 |
3 | export async function POST(request) {
4 | const { code } = await request.json();
5 |
6 | const clientId = process.env.NEXT_PUBLIC_CLIENT_ID;
7 | const clientSecret = process.env.CLIENT_SECRET;
8 | const redirectUri = process.env.NEXT_PUBLIC_REDIRECT_URL;
9 |
10 | const tokenResponse = await fetch("https://accounts.spotify.com/api/token", {
11 | method: "POST",
12 | headers: {
13 | "Content-Type": "application/x-www-form-urlencoded",
14 | Authorization:
15 | "Basic " +
16 | Buffer.from(`${clientId}:${clientSecret}`).toString("base64"),
17 | },
18 | body: new URLSearchParams({
19 | grant_type: "authorization_code",
20 | code,
21 | redirect_uri: redirectUri,
22 | }),
23 | });
24 |
25 | if (!tokenResponse.ok) {
26 | const error = await tokenResponse.json();
27 | return NextResponse.json(
28 | { error: error.error_description },
29 | { status: 400 }
30 | );
31 | }
32 |
33 | const data = await tokenResponse.json();
34 | const isProduction = process.env.NODE_ENV === "production";
35 |
36 | const response = NextResponse.json({ success: true });
37 | response.cookies.set("sp-at", data.access_token, {
38 | httpOnly: true,
39 | secure: isProduction,
40 | sameSite: "lax",
41 | path: "/",
42 | maxAge: 3600, // 1 hour
43 | });
44 |
45 | return response;
46 | }
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NewTunes (formerly MySpotify)
2 |
3 | Discover your top tracks, artists, and recent listens from Spotify. Filter by last month, six months, or all time and share your profile with friends. No server-side storage - your access token stays in a cookie on your device.
4 |
5 | ## Quick Start
6 |
7 | 1. Prerequisites
8 |
9 | - Node.js 22+ and npm
10 | - A Spotify Developer application (https://developer.spotify.com/dashboard)
11 |
12 | 2. Configure your Spotify app
13 |
14 | - In your Spotify app settings, add a redirect URI for development: `http://localhost:3000/auth`
15 | - Scopes required: `user-top-read user-read-recently-played`
16 |
17 | 3. Environment variables
18 |
19 | Create a `.env.local` in the project root:
20 |
21 | ```bash
22 | # Spotify app credentials
23 | NEXT_PUBLIC_CLIENT_ID=your_spotify_client_id
24 | CLIENT_SECRET=your_spotify_client_secret
25 |
26 | # Where Spotify should send users after auth (must match your app settings)
27 | # For local dev you can omit this — the app defaults to window.origin + /auth
28 | NEXT_PUBLIC_REDIRECT_URL=http://localhost:3000/auth
29 | ```
30 |
31 | Notes:
32 |
33 | - `NEXT_PUBLIC_CLIENT_ID` and `NEXT_PUBLIC_REDIRECT_URL` are used by the client during the implicit grant redirect.
34 | - `CLIENT_SECRET` is only needed when constructing the SDK server-side; no refresh tokens are stored.
35 |
36 | 4. Install and run
37 |
38 | ```bash
39 | npm install
40 | npm run dev
41 | # Visit http://localhost:3000
42 | ```
43 |
--------------------------------------------------------------------------------
/src/components/UpdateBanner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { useEffect, useState } from "react";
5 |
6 | export default function UpdateBanner() {
7 | const [visible, setVisible] = useState(false);
8 |
9 | useEffect(() => {
10 | const dismissed = localStorage.getItem("updateBannerDismissed");
11 | if (!dismissed) {
12 | setVisible(true);
13 | }
14 | }, []);
15 |
16 | const dismiss = () => {
17 | localStorage.setItem("updateBannerDismissed", "true");
18 | setVisible(false);
19 | };
20 |
21 | if (!visible) return null;
22 |
23 | return (
24 |
25 |
26 | {/* Left: can wrap to multiple lines */}
27 |
28 | 🎉 New major update! Check out the new features and improvements.
29 |
30 |
31 | {/* Right: fixed, no wrap */}
32 |
33 |
39 | Learn more →
40 |
41 |
46 | ✕
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/gust-ui/SlidingTabs.js:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from "react";
2 |
3 | function SlidingTabs({
4 | className = "",
5 | tabs = [],
6 | onChange = () => {},
7 | ...newProps
8 | }) {
9 | const [activeTab, setActiveTab] = useState(0);
10 | const numTabs = tabs.length || 1;
11 |
12 | const sliderStyle = useMemo(
13 | () => ({
14 | width: `${100 / numTabs}%`,
15 | transform: `translateX(${activeTab * 100}%)`,
16 | }),
17 | [activeTab, numTabs]
18 | );
19 |
20 | const tabBase =
21 | "flex-1 text-center font-medium pb-3 cursor-pointer hover:text-blue-400";
22 |
23 | return (
24 |
28 | {tabs.map((t, index) => {
29 | const isObject = typeof t === "object";
30 | const name = isObject ? t.label : t;
31 | const value = isObject ? t.value : t;
32 | const icon = isObject ? t.icon : null;
33 | return (
34 | {
41 | if (activeTab !== index) {
42 | setActiveTab(index);
43 | onChange(value);
44 | }
45 | }}
46 | >
47 | {icon ? (
48 |
49 | {React.cloneElement(icon, { className: "mr-2" })}
50 |
51 | ) : null}
52 | {name}
53 |
54 | );
55 | })}
56 |
60 |
61 | );
62 | }
63 |
64 | export default SlidingTabs;
65 |
--------------------------------------------------------------------------------
/src/app/you/recents/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import OtherLoadingCard from "@/components/other-cards/OtherLoadingCard";
4 | import OtherTrackCard from "@/components/other-cards/OtherTrackCard";
5 | import { useRouter } from "next/navigation";
6 | import { JSX, useEffect, useState } from "react";
7 |
8 | type RecentTrack = unknown; // replace with your API type if you have one
9 |
10 | export default function RecentsPage() {
11 | const router = useRouter();
12 | const [cards, setCards] = useState(getLoadingCards());
13 |
14 | function getLoadingCards() {
15 | const loading: JSX.Element[] = [];
16 | for (let i = 0; i < 50; i++) {
17 | loading.push(
18 |
19 |
20 |
21 | );
22 | }
23 | return loading;
24 | }
25 |
26 | useEffect(() => {
27 | const load = async () => {
28 | try {
29 | const res = await fetch("/api/you/recent", { credentials: "include" });
30 | if (res.status === 401) {
31 | router.push("/auth");
32 | return;
33 | }
34 | if (!res.ok) {
35 | router.push("/");
36 | return;
37 | }
38 | const data: RecentTrack[] = await res.json();
39 | const loaded = data.map((item, i) => (
40 |
41 |
42 |
43 | ));
44 | setCards(loaded);
45 | } catch {
46 | router.push("/");
47 | }
48 | };
49 |
50 | load();
51 | }, [router]);
52 |
53 | return (
54 |
55 |
56 | Recents
57 |
58 |
59 |
60 |
61 | {cards}
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/layout/TopPage.js:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import axios from "axios";
4 | import { useRouter } from "next/navigation";
5 | import { useEffect, useState } from "react";
6 | import ArtistCard from "../featured/ArtistCard";
7 | import LoadingCard from "../featured/LoadingCard";
8 | import TrackCard from "../featured/TrackCard";
9 | import SlidingTabs from "../gust-ui/SlidingTabs";
10 | import OtherArtistCard from "../other-cards/OtherArtistCard";
11 | import OtherLoadingCard from "../other-cards/OtherLoadingCard";
12 | import OtherTrackCard from "../other-cards/OtherTrackCard";
13 |
14 | export default function TopPage(props) {
15 | const [cards, setCards] = useState(loadingCards());
16 | const [period, setPeriod] = useState("short_term");
17 | const router = useRouter();
18 |
19 | function loadingCards() {
20 | let loading = { featured: [], other: [] };
21 | for (let i = 0; i < 6; i++) {
22 | loading.featured.push(
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | for (let i = 0; i < 20; i++) {
30 | loading.other.push(
31 |
32 |
33 |
34 | );
35 | }
36 |
37 | return loading;
38 | }
39 |
40 | function onPeriodChange(newPeriod) {
41 | setCards(loadingCards());
42 | setPeriod(newPeriod);
43 | }
44 |
45 | useEffect(() => {
46 | axios.get("/api/you/" + props.type + "/" + period).then(
47 | (response) => {
48 | let loadedCards = { featured: [], other: [] };
49 | response.data.slice(0, 6).forEach((item, i) => {
50 | loadedCards.featured.push(
51 |
52 | {props.type === "tracks" ? (
53 |
54 | ) : (
55 |
56 | )}
57 |
58 | );
59 | });
60 |
61 | response.data.slice(6).forEach((item, i) => {
62 | loadedCards.other.push(
63 |
64 | {props.type === "tracks" ? (
65 |
66 | ) : (
67 |
68 | )}
69 |
70 | );
71 | });
72 |
73 | setCards(loadedCards);
74 | },
75 | (err) => {
76 | if (err.response.status === 401) router.push("/auth");
77 | else router.push("/");
78 | }
79 | );
80 | }, [period, props.type]);
81 |
82 | return (
83 |
84 |
85 | {props.title}
86 |
87 |
88 |
89 |
onPeriodChange(e)}
105 | />
106 |
107 |
108 | {cards.featured}
109 |
110 |
111 |
112 | {cards.other}
113 |
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export default function Home() {
4 | return (
5 |
6 | {/* Hero */}
7 |
8 | {/* light decorative background */}
9 |
12 |
13 |
14 |
15 |
16 | What have you been{" "}
17 | listening to?
18 |
19 |
20 | See and share your top Spotify artists and tracks. Filter by last
21 | month, six months, or all time.
22 |
23 |
24 |
25 |
29 | Sign in with Spotify
30 |
31 |
32 |
33 |
34 | No data is saved or shared without your permission.
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | {/* Feature 1 */}
45 |
46 |
47 |
48 |
49 | Find the music and artists you play the most
50 |
51 |
52 | Your top lists update automatically. Quickly switch time ranges to
53 | see what changed and what stuck around.
54 |
55 |
56 |
57 | {/* Image block */}
58 |
59 |
64 |
65 |
66 |
67 |
68 | {/* Feature 2 */}
69 |
70 |
71 | {/* Image block */}
72 |
73 |
78 |
79 |
80 | {/* Text block */}
81 |
82 |
83 | Coming Soon
84 |
85 |
86 | Share with friends in one click
87 |
88 |
89 | Generate a clean link to your profile and post it anywhere. No
90 | setup, no fuss.
91 |
92 |
93 |
94 |
95 |
96 | {/* CTA */}
97 |
98 |
99 | Ready to see your Spotify stats?
100 |
101 |
102 | Sign in takes just a few seconds, and your data always stays private.
103 |
104 |
105 |
109 | Get started
110 |
111 |
112 |
113 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/app/auth/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { useCallback, useEffect, useState } from "react";
5 |
6 | type User = {
7 | name: string;
8 | image: string;
9 | };
10 |
11 | const SCOPES = "user-top-read user-read-recently-played";
12 |
13 | export default function AuthPage() {
14 | const router = useRouter();
15 | const [status, setStatus] = useState("Just a moment..");
16 | const [user, setUser] = useState(null);
17 |
18 | // Persist user info for later use in the app
19 | useEffect(() => {
20 | if (user) {
21 | localStorage.setItem("ms-user-name", user.name);
22 | localStorage.setItem("ms-user-img", user.image);
23 | }
24 | }, [user]);
25 |
26 | const validateToken = useCallback(async () => {
27 | try {
28 | const res = await fetch("/api/validate", { credentials: "include" });
29 | if (!res.ok) throw new Error("Invalid token");
30 | const data = (await res.json()) as User;
31 | setUser(data);
32 | router.replace("/you/tracks");
33 | return true;
34 | } catch {
35 | // Clear the httpOnly cookie via the logout API
36 | await fetch("/api/logout", { method: "POST", credentials: "include" });
37 | setStatus("Taking you home...");
38 | router.replace("/");
39 | return false;
40 | }
41 | }, [router]);
42 |
43 | useEffect(() => {
44 | const handleAuth = async () => {
45 | // 1) If redirected back from Spotify with an authorization code
46 | const urlParams = new URLSearchParams(window.location.search);
47 | const codeFromUrl = urlParams.get("code");
48 |
49 | if (codeFromUrl) {
50 | setStatus("Finishing up...");
51 | try {
52 | const res = await fetch("/api/auth", {
53 | method: "POST",
54 | headers: { "Content-Type": "application/json" },
55 | body: JSON.stringify({ code: codeFromUrl }),
56 | credentials: "include",
57 | });
58 | if (!res.ok) throw new Error("Token exchange failed");
59 | // The httpOnly cookie is set by the server response
60 | validateToken();
61 | } catch {
62 | setStatus("Something went wrong...");
63 | router.replace("/");
64 | }
65 | return;
66 | }
67 |
68 | // 2) Try validating an existing cookie (httpOnly, so we can't read it client-side)
69 | const validateRes = await fetch("/api/validate", {
70 | credentials: "include",
71 | });
72 | if (validateRes.ok) {
73 | const data = (await validateRes.json()) as User;
74 | setUser(data);
75 | router.replace("/you/tracks");
76 | return;
77 | }
78 |
79 | // 3) Otherwise, send the user to Spotify login
80 | const clientId = process.env.NEXT_PUBLIC_CLIENT_ID;
81 | const redirectUri =
82 | process.env.NEXT_PUBLIC_REDIRECT_URL ??
83 | `${window.location.origin}/auth`;
84 |
85 | setStatus("Taking you to Spotify...");
86 | const authorizeUrl =
87 | "https://accounts.spotify.com/authorize" +
88 | `?response_type=code` +
89 | `&client_id=${encodeURIComponent(String(clientId))}` +
90 | `&scope=${encodeURIComponent(SCOPES)}` +
91 | `&redirect_uri=${encodeURIComponent(redirectUri)}`;
92 |
93 | window.location.href = authorizeUrl;
94 | };
95 |
96 | handleAuth();
97 | }, [validateToken, router]); // run once on mount
98 |
99 | return (
100 |
101 | {/* Left: animated loader */}
102 |
105 |
106 | {/* Right: text */}
107 |
108 |
109 | {status}
110 |
111 |
112 | This only takes a few seconds.
113 |
114 |
115 |
116 |
168 |
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Disclosure, Menu, Transition } from "@headlessui/react";
4 | import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline";
5 | import Link from "next/link";
6 | import { Fragment, useEffect, useState } from "react";
7 |
8 | type NavItem = { name: string; href: string };
9 |
10 | const navigation: NavItem[] = [
11 | { name: "Tracks", href: "/you/tracks" },
12 | { name: "Artists", href: "/you/artists" },
13 | { name: "Recent", href: "/you/recents" },
14 | ];
15 |
16 | function classNames(...classes: Array) {
17 | return classes.filter(Boolean).join(" ");
18 | }
19 |
20 | export default function Navbar() {
21 | const [userPicture, setUserPicture] = useState(
22 | "https://cdn.mkn.cx/myspotify/dev/profile.png"
23 | );
24 | const [loggedIn, setLoggedIn] = useState(false);
25 |
26 | useEffect(() => {
27 | const picture = localStorage.getItem("ms-user-img");
28 | if (picture) setUserPicture(picture);
29 | // Use localStorage to check login status (set by auth page when user info is stored)
30 | const userName = localStorage.getItem("ms-user-name");
31 | if (userName) setLoggedIn(true);
32 | }, []);
33 |
34 | return (
35 |
36 | {({ open }) => (
37 | <>
38 |
39 |
40 | {/* Mobile menu button */}
41 |
42 |
47 | Open main menu
48 | {open ? (
49 |
50 | ) : (
51 |
52 | )}
53 |
54 |
55 |
56 | {/* Brand + desktop nav */}
57 |
58 |
59 | NewTunes
60 |
61 |
62 |
63 | {navigation.map((item) => (
64 |
70 | {item.name}
71 |
72 | ))}
73 |
74 |
75 |
76 |
77 | {/* User menu */}
78 |
79 |
80 |
81 |
85 | Open user menu
86 | {/* Keeping for simplicity, swap to next/image if you prefer */}
87 |
92 |
93 |
94 |
103 |
107 |
108 | {({ active }) => (
109 |
116 | {loggedIn ? "Sign out" : "Sign in"}
117 |
118 | )}
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 | {/* Mobile nav */}
128 |
129 |
130 | {navigation.map((item) => (
131 |
137 | {item.name}
138 |
139 | ))}
140 |
141 |
142 | >
143 | )}
144 |
145 | );
146 | }
147 |
--------------------------------------------------------------------------------