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
9 changes: 4 additions & 5 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import NextAuth from 'next-auth'
import SpotifyProvider from 'next-auth/providers/spotify'
import type { JWT } from 'next-auth/jwt'
import type { Account, User } from 'next-auth'
import { JWT } from 'next-auth/jwt'
import { Account, User } from 'next-auth'

const SPOTIFY_SCOPES = [
'user-read-email',
Expand Down Expand Up @@ -36,7 +35,7 @@
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, // Fall back to old refresh token
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
}
} catch (error) {
console.error('Error refreshing access token', error)
Expand All @@ -55,6 +54,7 @@
authorization: `https://accounts.spotify.com/authorize?scope=${SPOTIFY_SCOPES}`,
}),
],
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async jwt({
token,
Expand All @@ -80,7 +80,7 @@

return refreshAccessToken(token)
},
async session({ session, token }: { session: any; token: JWT }) {

Check failure on line 83 in app/api/auth/[...nextauth]/route.ts

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
session.user = token.user as User
session.accessToken = token.accessToken
session.error = token.error
Expand All @@ -90,7 +90,6 @@
},
}

//@ts-expect-error ???
const handler = NextAuth(authOptions)

export { handler as GET, handler as POST }
18 changes: 14 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
'use client'

import MatchModal from '@/components/shared/MatchModal'
import SpotifyConnect from '@/components/shared/SpotifyConnect'
import SwipeDeck from '@/components/shared/SwipeDeck'
import { useSession } from 'next-auth/react'

export default function Home() {
const { data: session, status } = useSession()

Check warning on line 9 in app/page.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'session' is assigned a value but never used

return (
<main className='flex min-h-screen flex-col items-center p-12 md:p-24'>
<div className='w-full max-w-2xl text-center'>
<h1 className='text-4xl font-bold mb-8'>Chordially</h1>
<SpotifyConnect />
<main className='flex min-h-screen flex-col items-center justify-center p-4 md:p-8'>
<MatchModal />

<div className='w-full max-w-2xl text-center mb-8'>
<h1 className='text-4xl font-bold'>Chordially</h1>
</div>

{status === 'authenticated' ? <SwipeDeck /> : <SpotifyConnect />}
</main>
)
}
62 changes: 62 additions & 0 deletions components/shared/MatchModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client'

import { useAuthStore } from '@/stores/auth-store'
import { AnimatePresence, motion } from 'framer-motion'
import Image from 'next/image'

export default function MatchModal() {
const { isMatchModalOpen, matchedUser, closeMatchModal } = useAuthStore()

return (
<AnimatePresence>
{isMatchModalOpen && matchedUser && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className='fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50'
onClick={closeMatchModal}
>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className='bg-gradient-to-br from-pink-500 to-purple-600 p-8 rounded-2xl text-white text-center shadow-2xl'
onClick={(e) => e.stopPropagation()}
>
<h1 className='text-4xl font-bold mb-4'>It&apos;s a Chord!</h1>
<p className='text-lg mb-6'>
You and {matchedUser.name} have matched!
</p>
<div className='flex justify-center items-center space-x-4'>
<Image
src={
useAuthStore.getState().user?.avatarUrl ||
'/default-avatar.png'
}
alt='Your avatar'
width={100}
height={100}
className='rounded-full border-4 border-white'
/>
<Image
src={matchedUser.avatarUrl}
alt={matchedUser.name}
width={100}
height={100}
className='rounded-full border-4 border-white'
/>
</div>
<button
onClick={closeMatchModal}
className='mt-8 bg-white text-purple-600 font-bold py-2 px-6 rounded-full'
>
Keep Swiping
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
30 changes: 30 additions & 0 deletions components/shared/MusicCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { UserProfile } from '@/lib/api-schema'
import Image from 'next/image'

interface MusicCardProps {
user: UserProfile
}

export default function MusicCard({ user }: MusicCardProps) {
return (
<div className='relative h-full w-full rounded-2xl shadow-2xl overflow-hidden'>
<Image
src={user.avatarUrl}
alt={user.name}
fill
className='object-cover'
priority
/>
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/20' />
<div className='absolute bottom-0 left-0 p-6 text-white'>
<h2 className='text-3xl font-bold'>
{user.name}, {user.age}
</h2>
<p className='mt-2 text-lg font-semibold'>Anthem:</p>
<p className='text-md'>
{user.musicProfile.anthem.title} - {user.musicProfile.anthem.artist}
</p>
</div>
</div>
)
}
155 changes: 155 additions & 0 deletions components/shared/SwipeDeck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
'use client'

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type {
PotentialMatchesResponse,
SwipeRequest,
SwipeResponse,
UserProfile,
} from '@/lib/api-schema'
import { useState } from 'react'
import {
motion,
useMotionValue,
useTransform,
AnimatePresence,
} from 'framer-motion'
import MusicCard from './MusicCard'
import { useAuthStore } from '@/stores/auth-store'

const fetchPotentialMatches = async (): Promise<UserProfile[]> => {
const res = await fetch('/api/users/potential-matches')
if (!res.ok) throw new Error('Network response was not ok')
const data: PotentialMatchesResponse = await res.json()
return data.users
}

const postSwipe = async (
swipe: SwipeRequest
): Promise<{ res: SwipeResponse; swipedUser: UserProfile }> => {
const swipedUser = (swipe as any).swipedUser

Check failure on line 30 in components/shared/SwipeDeck.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
const res = await fetch('/api/swipes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: swipe.userId, direction: swipe.direction }),
})
if (!res.ok) throw new Error('Swipe failed')
return { res: await res.json(), swipedUser }
}

export default function SwipeDeck() {
const [users, setUsers] = useState<UserProfile[]>([])
const queryClient = useQueryClient()
const openMatchModal = useAuthStore((state) => state.openMatchModal)

const { isLoading, isError } = useQuery({
queryKey: ['potentialMatches'],
queryFn: fetchPotentialMatches,
onSuccess: (data) => {
setUsers(data)
},
})

const swipeMutation = useMutation({
mutationFn: postSwipe,
onSuccess: ({ res, swipedUser }) => {
if (res.isMatch) {
openMatchModal(swipedUser)
}

queryClient.prefetchQuery({
queryKey: ['potentialMatches'],
queryFn: fetchPotentialMatches,
})
},
onError: (error) => {
console.error('Swipe mutation failed:', error)
},
})

const onSwipe = (direction: 'left' | 'right') => {
if (users.length === 0) return

const swipedUser = users[0]
swipeMutation.mutate({
userId: swipedUser.id,
direction,
swipedUser: swipedUser as any,

Check failure on line 77 in components/shared/SwipeDeck.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
})

setUsers((prevUsers) => prevUsers.slice(1))

if (users.length <= 3) {
queryClient.invalidateQueries({ queryKey: ['potentialMatches'] })
}
}

if (isLoading)
return <div className='text-center'>Finding potential matches...</div>
if (isError)
return <div className='text-center text-red-500'>Something went wrong.</div>

return (
<div className='relative w-full max-w-sm h-[500px] mx-auto'>
<AnimatePresence>
{users.length > 0 ? (
users
.map((user, index) => {
if (index === 0) {
return (
<DraggableCard key={user.id} user={user} onSwipe={onSwipe} />
)
}
return (
<motion.div
key={user.id}
className='absolute inset-0'
style={{
transform: `scale(${1 - index * 0.05}) translateY(${index * 10}px)`,
zIndex: -index,
}}
>
<MusicCard user={user} />
</motion.div>
)
})
.reverse()
) : (
<div className='text-center'>
No more users to show. Come back later!
</div>
)}
</AnimatePresence>
</div>
)
}

function DraggableCard({
user,
onSwipe,
}: {
user: UserProfile
onSwipe: (direction: 'left' | 'right') => void
}) {
const x = useMotionValue(0)
const rotate = useTransform(x, [-200, 200], [-30, 30])
const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0, 1, 1, 1, 0])

return (
<motion.div
className='absolute inset-0 cursor-grab'
drag='x'
dragConstraints={{ left: 0, right: 0 }}
style={{ x, rotate, opacity }}
onDragEnd={(event, info) => {
if (info.offset.x > 100) {
onSwipe('right')
} else if (info.offset.x < -100) {
onSwipe('left')
}
}}
>
<MusicCard user={user} />
</motion.div>
)
}
Loading
Loading