-
Notifications
You must be signed in to change notification settings - Fork 20
new piper ui! #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
new piper ui! #75
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| ## Design Context | ||
|
|
||
| ### Users | ||
| Piper is for people who publish their listening activity through an Atmosphere account. They use this interface to connect music services, confirm that tracking is working, see the latest listen from each source, and manage access to their Piper data. | ||
|
|
||
| ### Brand Personality | ||
| Quiet, legible, and connected. The interface should feel dependable and personal, with the calm directness of Aktivi and none of the density of a conventional admin dashboard. | ||
|
|
||
| ### Aesthetic Direction | ||
| A light, refined utility interface based on the Lexidraw concept: three clearly separated music sources flow into one Atmosphere identity. Keep Piper's cream-and-green palette. Aktivi is the strongest structural reference, especially its oversized lowercase type, restrained navigation, generous spacing, and use of a real product object as the composition. Offprint informs typographic confidence and compact controls. Atmosphere Money informs the precise treatment of identity and protocol details. Soft status-tinted panels, generous whitespace, and plain language make connection state understandable at a glance. Avoid dashboard metrics, decorative gradients, heavy shadows, and excessive navigation. | ||
|
|
||
| ### Design Principles | ||
| - Put service connection state before secondary detail. | ||
| - Make the relationship between music sources and the Atmosphere account immediately visible. | ||
| - Use color as a redundant status cue, never as the only explanation. | ||
| - Reveal the latest useful record without turning the page into a feed. | ||
| - Keep setup actions close to the service they affect. | ||
| - Preserve full functionality on narrow screens with a simple vertical flow. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import ( | |
| "log" | ||
| "net/http" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/spf13/viper" | ||
|
|
@@ -19,15 +20,22 @@ import ( | |
| atprotoservice "github.com/teal-fm/piper/service/atproto" | ||
| "github.com/teal-fm/piper/service/musicbrainz" | ||
| "github.com/teal-fm/piper/service/playingnow" | ||
| profileservice "github.com/teal-fm/piper/service/profile" | ||
| "github.com/teal-fm/piper/service/spotify" | ||
| "github.com/teal-fm/piper/session" | ||
| ) | ||
|
|
||
| type HomeParams struct { | ||
| NavBar pages.NavBar | ||
| NavBar pages.NavBar | ||
| User *models.User | ||
| SpotifyTrack *models.Track | ||
| AppleMusicTrack *models.Track | ||
| LastFMTrack *models.Track | ||
| Atmosphere profileservice.Account | ||
| AppleMusicDevToken string | ||
| } | ||
|
|
||
| func home(database *db.DB, pg *pages.Pages) http.HandlerFunc { | ||
| func home(database *db.DB, pg *pages.Pages, profileResolver *profileservice.Resolver, appleMusicService *applemusic.Service) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, r *http.Request) { | ||
|
|
||
| w.Header().Set("Content-Type", "text/html") | ||
|
|
@@ -36,22 +44,63 @@ func home(database *db.DB, pg *pages.Pages) http.HandlerFunc { | |
| isLoggedIn := authenticated | ||
| lastfmUsername := "" | ||
|
|
||
| var user *models.User | ||
| var spotifyTrack, appleMusicTrack, lastFMTrack *models.Track | ||
| var atmosphere profileservice.Account | ||
| appleMusicDevToken := "" | ||
| appleMusicEnabled := viper.GetBool("enable_applemusic") && appleMusicService != nil | ||
|
|
||
| if isLoggedIn { | ||
| user, err := database.GetUserByID(userID) | ||
| fmt.Printf("User: %+v\n", user) | ||
| var err error | ||
| user, err = database.GetUserByID(userID) | ||
| if err == nil && user != nil && user.LastFMUsername != nil { | ||
| lastfmUsername = *user.LastFMUsername | ||
| lastfmUsername = strings.TrimSpace(*user.LastFMUsername) | ||
| } else if err != nil { | ||
| log.Printf("Error fetching user %d details for home page: %v", userID, err) | ||
| } | ||
| if user != nil && user.ATProtoDID != nil { | ||
| profileContext, cancel := context.WithTimeout(r.Context(), 5*time.Second) | ||
| atmosphere, err = profileResolver.Resolve(profileContext, *user.ATProtoDID) | ||
| cancel() | ||
| if err != nil { | ||
| log.Printf("Error resolving Atmosphere profile for user %d: %v", userID, err) | ||
| } | ||
| } | ||
| if appleMusicEnabled { | ||
| appleMusicDevToken, _, err = appleMusicService.GenerateDeveloperToken() | ||
| if err != nil { | ||
| log.Printf("Error preparing Apple Music on home page: %v", err) | ||
| appleMusicEnabled = false | ||
| } | ||
| } | ||
|
|
||
| spotifyTrack, err = database.GetLatestTrackForService(userID, db.SourceSpotify) | ||
| if err != nil { | ||
| log.Printf("Error fetching latest Spotify track for user %d: %v", userID, err) | ||
| } | ||
| appleMusicTrack, err = database.GetLatestTrackForService(userID, db.SourceAppleMusic) | ||
| if err != nil { | ||
| log.Printf("Error fetching latest Apple Music track for user %d: %v", userID, err) | ||
| } | ||
| lastFMTrack, err = database.GetLatestTrackForService(userID, db.SourceLastfm) | ||
| if err != nil { | ||
| log.Printf("Error fetching latest Last.fm track for user %d: %v", userID, err) | ||
| } | ||
| } | ||
| params := HomeParams{ | ||
| User: user, | ||
| SpotifyTrack: spotifyTrack, | ||
| AppleMusicTrack: appleMusicTrack, | ||
| LastFMTrack: lastFMTrack, | ||
| Atmosphere: atmosphere, | ||
| AppleMusicDevToken: appleMusicDevToken, | ||
| NavBar: pages.NavBar{ | ||
| IsLoggedIn: isLoggedIn, | ||
| CurrentPage: pages.NavConnections, | ||
| LastFMUsername: lastfmUsername, | ||
| SpotifyEnabled: viper.GetBool("enable_spotify"), | ||
| LastFMEnabled: viper.GetBool("enable_lastfm"), | ||
| AppleMusicEnabled: viper.GetBool("enable_applemusic"), | ||
| AppleMusicEnabled: appleMusicEnabled, | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }, | ||
| } | ||
| err := pg.Execute("home", w, params) | ||
|
|
@@ -70,7 +119,7 @@ func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc { | |
| return | ||
| } | ||
|
|
||
| lastfmUsername := r.FormValue("lastfm_username") | ||
| lastfmUsername := strings.TrimSpace(r.FormValue("lastfm_username")) | ||
| if lastfmUsername == "" { | ||
| http.Error(w, "Last.fm username cannot be empty", http.StatusBadRequest) | ||
| return | ||
|
|
@@ -91,7 +140,7 @@ func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc { | |
| currentUser, err := database.GetUserByID(userID) | ||
| currentUsername := "" | ||
| if err == nil && currentUser != nil && currentUser.LastFMUsername != nil { | ||
| currentUsername = *currentUser.LastFMUsername | ||
| currentUsername = strings.TrimSpace(*currentUser.LastFMUsername) | ||
| } else if err != nil { | ||
| log.Printf("Error fetching user %d for Last.fm form: %v", userID, err) | ||
| // Don't fail, just show an empty form | ||
|
|
@@ -105,6 +154,7 @@ func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc { | |
| }{ | ||
| NavBar: pages.NavBar{ | ||
| IsLoggedIn: authenticated, | ||
| CurrentPage: pages.NavConnections, | ||
| LastFMUsername: currentUsername, | ||
| SpotifyEnabled: viper.GetBool("enable_spotify"), | ||
| LastFMEnabled: viper.GetBool("enable_lastfm"), | ||
|
|
@@ -128,7 +178,7 @@ func handleLinkLastfmSubmit(database *db.DB) http.HandlerFunc { | |
| return | ||
| } | ||
|
|
||
| lastfmUsername := r.FormValue("lastfm_username") | ||
| lastfmUsername := strings.TrimSpace(r.FormValue("lastfm_username")) | ||
| if lastfmUsername == "" { | ||
| http.Error(w, "Last.fm username cannot be empty", http.StatusBadRequest) | ||
| return | ||
|
|
@@ -147,30 +197,19 @@ func handleLinkLastfmSubmit(database *db.DB) http.HandlerFunc { | |
| } | ||
| } | ||
|
|
||
| func handleAppleMusicLink(pg *pages.Pages, am *applemusic.Service) http.HandlerFunc { | ||
| func handleUnlinkLastfm(database *db.DB) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Set("Content-Type", "text/html") | ||
| devToken, _, errTok := am.GenerateDeveloperToken() | ||
| if errTok != nil { | ||
| log.Printf("Error generating Apple Music developer token: %v", errTok) | ||
| http.Error(w, "Failed to prepare Apple Music", http.StatusInternalServerError) | ||
| if r.Method != http.MethodPost { | ||
| http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | ||
| return | ||
| } | ||
| data := struct { | ||
| NavBar pages.NavBar | ||
| DevToken string | ||
| }{ | ||
| DevToken: devToken, | ||
| NavBar: pages.NavBar{ | ||
| SpotifyEnabled: viper.GetBool("enable_spotify"), | ||
| LastFMEnabled: viper.GetBool("enable_lastfm"), | ||
| AppleMusicEnabled: viper.GetBool("enable_applemusic"), | ||
| }, | ||
| } | ||
| err := pg.Execute("applemusic_link", w, data) | ||
| if err != nil { | ||
| log.Printf("Error executing template: %v", err) | ||
| userID, _ := session.GetUserID(r.Context()) | ||
| if err := database.ClearLastFMUsername(userID); err != nil { | ||
| log.Printf("Error removing Last.fm account for user %d: %v", userID, err) | ||
| http.Error(w, "Failed to remove Last.fm account", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| http.Redirect(w, r, "/", http.StatusSeeOther) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Protect the Last.fm unlink action from cross-site requests. This POST clears persistent account state using only ambient session authentication; it does not require a CSRF token or validate the request origin, and the session cookie has no explicit 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -332,6 +371,7 @@ func apiLinkLastfmHandler(database *db.DB) http.HandlerFunc { | |
| return | ||
| } | ||
|
|
||
| reqBody.LastFMUsername = strings.TrimSpace(reqBody.LastFMUsername) | ||
| if reqBody.LastFMUsername == "" { | ||
| jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Last.fm username cannot be empty"}) | ||
| return | ||
|
|
@@ -352,8 +392,7 @@ func apiUnlinkLastfmHandler(database *db.DB) http.HandlerFunc { | |
| return func(w http.ResponseWriter, r *http.Request) { | ||
| userID, _ := session.GetUserID(r.Context()) | ||
|
|
||
| // TODO: add a clear username for user id fn | ||
| err := database.AddLastFMUsername(userID, "") | ||
| err := database.ClearLastFMUsername(userID) | ||
| if err != nil { | ||
| log.Printf("apiUnlinkLastfmHandler: Error unlinking Last.fm username for user %d: %v", userID, err) | ||
| jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to unlink Last.fm username"}) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package db | ||
|
|
||
| import "testing" | ||
|
|
||
| func TestLastFMUsernameLifecycle(t *testing.T) { | ||
| database := newTestDB(t) | ||
| userID := createTestUser(t, database) | ||
|
|
||
| if err := database.AddLastFMUsername(userID, " listener "); err != nil { | ||
| t.Fatalf("add Last.fm username: %v", err) | ||
| } | ||
| user, err := database.GetUserByID(userID) | ||
| if err != nil { | ||
| t.Fatalf("load user: %v", err) | ||
| } | ||
| if user.LastFMUsername == nil || *user.LastFMUsername != "listener" { | ||
| t.Fatalf("got username %v, want listener", user.LastFMUsername) | ||
| } | ||
|
|
||
| if err := database.ClearLastFMUsername(userID); err != nil { | ||
| t.Fatalf("clear Last.fm username: %v", err) | ||
| } | ||
| user, err = database.GetUserByID(userID) | ||
| if err != nil { | ||
| t.Fatalf("reload user: %v", err) | ||
| } | ||
| if user.LastFMUsername != nil { | ||
| t.Fatalf("got username %q after clear, want nil", *user.LastFMUsername) | ||
| } | ||
| } | ||
|
|
||
| func TestBlankLastFMUsernameIsDisconnected(t *testing.T) { | ||
| database := newTestDB(t) | ||
| userID := createTestUser(t, database) | ||
|
|
||
| if err := database.AddLastFMUsername(userID, " "); err != nil { | ||
| t.Fatalf("add blank Last.fm username: %v", err) | ||
| } | ||
| user, err := database.GetUserByID(userID) | ||
| if err != nil { | ||
| t.Fatalf("load user: %v", err) | ||
| } | ||
| if user.LastFMUsername != nil { | ||
| t.Fatalf("blank username persisted as %q", *user.LastFMUsername) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After the profile cache expires, every authenticated home request synchronously waits for DID and avatar network lookups before rendering. A slow PDS or profile endpoint therefore delays the primary connections page by several seconds even though this profile decoration is nonessential.
Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!