Skip to content
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REDIRECT_URI=http://127.0.0.1:3000/api/auth/callback
LASTFM_API_KEY=
SESSION_SECRET=
TOKEN_ENCRYPTION_KEY=
DATABASE_URL=
Expand Down
315 changes: 130 additions & 185 deletions docs/PRD.md

Large diffs are not rendered by default.

514 changes: 220 additions & 294 deletions docs/SYSTEM_DESIGN.md

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "i.scdn.co", // Spotify artist/album images
},
{
protocol: "https",
hostname: "image-cdn-ak.spotifycdn.com", // Spotify playlist images
},
{
protocol: "https",
hostname: "image-cdn-fa.spotifycdn.com", // Spotify playlist images
},
{
protocol: "https",
hostname: "mosaic.scdn.co", // Spotify mosaic playlist covers
},
],
},
};

export default nextConfig;
127 changes: 127 additions & 0 deletions src/app/(protected)/profile/_components/artists-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"use client";

import { useState } from "react";
import Image from "next/image";
import { useTopArtists } from "@/hooks/use-top-artists";
import { SectionLabel } from "@/components/layout/section-label";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { TIME_RANGES } from "@/lib/constants";
import type { TimeRange } from "@/types";

export function ArtistsSection() {
const [timeRange, setTimeRange] = useState<TimeRange>("short_term");
const { data, isLoading } = useTopArtists(timeRange);

const topThree = data?.artists.slice(0, 3) ?? [];
const rest = data?.artists.slice(3, 9) ?? [];

return (
<section id="artists" aria-labelledby="artists-heading" className="py-16">
<div className="mb-6">
<SectionLabel className="mb-2">Defining Voices</SectionLabel>
<div className="flex items-end justify-between">
<h2 id="artists-heading" className="font-serif text-3xl font-medium tracking-tight">
Top Artists
</h2>
<div
className="flex gap-1 rounded-full border bg-muted/30 p-1"
role="group"
aria-label="Time range"
>
{TIME_RANGES.map((r) => (
<button
key={r.value}
onClick={() => setTimeRange(r.value)}
aria-pressed={timeRange === r.value}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-all",
timeRange === r.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{r.label}
</button>
))}
</div>
</div>
</div>

{/* Top 3 editorial grid */}
{isLoading ? (
<div className="mb-4 grid grid-cols-3 gap-px overflow-hidden rounded-2xl border">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded-none" />
))}
</div>
) : (
<div className="mb-4 grid grid-cols-3 gap-px overflow-hidden rounded-2xl border bg-border">
{topThree.map((artist, i) => (
<a
key={artist.id}
href={artist.spotifyUrl}
target="_blank"
rel="noopener noreferrer"
className="group relative aspect-square overflow-hidden bg-muted transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`${artist.name} — ranked #${i + 1}`}
>
{artist.imageUrl ? (
<Image
src={artist.imageUrl}
alt={artist.name}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
sizes="(max-width: 768px) 33vw, 280px"
loading={i === 0 ? "eager" : "lazy"}
priority={i === 0}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-4xl">🎵</div>
)}
<div className="absolute inset-0 bg-linear-to-t from-black/70 via-black/10 to-transparent" />
<div className="absolute bottom-0 left-0 p-4">
<p className="text-xs font-semibold text-white/60">#{i + 1}</p>
<p className="font-serif text-lg font-medium text-white">{artist.name}</p>
</div>
</a>
))}
</div>
)}

{/* Remaining artists list */}
{!isLoading && rest.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{rest.map((artist, i) => (
<a
key={artist.id}
href={artist.spotifyUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl border bg-muted/20 p-3 transition-colors hover:bg-muted/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={artist.name}
>
<div className="relative size-10 shrink-0 overflow-hidden rounded-full">
{artist.imageUrl ? (
<Image
src={artist.imageUrl}
alt={artist.name}
fill
className="object-cover"
sizes="40px"
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-lg">🎵</div>
)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{artist.name}</p>
<p className="text-xs text-muted-foreground">#{i + 4}</p>
</div>
</a>
))}
</div>
)}
</section>
);
}
67 changes: 67 additions & 0 deletions src/app/(protected)/profile/_components/genre-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { useGenreBreakdown } from "@/hooks/use-genre-breakdown";
import { useTopArtists } from "@/hooks/use-top-artists";
import { SectionLabel } from "@/components/layout/section-label";
import { Skeleton } from "@/components/ui/skeleton";

export function GenreSection() {
// Only fetch genre breakdown after top artists have loaded
const topArtistsQuery = useTopArtists("short_term");
const { data, isLoading } = useGenreBreakdown({
enabled: topArtistsQuery.isSuccess,
});

const genres = data?.genres ?? [];

return (
<section id="sound" aria-labelledby="sound-heading" className="py-16">
<SectionLabel className="mb-2">Sound DNA</SectionLabel>
<h2 id="sound-heading" className="mb-6 font-serif text-3xl font-medium tracking-tight">
Your Genre Fingerprint
</h2>

{isLoading ? (
<div className="space-y-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-3 w-24 shrink-0" />
<Skeleton className="h-2 flex-1 rounded-full" />
<Skeleton className="h-3 w-8 shrink-0" />
</div>
))}
</div>
) : genres.length === 0 ? (
<p className="text-sm text-muted-foreground">
Genre data is loading — check back in a moment.
</p>
) : (
<div className="space-y-4" role="list" aria-label="Genre breakdown">
{genres.map((entry) => (
<div key={entry.genre} className="flex items-center gap-4" role="listitem">
<span className="w-28 shrink-0 text-sm capitalize text-muted-foreground">
{entry.genre}
</span>
<div
className="h-2 flex-1 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={entry.weight}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${entry.genre}: ${entry.weight}%`}
>
<div
className="h-full rounded-full bg-foreground/80 transition-all duration-700"
style={{ width: `${entry.weight}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{entry.weight}%
</span>
</div>
))}
</div>
)}
</section>
);
}
59 changes: 59 additions & 0 deletions src/app/(protected)/profile/_components/playlists-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import Image from "next/image";
import { usePlaylists } from "@/hooks/use-playlists";
import { SectionLabel } from "@/components/layout/section-label";
import { Skeleton } from "@/components/ui/skeleton";

export function PlaylistsSection() {
const { data, isLoading } = usePlaylists();
const playlists = data?.playlists.slice(0, 6) ?? [];

return (
<section id="playlists" aria-labelledby="playlists-heading" className="py-16">
<SectionLabel className="mb-2">Your Collections</SectionLabel>
<h2 id="playlists-heading" className="mb-6 font-serif text-3xl font-medium tracking-tight">
Playlists
</h2>

<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{isLoading
? [...Array(6)].map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-square w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))
: playlists.map((playlist) => (
<a
key={playlist.id}
href={playlist.spotifyUrl}
target="_blank"
rel="noopener noreferrer"
className="group rounded-xl border bg-muted/20 p-3 transition-colors hover:bg-muted/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`${playlist.name} — ${playlist.trackCount} tracks`}
>
<div className="relative mb-3 aspect-square w-full overflow-hidden rounded-lg">
{playlist.imageUrl ? (
<Image
src={playlist.imageUrl}
alt={playlist.name}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
sizes="(max-width: 640px) 50vw, 33vw"
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-4xl">
🎵
</div>
)}
</div>
<p className="truncate font-serif text-sm font-medium">{playlist.name}</p>
<p className="text-xs text-muted-foreground">{playlist.trackCount} tracks</p>
</a>
))}
</div>
</section>
);
}
Loading
Loading