├── 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 |
4 |
8 |
9 |
10 |
11 |
12 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 | {`${props.artistData.name}'s 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 | {`${props.songData.name}'s 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 |
5 | 6 | {`${props.artistData.name}'s 11 | 12 |
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 |
5 | 6 | {`${props.songData.name}'s 11 | 12 |
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 | 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 | 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 |
10 |
11 |
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 | Album artwork grid 64 |
65 |
66 |
67 | 68 | {/* Feature 2 */} 69 |
70 |
71 | {/* Image block */} 72 |
73 | Shareable profile link in a browser 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 |
103 |
104 |
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 | 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 | User avatar 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 | --------------------------------------------------------------------------------