diff --git a/.env b/.env index 4f826d3..8f2682c 100644 --- a/.env +++ b/.env @@ -1,8 +1,8 @@ -NEXT_PUBLIC_CLIENT_ID="319f3f19b0794ac28b1df51ca946609c" +# NEXT_PUBLIC_CLIENT_ID="319f3f19b0794ac28b1df51ca946609c" +# NEXT_PUBLIC_AUTH_ENDPOINT="https://accounts.spotify.com/authorize" +# NEXT_PUBLIC_RESPONSE_TYPE="token" +# NEXT_PUBLIC_BACKEND_URL="http://localhost:3001" NEXT_PUBLIC_REDIRECT_URI="http://localhost:3000/auth/callback" -NEXT_PUBLIC_AUTH_ENDPOINT="https://accounts.spotify.com/authorize" -NEXT_PUBLIC_RESPONSE_TYPE="token" -NEXT_PUBLIC_BACKEND_URL="http://localhost:3001" -NEXT_PUBLIC_FRONTENT_URL="localhost:3000" +NEXT_PUBLIC_FRONTEND_URL="localhost:3000" NEXT_PUBLIC_SUPABASE_URL=https://dkuewaaupmoqazilskoo.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRrdWV3YWF1cG1vcWF6aWxza29vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTA4MTc1MjIsImV4cCI6MjAyNjM5MzUyMn0.KgPgXhCY0jZxmUh9lOAYNcVBQFBU3vuSnl3Yfid_U6g diff --git a/README.md b/README.md index b49cc9e..9278e16 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,11 @@ ```bash npm install -touch api/.env ``` ## Development -To start the frontend and backend locally together, simply run `npm run dev`. Ctrl+C will kill both processes. - -### Frontend - -To run the frontend application in development: - -```bash -npm run next-dev -``` - -Access it from `localhost:3000`. - -### Backend - -To run the backend server in development: - -```bash -npm run server-dev -``` - -Access it from `localhost:3001`. +To start the application, simply run `npm run dev`. You can access it from `localhost:3000`. ## Testing @@ -49,4 +28,8 @@ npm run lint ## Production & Deployment -Coming soon! +The application is deployed at [http://unify-cs439.vercel.app](http://unify-cs439.vercel.app).\ +You can view an example of a user data page at [http://unify-cs439.vercel.app/user/testuser](http://unify-cs439.vercel.app/user/testuser)\ +You can view an example of a unify data page at [http://unify-cs439.vercel.app/unify/testuser&byee1029](http://unify-cs439.vercel.app/unify/testuser&byee1029)\ +To log in with Spotify, you must be a registered user, as the app is in development (on Spotify's end).\ +Contact to be added as a user. diff --git a/__tests__/__snapshots__/frontend.t.js.snap b/__tests__/__snapshots__/frontend.t.js.snap index 4cfad27..9e56b0c 100644 --- a/__tests__/__snapshots__/frontend.t.js.snap +++ b/__tests__/__snapshots__/frontend.t.js.snap @@ -273,7 +273,7 @@ exports[`Index Component matches the snapshot 1`] = ` style="cursor: pointer;" /> diff --git a/__tests__/backend.t.js b/__tests__/backend.t.js deleted file mode 100644 index 5127ece..0000000 --- a/__tests__/backend.t.js +++ /dev/null @@ -1,77 +0,0 @@ -const request = require("supertest"); -// const axios = require("axios"); -const app = require("../api/server"); - -jest.mock("axios"); - -describe("Express App Tests", () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("GET /api", () => { - it("should respond with JSON message", async () => { - const response = await request(app).get("/api"); - - expect(response.statusCode).toBe(200); - expect(response.body.message).toBe("Hello from server!"); - }); - }); - - // TODO @David these two tests below do not actually work lol - // TODO @David if you uncomment and run `npm test` then it returns errors - - // describe("GET /getUserProfile", () => { - // it("should respond with user profile when valid token is provided", async () => { - // axios.get.mockResolvedValue({ data: { uri: "some-uri" } }); - - // const response = await request(app).get( - // "/getUserProfile?token=valid-token", - // ); - - // expect(response.statusCode).toBe(200); - // expect(response.body.profile).toBeDefined(); - // }); - - // it("should respond with 400 when no token is provided", async () => { - // const response = await request(app).get("/getUserProfile"); - - // expect(response.statusCode).toBe(400); - // }); - - // it("should respond with 500 when error occurs", async () => { - // const response = await request(app).get( - // "/getTopItems?token=invalid-token", - // ); - - // expect(response.statusCode).toBe(500); - // }); - // }); - - // describe("GET /getTopItems", () => { - // it("should respond with top items when valid token and type are provided", async () => { - // axios.get.mockResolvedValue({ data: { items: ["item1", "item2"] } }); - - // const response = await request(app).get( - // "/getTopItems?token=valid-token&type=artists", - // ); - - // expect(response.statusCode).toBe(200); - // expect(response.body.topItems).toBeDefined(); - // }); - - // it("should respond with 400 when no token is provided", async () => { - // const response = await request(app).get("/getTopItems?type=artists"); - - // expect(response.statusCode).toBe(400); - // }); - - // it("should respond with 500 when error occurs", async () => { - // const response = await request(app).get( - // "/getTopItems?token=invalid-token&type=artists", - // ); - - // expect(response.statusCode).toBe(500); - // }); - // }); -}); diff --git a/api/.gitignore b/api/.gitignore deleted file mode 100644 index 4c49bd7..0000000 --- a/api/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env diff --git a/api/index.js b/api/index.js deleted file mode 100644 index dd15749..0000000 --- a/api/index.js +++ /dev/null @@ -1,12 +0,0 @@ -const app = require("./server"); - -app.listen(process.env.PORT || 3001, () => { - console.log(`App listening on port ${process.env.PORT || 3001}.`); // eslint-disable-line no-console -}); - -/* - * TODO - * migrate all server.js to spotify.js - * delete api folder - * update README, package.json, Github Action scripts to reflect changes - */ diff --git a/api/server.js b/api/server.js deleted file mode 100644 index 2e86b34..0000000 --- a/api/server.js +++ /dev/null @@ -1,267 +0,0 @@ -/* eslint-disable no-console */ -const express = require("express"); - -const axios = require("axios"); - -const cors = require("cors"); - -const app = express(); - -app.use(cors()); - -app.get("/api", (req, res) => { - res.json({ message: "Hello from server!" }); -}); - -/** ****** - * ! MIGRATE ALL THESE FUNCTIONS TO SPOTIFY.JS ! - ********* */ - -/** ****** - * this function has already been migrated - ********* */ -app.get("/getUserProfile", async (req, res) => { - const { token } = req.query; // Assuming the token is passed as a query parameter - - if (!token) { - return res.status(400).send("Token not provided."); - } - - try { - const { data } = await axios.get("https://api.spotify.com/v1/me", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - return res.json({ profile: data }); - } catch (error) { - console.error("Error fetching user profile:", error); - return res.status(500).send("Error fetching user profile."); - } -}); - -/** ****** - * this function has already been migrated - ********* */ -app.get("/getTopItems", async (req, res) => { - const { token, type } = req.query; - const timeRange = req.query.timeRange || "short_term"; - const limit = req.query.limit || 5; - - if (!token) { - return res.status(400).send("Token not provided."); - } - - try { - console.log( - `https://api.spotify.com/v1/me/top/${type}?time_range=${timeRange}&limit=${limit}`, - ); - const { data } = await axios.get( - `https://api.spotify.com/v1/me/top/${type}?time_range=${timeRange}&limit=${limit}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - return res.json({ topItems: data.items }); - } catch (error) { - console.error("Error fetching top items:", error); - return res.status(500).send("Error fetching top items."); - } -}); - -app.get("/getRecommendations", async (req, res) => { - const { token } = req.query; - const limit = req.query.limit || 10; - const seedGenres = req.query.seed_genres; - const seedArtists = req.query.seed_artists; - const seedTracks = req.query.seed_tracks; - - if (!token) { - return res.status(400).send("Token not provided."); - } - - let queryParams = `limit=${limit}`; - - if (seedGenres) - queryParams += `&seed_genres=${encodeURIComponent(seedGenres)}`; - if (seedArtists) - queryParams += `&seed_artists=${encodeURIComponent(seedArtists)}`; - if (seedTracks) - queryParams += `&seed_tracks=${encodeURIComponent(seedTracks)}`; - - try { - const { data } = await axios.get( - `https://api.spotify.com/v1/recommendations?${queryParams}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - return res.json(data); - } catch (error) { - console.error("Error fetching recommendations:", error); - return res.status(500).send("Error fetching recommendations."); - } -}); - -app.get("/getAudioFeatures", async (req, res) => { - const { token, ids } = req.query; - - if (!token) { - return res.status(400).send("Token not provided."); - } - - try { - const { data } = await axios.get( - `https://api.spotify.com/v1/audio-features?ids=${encodeURIComponent(ids)}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - return res.json(data); - } catch (error) { - console.error("Error fetching audio features:", error); - return res.status(500).send("Error fetching audio features"); - } -}); - -app.get("/getAverageAudioFeatures", async (req, res) => { - const { token } = req.query; - - // console.log("getting average audio features"); - - // console.log(token); - - if (!token) { - return res.status(400).json({ error: "Token not provided." }); - } - - try { - // const songs = null; - const songs = await axios.get( - `https://api.spotify.com/v1/me/top/tracks?time_range=short_term&limit=5`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - // console.log(songs.data); - - if (!songs) { - console.error("Failed to fetch top items"); - return res.status(500).send("Failed to fetch top items"); - } - - const trackIds = songs.data.items.map((track) => track.id).join(","); - - const { data } = await axios.get( - `https://api.spotify.com/v1/audio-features?ids=${encodeURIComponent(trackIds)}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - const audioFeatures = data.audio_features; - - const featuresSum = audioFeatures.reduce( - (acc, feature) => { - acc.acousticness += feature.acousticness; - acc.danceability += feature.danceability; - acc.energy += feature.energy; - acc.instrumentalness += feature.instrumentalness; - acc.speechiness += feature.speechiness; - acc.valence += feature.valence; - return acc; - }, - { - acousticness: 0, - danceability: 0, - energy: 0, - instrumentalness: 0, - speechiness: 0, - valence: 0, - }, - ); - - const featuresAvg = Object.keys(featuresSum).reduce((acc, key) => { - acc[key] = (featuresSum[key] * 100) / audioFeatures.length; - return acc; - }, {}); - - return res.json(featuresAvg); - } catch (error) { - console.error("Error fetching audio features:", error); - return res.status(500).send("Error fetching audio features"); - } -}); - -app.get("/getUserData", async (req, res) => { - const { token } = req.query; // Assuming the token is passed as a query parameter - - if (!token) { - return res.status(400).send("Token not provided."); - } - - try { - const userProfileResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/getUserProfile?token=${token}`, - ); - - const userProfile = userProfileResponse.data.profile; - - // console.log("user profile: ", userProfileResponse); - - // Fetch average audio features - const averageAudioFeaturesResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/getAverageAudioFeatures?token=${token}`, - ); - const averageAudioFeatures = averageAudioFeaturesResponse.data; - - const featuresData = Object.keys(averageAudioFeatures).map((key) => ({ - feature: key, - value: averageAudioFeatures[key], - })); - - // Fetch top artists - const topArtistsResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/getTopItems?token=${token}&type=artists`, - ); - - const topArtists = topArtistsResponse.data.topItems; - - // Fetch top songs - const topSongsResponse = await axios.get( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/getTopItems?token=${token}&type=tracks`, - ); - - const topSongs = topSongsResponse.data.topItems; - - // Constructing the user data JSON - const userData = { - userProfile, - featuresData, - topArtists, - topSongs, - }; - - return res.json(userData); - } catch (error) { - console.error("Error fetching user data:", error); - return res.status(500).send("Error fetching user data."); - } -}); - -module.exports = app; diff --git a/package.json b/package.json index 8168531..78bd0ec 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,10 @@ "version": "0.1.0", "private": true, "scripts": { - "next-dev": "next dev", - "server-dev": "nodemon -r dotenv/config api/index.js dotenv_config_path=api/.env", - "dev": "concurrently \"npm run server-dev\" \"npm run next-dev\" --kill-others", + "dev": "next dev", "build": "next build", "start": "next start", "test": "jest", - "server": "node -r dotenv/config api/index.js dotenv_config_path=api/.env", "lint-next": "next lint", "lint": "eslint --fix --c .eslintrc.json --ext .js,.jsx,.mjs .; prettier --write --config ./.prettierrc .", "lint:dev": "eslint --c .eslintrc.json --ext .js,.jsx,.mjs . && prettier --check --config ./.prettierrc .", @@ -39,7 +36,6 @@ "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", "autoprefixer": "^10.4.17", - "concurrently": "^8.2.2", "eslint": "^8.56.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-next": "14.1.0", diff --git a/src/app/account/UNUSED_ROUTE b/src/app/account/UNUSED_ROUTE new file mode 100644 index 0000000..e69de29 diff --git a/src/app/error/error.jsx b/src/app/error/error.jsx new file mode 100644 index 0000000..ca16634 --- /dev/null +++ b/src/app/error/error.jsx @@ -0,0 +1,49 @@ +"use client"; + +import React from "react"; +import PropTypes from "prop-types"; + +function ErrorAlert({ Title, Message, RedirectTo }) { + const handleClose = () => { + if (RedirectTo) { + // Redirect to "/" + window.location.href = RedirectTo; + } + }; + + return ( +
+ {Title} + {Message} + {RedirectTo && ( + + + Close + + + + )} +
+ ); +} + +ErrorAlert.propTypes = { + Title: PropTypes.string.isRequired, + Message: PropTypes.string.isRequired, + RedirectTo: PropTypes.string, +}; + +ErrorAlert.defaultProps = { + RedirectTo: null, // Set RedirectTo prop default value to null +}; + +export default ErrorAlert; diff --git a/src/app/error/page.jsx b/src/app/error/page.jsx index c509622..4cec131 100644 --- a/src/app/error/page.jsx +++ b/src/app/error/page.jsx @@ -1,10 +1,7 @@ -// TODO style this page +"use client"; + +import ErrorAlert from "@/app/error/error"; export default function ErrorPage() { - return ( -
-

Uh Oh!

-

Sorry, something went wrong.

-
- ); + return ; } diff --git a/src/app/login/UNUSED_ROUTE b/src/app/login/UNUSED_ROUTE new file mode 100644 index 0000000..e69de29 diff --git a/src/app/unify/[users]/UnifyContent.jsx b/src/app/unify/[users]/UnifyContent.jsx index 71efbf6..94e7a0e 100644 --- a/src/app/unify/[users]/UnifyContent.jsx +++ b/src/app/unify/[users]/UnifyContent.jsx @@ -5,13 +5,140 @@ import PropTypes from "prop-types"; import ShareUnify from "@/components/svg-art/share_unify"; import "@/app/globals.css"; -function calculateSimilarity(list1, list2) { +function calculateGenreSimilarity(list1, list2) { const intersection = Object.keys(list1).filter((key) => Object.prototype.hasOwnProperty.call(list2, key), ).length; const union = Object.keys({ ...list1, ...list2 }).length; const similarity = intersection / union; - return similarity * 100; // Convert to percentage + return Math.round(similarity * 100); // Convert to percentage +} + +// check how far away matching top artists are from each other in top artists list +function calculateArtistSimilarity(list1, list2) { + const maxLength = Math.max(list1.length, list2.length); + let Similarity = 0; + for (let i = 0; i < maxLength; i++) { + if (list2.includes(list1[i])) { + const j = list2.indexOf(list1[i]); + Similarity += 1 / (Math.abs(i - j) + 1) / 5; + // console.log(i, j, Similarity); + } + // if (list1[i] === list2[i]) { + // Similarity += maxLength - i; + // } + } + return Math.min(Similarity * 100, 100); +} + +function featureDataSimilarity(features1, features2) { + // console.log(features1, features2); + if (features1.length !== features2.length) { + throw new Error("Arrays must have the same length"); + } + let totalDifference = 0; + for (let i = 0; i < features1.length; i++) { + totalDifference += Math.abs(features1[i].value - features2[i].value); + } + + // calculate song feature similarity by squaring average difference in song feaure + // console.log(totalDifference / features1.length / 100); + return Math.round((1 - totalDifference / features1.length / 100) ** 2 * 100); +} + +function percentMatch(user1, user2) { + const genreSimilarity = calculateGenreSimilarity( + user1.topGenres, + user2.topGenres, + ); + const featureSimilarity = featureDataSimilarity( + user1.featuresData, + user2.featuresData, + ); + const artistSimilarity = calculateArtistSimilarity( + user1.topArtists.map((artist) => artist.name), + user2.topArtists.map((artist) => artist.name), + ); + return Math.round( + (genreSimilarity + featureSimilarity + artistSimilarity) / 3, + ); +} + +function VinylCircle({ centerCircleColor }) { + const radii = []; + for (let i = 159; i > 41; i -= 3) { + radii.push(i); + } + + return ( + + {radii.map((radius) => ( + + ))} + + + + ); +} + +function GenrePieChart({ data, centerCircleColor }) { + return ( +
+ +
+ +
+
+ ); } export default function UnifyContent({ user1Data, user2Data }) { @@ -67,14 +194,31 @@ export default function UnifyContent({ user1Data, user2Data }) { 501, ); + // draw percent match to canvas + ctx.font = "70px Koulen"; + ctx.strokeStyle = "black"; + ctx.lineWidth = 6; + ctx.miterLimit = 2; // fix miter bug + ctx.strokeText( + `${percentMatch(user1Data, user2Data)}% Match`, + canvas.width / 2, + 220, + ); + ctx.fillStyle = "white"; + ctx.fillText( + `${percentMatch(user1Data, user2Data)}% Match`, + canvas.width / 2, + 220, + ); + // Convert canvas to blob canvas.toBlob((blob) => { if (navigator.share) { navigator .share({ title: "Unify with me!", - text: `Compare our stats on Uni.fy`, - url: "unify", + text: `Compare our stats on Unify`, + url: "", files: [ new File([blob], "file.png", { type: blob.type, @@ -118,9 +262,8 @@ export default function UnifyContent({ user1Data, user2Data }) { .slice(0, 5) // Get the top 5 genres .map(([id, value]) => ({ id, value })); // Map to { id: genre, value: frequency } objects - const genreSimilarity = calculateSimilarity( - user1Data.topGenres, - user2Data.topGenres, + const genreSimilarity = Math.round( + calculateGenreSimilarity(user1Data.topGenres, user2Data.topGenres), ); return ( @@ -129,8 +272,7 @@ export default function UnifyContent({ user1Data, user2Data }) { className="font-koulen" style={{ fontSize: 100, textAlign: "center" }} > - {" "} - 0% Match! + {percentMatch(user1Data, user2Data)}% Match!
{/*