diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7130ff6..52195b6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,12 +13,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci - name: Run frontend tests - run: | - npm install --save-dev jest - npm test + run: npm test - name: Run backend tests run: | diff --git a/README.md b/README.md index d34cc0a..29cf7a7 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,17 @@ Once you have your Spotify developer account, you will need to ensure that you h Before running the command, you will need to create an `.env` file at the root directory. The file needs to have the following: ``` -CLIENT_ID="" -CLIENT_SECRET="" -STATE="" +CLIENT_ID="" +CLIENT_SECRET="" +STATE="" ``` For `STATE`, this value is up to the user, but it is recommended to use an encrypted password. Once you have followed the prerequisites and installed all the needed dependencies, simply run the following command: `go run main.go`. From there, your default browser should kick off the web application against `http://localhost:8080`. -## Running Program -To run the program, you have three options: GUI mode, interactive CLI, or command line arguments. +Once done, you should see the following login page accompanied by your unique home page: -### Testing -There is frontend and backend testing for the web application. For the backend changes, simply run the following command at the root directory: `go test ./... -v`. +![login-screen](assets/login.png) -For the frontend changes, once you have npm properly configured with Jest, simply run the following command: `npm test`. \ No newline at end of file +![home-screen](assets/home.png) \ No newline at end of file diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..82047d1 Binary files /dev/null and b/assets/favicon.ico differ diff --git a/assets/home.png b/assets/home.png new file mode 100644 index 0000000..7acab71 Binary files /dev/null and b/assets/home.png differ diff --git a/assets/login.png b/assets/login.png new file mode 100644 index 0000000..7f24f20 Binary files /dev/null and b/assets/login.png differ diff --git a/views/assets/spotify-logo.svg b/assets/spotify-logo.svg similarity index 100% rename from views/assets/spotify-logo.svg rename to assets/spotify-logo.svg diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..022cc54 --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,317 @@ +/* Global Styles */ +body { + margin: 0; + padding: 0; + min-height: 100vh; + font-family: 'Montserrat', Arial, sans-serif; + background: radial-gradient(circle at top right, #2a7b49 0%, #191414 46%, #111 100%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +/* Typography */ +h1 { + color: #fff; + font-size: 2rem; + margin-bottom: 16px; + font-weight: 700; + text-align: center; +} + +/* Layout */ +.powered-by { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #fff; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 1px; + margin-bottom: 0; +} + +.legal-disclaimer { + margin: 10px 0 0; + padding: 0 18px; + text-align: center; + color: rgba(255, 255, 255, 0.82); + font-size: 0.8rem; + font-weight: 500; + letter-spacing: 0.2px; + max-width: 680px; +} + +footer { + width: 100%; + position: fixed; + left: 0; + bottom: 0; + display: flex; + justify-content: center; + background: transparent; + z-index: 10; + padding-bottom: 24px; +} + +.spotify-logo { + display: none; +} + +.spotify-logo-footer { + width: 300px; + max-width: 80vw; + margin-top: 16px; + display: block; +} + +/* Navigation */ +.tabs { + display: flex; + gap: 24px; + justify-content: center; + margin-bottom: 32px; +} + +.tab-link { + color: #fff; + text-decoration: none; + font-size: 1.1rem; + font-weight: 600; + padding: 8px 20px; + border-radius: 30px; + transition: background 0.3s, color 0.3s, transform 0.2s; +} + +.tab-link:hover { + background: #1DB954; + color: #191414; + transform: scale(1.08); +} + +/* Home Page */ +body.home-page { + --home-heading-color: #fff; + justify-content: center; + align-items: center; + padding: 0 18px 140px; + overflow-x: hidden; +} + +.ambient-orb { + display: none; +} + +.orb-one { + top: -80px; + left: -100px; + background: #1DB954; +} + +.orb-two { + right: -110px; + bottom: 60px; + background: #95ffbe; +} + +header, +.home-main { + position: relative; + z-index: 1; +} + +.home-main { + width: min(760px, 100%); + margin: 16px auto 0; + display: grid; + gap: 16px; + color: #fff; + padding-bottom: 12px; +} + +.home-hero, +.snapshot-card, +.trend-panel { + background: rgba(0, 0, 0, 0.38); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 14px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.16); +} + +.home-hero { + padding: 22px; +} + +.home-title { + margin: 0; + color: var(--home-heading-color); + text-align: left; + font-size: clamp(1.45rem, 2.9vw, 2rem); + line-height: 1.25; +} + +.home-subtitle { + margin: 8px 0 0; + color: rgba(255, 255, 255, 0.88); + font-size: 0.98rem; +} + +.snapshot-grid { + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +.snapshot-card { + padding: 16px; +} + +.snapshot-card h2, +.trend-panel h2 { + margin: 0; + color: var(--home-heading-color); + font-size: 1.04rem; +} + +.snapshot-value { + margin: 10px 0 2px; + color: #fff; + font-size: 1.15rem; + font-weight: 700; +} + +.snapshot-meta { + margin: 0; + color: #fff; + font-size: 0.94rem; + line-height: 1.45; +} + +.trend-panel { + padding: 16px; +} + +.trend-note { + margin: 6px 0 0; + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; +} + +.trend-stats { + margin-top: 12px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.trend-stat { + background: rgba(255, 255, 255, 0.08); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.16); + padding: 12px; +} + +.trend-label { + display: block; + color: rgba(255, 255, 255, 0.86); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.trend-number { + display: block; + margin-top: 5px; + color: #fff; + font-size: 1.22rem; + font-weight: 700; +} + +/* Components */ +.login-btn { + background: #1DB954; + color: #fff; + border: none; + border-radius: 50px; + padding: 14px 32px; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s, transform 0.2s; + box-shadow: 0 2px 8px rgba(30,185,84,0.2); + display: block; + margin-left: auto; + margin-right: auto; + margin-top: 8px; +} + +.login-btn:hover { + background: #17a74a; + transform: scale(1.04); +} + +.artist-headshot { + width: 100px; + height: 100px; + border-radius: 50%; + margin-right: 18px; + object-fit: cover; + box-shadow: 0 2px 12px rgba(0,0,0,0.18); +} + +.artist-name { + color: #fff; + font-weight: 700; + font-size: 1.1rem; +} + +/* Animation */ +.fade-in { + opacity: 0; + animation: fadeIn 1.2s ease forwards; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Media Queries */ +@media (max-width: 500px) { + .login-container { + padding: 24px 8px; + max-width: 95vw; + } + + h1 { + font-size: 1.3rem; + } + + .tabs { + margin-top: 18px; + margin-bottom: 18px; + gap: 10px; + } + + .tab-link { + font-size: 0.92rem; + padding: 7px 12px; + } + + .home-main { + margin-top: 0; + gap: 14px; + } + + .home-hero, + .trend-panel { + padding: 16px; + } + + .snapshot-grid, + .trend-stats { + grid-template-columns: 1fr; + } +} diff --git a/authenticate/authenticate.go b/authenticate/authenticate.go index 7a72ca2..40c6feb 100644 --- a/authenticate/authenticate.go +++ b/authenticate/authenticate.go @@ -8,9 +8,8 @@ import ( spotifyauth "github.com/zmb3/spotify/v2/auth" ) -var ( - authenticator *spotifyauth.Authenticator - redirectURI = "http://127.0.0.1:8080/callback" +const ( + redirectURI = "http://127.0.0.1:8080/callback" ) // SpotifyAuthenticate enables login with your unique client ID and secret, loading env variables from the specified file. @@ -27,9 +26,9 @@ func SpotifyAuthenticate(envFile string) (*spotifyauth.Authenticator, error) { return nil, fmt.Errorf("CLIENT_ID and CLIENT_SECRET must be set") } - authenticator = spotifyauth.New( + authenticator := spotifyauth.New( spotifyauth.WithRedirectURL(redirectURI), - spotifyauth.WithScopes(spotifyauth.ScopeUserTopRead), + spotifyauth.WithScopes(spotifyauth.ScopeUserTopRead, spotifyauth.ScopeUserReadRecentlyPlayed), spotifyauth.WithClientID(clientID), spotifyauth.WithClientSecret(clientSecret), ) diff --git a/handler/handler.go b/handler/handler.go index 7afc318..138118a 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -6,7 +6,9 @@ import ( "log" "net/http" "os" + "strconv" "strings" + "time" "github.com/gorilla/mux" "github.com/joho/godotenv" @@ -20,14 +22,33 @@ func isAuthenticated(r *http.Request) bool { } func handleHomePage(w http.ResponseWriter, template *template.Template, userName string) { - template.Execute(w, map[string]interface{}{"UserName": userName}) + template.Execute(w, map[string]interface{}{ + "UserName": userName, + "TopGenre": cachedHomeStats.TopGenre, + "ListeningSessions": cachedHomeStats.ListeningSessions, + "UniqueArtists": cachedHomeStats.UniqueArtists, + "FreshDiscoveries": cachedHomeStats.FreshDiscoveries, + }) } var ( - cachedArtists []ArtistInfo - cachedTracks []TrackInfo + cachedArtists []ArtistInfo + cachedTracks []TrackInfo + cachedHomeStats = HomeStats{ + TopGenre: "Not enough data yet", + ListeningSessions: 0, + UniqueArtists: 0, + FreshDiscoveries: 0, + } ) +type HomeStats struct { + TopGenre string + ListeningSessions int + UniqueArtists int + FreshDiscoveries int +} + type ArtistInfo struct { Name string ImageURL string @@ -121,6 +142,7 @@ func handleTopTracksPage(w http.ResponseWriter, template *template.Template) { template.Execute(w, map[string]any{"TopTracks": cachedTracks}) } +// handleCallback is a handler for the Spotify authentication callback. func handleCallback(w http.ResponseWriter, r *http.Request, auth *spotifyauth.Authenticator, state string) { if r.FormValue("state") != state { http.Error(w, "State mismatch", http.StatusBadRequest) @@ -142,20 +164,25 @@ func handleCallback(w http.ResponseWriter, r *http.Request, auth *spotifyauth.Au userName = user.DisplayName } - topArtists, err := client.CurrentUsersTopArtists(r.Context(), spotify.Limit(5), spotify.Timerange("short_term")) + topArtists, err := client.CurrentUsersTopArtists(r.Context(), spotify.Limit(3), spotify.Timerange("short_term")) if err != nil { http.Error(w, "Failed to get top artists", http.StatusInternalServerError) log.Println("TopArtists error:", err) return } - topTracks, err := client.CurrentUsersTopTracks(r.Context(), spotify.Limit(5), spotify.Timerange("short_term")) + topTracks, err := client.CurrentUsersTopTracks(r.Context(), spotify.Limit(3), spotify.Timerange("short_term")) if err != nil { http.Error(w, "Failed to get top tracks", http.StatusInternalServerError) log.Println("TopTracks error:", err) return } + recentlyPlayed, recentErr := client.PlayerRecentlyPlayed(r.Context()) + if recentErr != nil { + log.Println("RecentlyPlayed error:", recentErr) + } + cachedTracks = nil for _, t := range topTracks.Tracks { artistName := "" @@ -163,7 +190,6 @@ func handleCallback(w http.ResponseWriter, r *http.Request, auth *spotifyauth.Au artistName = t.Artists[0].Name } - // Extract track ID from URI (format: spotify:track:TRACKID) uriString := string(t.URI) trackID := "" @@ -177,6 +203,10 @@ func handleCallback(w http.ResponseWriter, r *http.Request, auth *spotifyauth.Au Artist: artistName, TrackID: trackID, }) + + if len(cachedTracks) == 3 { + break + } } cachedArtists = nil @@ -198,7 +228,8 @@ func handleCallback(w http.ResponseWriter, r *http.Request, auth *spotifyauth.Au }) } - // Set authentication and username cookies + cachedHomeStats = deriveHomeStats(topArtists.Artists, topTracks.Tracks, recentlyPlayed) + http.SetCookie(w, &http.Cookie{ Name: "auth", Value: "true", @@ -215,3 +246,95 @@ func handleCallback(w http.ResponseWriter, r *http.Request, auth *spotifyauth.Au http.Redirect(w, r, "/", http.StatusFound) } + +// deriveHomeStats is a helper function to calculate the home page stats based on the user's top artists, +// tracks, and recently played items. +func deriveHomeStats(artists []spotify.FullArtist, tracks []spotify.FullTrack, recent []spotify.RecentlyPlayedItem) HomeStats { + stats := HomeStats{ + TopGenre: "Not enough data yet", + ListeningSessions: len(tracks), + UniqueArtists: 0, + FreshDiscoveries: 0, + } + + // Looking to find the most common genre amongst the top artists. + genreCounts := map[string]int{} + for _, artist := range artists { + for _, genre := range artist.Genres { + normalized := strings.TrimSpace(genre) + if normalized == "" { + continue + } + + genreCounts[normalized]++ + } + } + + maxGenreCount := 0 + for genre, count := range genreCounts { + if count > maxGenreCount { + maxGenreCount = count + stats.TopGenre = genre + } + } + + // Looking to see if there are any fresh discoveries in the top tracks. + now := time.Now() + for _, track := range tracks { + releaseDate := strings.TrimSpace(track.Album.ReleaseDate) + if releaseDate != "" && isSameMonthRelease(releaseDate, now) { + stats.FreshDiscoveries++ + } + } + + if len(recent) > 0 { + monthlyArtists := map[string]struct{}{} + stats.ListeningSessions = 0 + + for _, item := range recent { + if item.PlayedAt.Year() != now.Year() || item.PlayedAt.Month() != now.Month() { + continue + } + + stats.ListeningSessions++ + + for _, artist := range item.Track.Artists { + name := strings.TrimSpace(artist.Name) + if name != "" { + monthlyArtists[name] = struct{}{} + } + } + } + + stats.UniqueArtists = len(monthlyArtists) + return stats + } + + uniqueArtists := map[string]struct{}{} + for _, track := range tracks { + for _, artist := range track.Artists { + name := strings.TrimSpace(artist.Name) + if name != "" { + uniqueArtists[name] = struct{}{} + } + } + } + + stats.UniqueArtists = len(uniqueArtists) + + return stats +} + +// isSameMonthRelease is a helper function to determine if a track's release date is in the same +// month and year as the current date. It handles both "YYYY-MM-DD" and "YYYY-MM" formats. +func isSameMonthRelease(releaseDate string, now time.Time) bool { + if len(releaseDate) >= 7 { + year, yearErr := strconv.Atoi(releaseDate[0:4]) + month, monthErr := strconv.Atoi(releaseDate[5:7]) + if yearErr == nil && monthErr == nil { + return year == now.Year() && month == int(now.Month()) + } + } + + return false +} diff --git a/main.go b/main.go index e66af1f..49819f1 100644 --- a/main.go +++ b/main.go @@ -14,9 +14,12 @@ import ( ) const ( - envFile = ".env" - loginTemplateFile = "views/login.html" + envFile = ".env" + artistsTemplateFile = "views/top_artists.html" + footerTemplateFile = "views/shared/footer.html" + homeTemplateFile = "views/home.html" + loginTemplateFile = "views/login.html" tracksTemplateFile = "views/top_tracks.html" ) @@ -27,14 +30,15 @@ func main() { } r := mux.NewRouter() - loginTemplate := template.Must(template.ParseFiles(loginTemplateFile)) - homeTemplate := template.Must(template.ParseFiles("views/home.html")) - artistsTemplate := template.Must(template.ParseFiles(artistsTemplateFile)) - tracksTemplate := template.Must(template.ParseFiles(tracksTemplateFile)) + + loginTemplate := template.Must(template.ParseFiles(loginTemplateFile, footerTemplateFile)) + homeTemplate := template.Must(template.ParseFiles(homeTemplateFile, footerTemplateFile)) + artistsTemplate := template.Must(template.ParseFiles(artistsTemplateFile, footerTemplateFile)) + tracksTemplate := template.Must(template.ParseFiles(tracksTemplateFile, footerTemplateFile)) handler.SetupRoutes(r, &handler.SpotifyAuthWrapper{Authenticator: auth}, envFile, loginTemplate, homeTemplate, artistsTemplate, tracksTemplate) - r.PathPrefix("/assets/").Handler(http.StripPrefix("/assets/", http.FileServer(http.Dir("views/assets")))) + r.PathPrefix("/assets/").Handler(http.StripPrefix("/assets/", http.FileServer(http.Dir("assets")))) http.Handle("/", r) diff --git a/tests/unit/home.test.js b/tests/unit/home.test.js index 93bd2fc..228ca6f 100644 --- a/tests/unit/home.test.js +++ b/tests/unit/home.test.js @@ -3,7 +3,6 @@ describe('Home Page', () => { document.body.innerHTML = `

Welcome to AlGoRhythm!

-

Hello, TestUser! Glad to see you back.

`; }); diff --git a/views/assets/styles.css b/views/assets/styles.css deleted file mode 100644 index fa798f4..0000000 --- a/views/assets/styles.css +++ /dev/null @@ -1,142 +0,0 @@ -/* Global Styles */ -body { - margin: 0; - padding: 0; - min-height: 100vh; - font-family: 'Montserrat', Arial, sans-serif; - background: linear-gradient(135deg, #191414 0%, #1DB954 100%); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -/* Typography */ -h1 { - color: #fff; - font-size: 2rem; - margin-bottom: 16px; - font-weight: 700; - text-align: center; -} - -/* Layout */ -.powered-by { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #fff; - font-size: 1.5rem; - font-weight: 600; - letter-spacing: 1px; - margin-bottom: 0; -} - -footer { - width: 100%; - position: fixed; - left: 0; - bottom: 0; - display: flex; - justify-content: center; - background: transparent; - z-index: 10; - padding-bottom: 24px; -} - -.spotify-logo { - display: none; -} - -.spotify-logo-footer { - width: 300px; - max-width: 80vw; - margin-top: 16px; - display: block; -} - -/* Navigation */ -.tabs { - display: flex; - gap: 24px; - justify-content: center; - margin-bottom: 32px; -} - -.tab-link { - color: #fff; - text-decoration: none; - font-size: 1.1rem; - font-weight: 600; - padding: 8px 20px; - border-radius: 30px; - transition: background 0.3s, color 0.3s, transform 0.2s; -} - -.tab-link:hover { - background: #1DB954; - color: #191414; - transform: scale(1.08); -} - -/* Components */ -.login-btn { - background: #1DB954; - color: #fff; - border: none; - border-radius: 50px; - padding: 14px 32px; - font-size: 1.1rem; - font-weight: 700; - cursor: pointer; - transition: background 0.2s, transform 0.2s; - box-shadow: 0 2px 8px rgba(30,185,84,0.2); - display: block; - margin-left: auto; - margin-right: auto; - margin-top: 8px; -} - -.login-btn:hover { - background: #17a74a; - transform: scale(1.04); -} - -.artist-headshot { - width: 100px; - height: 100px; - border-radius: 50%; - margin-right: 18px; - object-fit: cover; - box-shadow: 0 2px 12px rgba(0,0,0,0.18); -} - -.artist-name { - color: #fff; - font-weight: 700; - font-size: 1.1rem; -} - -/* Animation */ -.fade-in { - opacity: 0; - animation: fadeIn 1.2s ease forwards; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Media Queries */ -@media (max-width: 500px) { - .login-container { - padding: 24px 8px; - max-width: 95vw; - } - - h1 { - font-size: 1.3rem; - } -} diff --git a/views/home.html b/views/home.html index 08322ac..089d481 100644 --- a/views/home.html +++ b/views/home.html @@ -4,11 +4,12 @@ Algorhythm - + + - -
-
+ + +
-
-

Hello, {{.UserName}}, welcome to AlGoRhythm! It's great to see you.

+
+
+

Hey {{.UserName}}, your listening story is ready!

+
+ +
+
+

Top Genre

+

{{.TopGenre}}

+
+
+ +
+

Trending This Month

+
+
+ Listening Sessions + {{.ListeningSessions}} +
+
+ Unique Artists + {{.UniqueArtists}} +
+
+ Fresh Discoveries + {{.FreshDiscoveries}} +
+
+
-
-
- Powered by - Spotify Logo -
-
+ {{template "footer" .}} diff --git a/views/login.html b/views/login.html index eafd541..2f5b39e 100644 --- a/views/login.html +++ b/views/login.html @@ -4,19 +4,19 @@ Login - + + - -

Welcome to AlGoRhythm!

-
- -
-
-
- Powered by - Spotify Logo -
-
+ + + +
+

Welcome to AlGoRhythm!

+
+ +
+
+ {{template "footer" .}} diff --git a/views/shared/footer.html b/views/shared/footer.html new file mode 100644 index 0000000..05b28e5 --- /dev/null +++ b/views/shared/footer.html @@ -0,0 +1,8 @@ +{{define "footer"}} +
+
+ Spotify Logo + +
+
+{{end}} \ No newline at end of file diff --git a/views/top_artists.html b/views/top_artists.html index b5a0dc4..c43dbe0 100644 --- a/views/top_artists.html +++ b/views/top_artists.html @@ -5,7 +5,7 @@ Top Artists - + @@ -30,11 +30,6 @@

Top Artists This Month

{{end}} -
-
- Powered by - Spotify Logo -
-
+ {{template "footer" .}} diff --git a/views/top_tracks.html b/views/top_tracks.html index db61254..d56b0c9 100644 --- a/views/top_tracks.html +++ b/views/top_tracks.html @@ -5,7 +5,7 @@ Top Tracks - + @@ -29,11 +29,6 @@

Top Tracks This Month

{{else}}

No tracks found.

{{end}} - + {{template "footer" .}}