Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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