From 7ed090edefb0a40a11f0455968c2f68651641f42 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Thu, 27 Aug 2026 12:39:54 -0500 Subject: [PATCH 1/5] new piper ui! --- .impeccable.md | 18 + cmd/handlers.go | 101 +- cmd/main.go | 3 + cmd/routes.go | 7 +- db/db.go | 6 + db/lfm.go | 16 +- db/lfm_test.go | 46 + pages/pages.go | 33 + pages/static/base.css | 388 +++++++ pages/static/main.css | 1194 ++++++++++++++++++---- pages/templates/apiKeys.gohtml | 141 ++- pages/templates/applemusic_link.gohtml | 244 ----- pages/templates/components/navBar.gohtml | 62 +- pages/templates/home.gohtml | 538 ++++++++-- pages/templates/lastFMForm.gohtml | 44 +- pages/templates/layouts/base.gohtml | 19 +- service/apikey/apikey.go | 3 +- service/profile/profile.go | 150 +++ 18 files changed, 2291 insertions(+), 722 deletions(-) create mode 100644 .impeccable.md create mode 100644 db/lfm_test.go delete mode 100644 pages/templates/applemusic_link.gohtml create mode 100644 service/profile/profile.go diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..c1e1c63 --- /dev/null +++ b/.impeccable.md @@ -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. diff --git a/cmd/handlers.go b/cmd/handlers.go index 439bca0..b8c103e 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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, }, } 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) } } @@ -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"}) diff --git a/cmd/main.go b/cmd/main.go index 118306f..9fbfbd0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,6 +23,7 @@ import ( "github.com/teal-fm/piper/service/lastfm" "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" ) @@ -38,6 +39,7 @@ type application struct { playingNowService *playingnow.Service appleMusicService *applemusic.Service pages *pages.Pages + profileResolver *profileservice.Resolver } // JSON API handlers @@ -216,6 +218,7 @@ func main() { playingNowService: playingNowService, appleMusicService: appleMusicService, pages: pages.NewPages(), + profileResolver: profileservice.NewResolver(), } trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second diff --git a/cmd/routes.go b/cmd/routes.go index 2a58bad..0337848 100644 --- a/cmd/routes.go +++ b/cmd/routes.go @@ -14,7 +14,7 @@ func (app *application) routes() http.Handler { //Handles static file routes mux.Handle("/static/{file_name}", app.pages.Static()) - mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages), app.sessionManager)) + mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages, app.profileResolver, app.appleMusicService), app.sessionManager)) // OAuth Routes mux.HandleFunc("/login/atproto", app.oauthManager.HandleLogin("atproto")) @@ -30,7 +30,10 @@ func (app *application) routes() http.Handler { mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly - mux.HandleFunc("/link-applemusic", session.WithAuth(handleAppleMusicLink(app.pages, app.appleMusicService), app.sessionManager)) + mux.HandleFunc("/unlink-lastfm", session.WithAuth(handleUnlinkLastfm(app.database), app.sessionManager)) + mux.HandleFunc("/link-applemusic", session.WithAuth(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusSeeOther) + }, app.sessionManager)) mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto")) mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) diff --git a/db/db.go b/db/db.go index 1cefd99..29921f7 100644 --- a/db/db.go +++ b/db/db.go @@ -71,6 +71,12 @@ func (db *DB) Initialize() error { return err } + // Older versions could store whitespace as a linked Last.fm account. + // Keep the persisted state aligned with what the UI considers connected. + if _, err = db.Exec(`UPDATE users SET lastfm_username = NULL WHERE TRIM(COALESCE(lastfm_username, '')) = ''`); err != nil { + return err + } + _, err = db.Exec(` CREATE TABLE IF NOT EXISTS tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/db/lfm.go b/db/lfm.go index 983c1aa..498ade7 100644 --- a/db/lfm.go +++ b/db/lfm.go @@ -2,11 +2,16 @@ package db import ( "database/sql" + "strings" "github.com/teal-fm/piper/models" ) func (db *DB) AddLastFMUsername(userID int64, lastfmUsername string) error { + lastfmUsername = strings.TrimSpace(lastfmUsername) + if lastfmUsername == "" { + return db.ClearLastFMUsername(userID) + } _, err := db.Exec(` UPDATE users SET lastfm_username = ? @@ -15,11 +20,20 @@ func (db *DB) AddLastFMUsername(userID int64, lastfmUsername string) error { return err } +func (db *DB) ClearLastFMUsername(userID int64) error { + _, err := db.Exec(` + UPDATE users + SET lastfm_username = NULL + WHERE id = ?`, userID) + + return err +} + func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) { rows, err := db.Query(` SELECT id, username, email, lastfm_username FROM users - WHERE lastfm_username IS NOT NULL + WHERE lastfm_username IS NOT NULL AND TRIM(lastfm_username) != '' ORDER BY id`) if err != nil { diff --git a/db/lfm_test.go b/db/lfm_test.go new file mode 100644 index 0000000..8e361ff --- /dev/null +++ b/db/lfm_test.go @@ -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) + } +} diff --git a/pages/pages.go b/pages/pages.go index 77a11af..cead260 100644 --- a/pages/pages.go +++ b/pages/pages.go @@ -12,6 +12,8 @@ import ( "net/http" "strings" "time" + + "github.com/teal-fm/piper/models" ) //go:embed templates/* static/* @@ -107,6 +109,31 @@ func (p *Pages) funcMap() template.FuncMap { } return t.Format("Jan 02, 2006 15:04") }, + "artistNames": func(artists []models.Artist) string { + if len(artists) == 0 { + return "Unknown artist" + } + names := make([]string, 0, len(artists)) + for _, artist := range artists { + if artist.Name != "" { + names = append(names, artist.Name) + } + } + if len(names) == 0 { + return "Unknown artist" + } + return strings.Join(names, ", ") + }, + "shortDID": func(did *string) string { + if did == nil || *did == "" { + return "Not available" + } + const prefix = "did:plc:" + if strings.HasPrefix(*did, prefix) && len(*did) > len(prefix)+10 { + return prefix + (*did)[len(prefix):len(prefix)+6] + "…" + (*did)[len(*did)-4:] + } + return *did + }, } } @@ -156,8 +183,14 @@ func (p *Pages) Execute(name string, w io.Writer, params any) error { type NavBar struct { IsLoggedIn bool + CurrentPage string LastFMUsername string SpotifyEnabled bool LastFMEnabled bool AppleMusicEnabled bool } + +const ( + NavConnections = "connections" + NavAPIAccess = "api-access" +) diff --git a/pages/static/base.css b/pages/static/base.css index f1d8c73..2c5374a 100644 --- a/pages/static/base.css +++ b/pages/static/base.css @@ -1 +1,389 @@ @import "tailwindcss"; + +:root { + --ink: oklch(24% 0.018 175); + --muted: oklch(49% 0.018 175); + --faint: oklch(66% 0.012 175); + --paper: oklch(97.5% 0.009 155); + --paper-deep: oklch(94.5% 0.013 155); + --line: oklch(84% 0.016 160); + --teal: oklch(55% 0.105 180); + --teal-dark: oklch(38% 0.075 180); + --connected-bg: oklch(94% 0.055 150); + --connected-line: oklch(74% 0.085 150); + --connected: oklch(46% 0.12 150); + --warning-bg: oklch(94.5% 0.045 25); + --warning-line: oklch(78% 0.08 25); + --warning: oklch(50% 0.14 25); + --unavailable-bg: oklch(95.5% 0.008 175); + --unavailable-line: oklch(82% 0.012 175); + --shadow: 0 10px 28px color-mix(in oklab, var(--ink) 6%, transparent); + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1rem; + --space-xl: 1.5rem; + --space-2xl: 2rem; + --space-3xl: 3rem; + --space-4xl: 4rem; + color-scheme: light; +} + +* { box-sizing: border-box; } + +html { min-width: 20rem; background: var(--paper); } + +body { + margin: 0; + color: var(--ink); + background: + radial-gradient(circle at 50% -20%, color-mix(in oklab, var(--teal) 7%, transparent), transparent 42rem), + var(--paper); + font-family: "Geologica", sans-serif; + font-size: 0.9375rem; + font-weight: 400; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } + +:focus-visible { + outline: 3px solid color-mix(in oklab, var(--teal) 55%, transparent); + outline-offset: 3px; +} + +::selection { color: var(--ink); background: color-mix(in oklab, var(--teal) 25%, transparent); } + +.site-shell { + width: min(100% - 2rem, 77rem); + margin-inline: auto; + padding-bottom: var(--space-4xl); +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 5.5rem; + gap: var(--space-xl); + border-bottom: 1px solid color-mix(in oklab, var(--line) 68%, transparent); +} + +.wordmark { + display: inline-flex; + align-items: center; + gap: 0.65rem; + font-family: "Gabarito", sans-serif; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.035em; +} + +.wordmark-symbol { + position: relative; + display: inline-flex; + align-items: flex-end; + justify-content: center; + width: 1.7rem; + height: 1.7rem; + gap: 0.14rem; + padding: 0.34rem; + overflow: hidden; + border-radius: 50%; + color: var(--paper); + background: var(--ink); +} + +.wordmark-symbol i { display: block; width: 0.18rem; border-radius: 1rem; background: currentColor; transform-origin: bottom; } +.wordmark-symbol i:nth-child(1) { height: 45%; transform: rotate(-12deg); } +.wordmark-symbol i:nth-child(2) { height: 90%; } +.wordmark-symbol i:nth-child(3) { height: 62%; transform: rotate(12deg); } + +.site-header nav { display: flex; align-items: center; gap: var(--space-xl); color: var(--muted); font-size: 0.82rem; font-weight: 500; } +.site-header nav a { position: relative; padding-block: 0.45rem; transition: color 180ms cubic-bezier(0.22, 1, 0.36, 1); } +.site-header nav a:hover { color: var(--ink); } +.site-header nav a[aria-current="page"] { color: var(--ink); } +.site-header nav a[aria-current="page"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--ink); } +.site-header nav .logout-link { padding: 0.52rem 0.8rem; border: 1px solid var(--line); border-radius: 0.45rem; color: var(--ink); } + +.page-intro { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.65fr); + align-items: end; + gap: var(--space-3xl); + padding-block: clamp(3rem, 7vw, 5.5rem) var(--space-3xl); +} + +.eyebrow, .detail-label { + margin: 0 0 var(--space-sm); + color: var(--teal-dark); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.11em; + line-height: 1.2; + text-transform: uppercase; +} + +h1, h2, p { margin-top: 0; } +h1, h2 { font-family: "Gabarito", sans-serif; } + +.page-intro h1 { + max-width: 12ch; + margin-bottom: 0; + font-size: clamp(2.75rem, 6vw, 5rem); + font-weight: 600; + letter-spacing: -0.055em; + line-height: 0.91; +} + +.intro-copy { max-width: 38ch; margin: 0 0 0.2rem; color: var(--muted); font-size: 1rem; line-height: 1.65; } + +.connection-map { position: relative; } +.service-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-lg); } + +.service-panel { + position: relative; + display: flex; + flex-direction: column; + min-height: 28rem; + overflow: hidden; + border: 1px solid var(--connected-line); + border-radius: 0.8rem; + background: var(--connected-bg); + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), border-color 220ms ease; +} + +.service-panel:hover { transform: translateY(-2px); } +.service-panel.is-not-connected { border-color: var(--line); background: color-mix(in oklab, var(--paper) 65%, var(--paper-deep)); } +.service-panel.is-unavailable { border-color: var(--unavailable-line); background: var(--unavailable-bg); } + +.service-header { padding: var(--space-xl) var(--space-xl) var(--space-lg); border-bottom: 1px solid color-mix(in oklab, currentColor 10%, transparent); } +.service-identity { display: flex; align-items: center; gap: var(--space-md); } +.service-identity h2 { margin-bottom: 0.16rem; font-size: 1.28rem; font-weight: 600; letter-spacing: -0.03em; } + +.service-mark { + display: grid; + place-items: center; + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + border: 1px solid color-mix(in oklab, currentColor 13%, transparent); + border-radius: 50%; + background: color-mix(in oklab, var(--paper) 65%, transparent); + font-family: "Gabarito", sans-serif; + font-weight: 700; +} + +.spotify-mark { color: oklch(45% 0.17 150); } +.apple-mark { color: oklch(52% 0.21 20); font-size: 1.2rem; } +.lastfm-mark { color: oklch(49% 0.2 25); font-size: 0.76rem; letter-spacing: -0.05em; } + +.status-label { display: flex; align-items: center; gap: 0.4rem; margin: 0; color: var(--connected); font-size: 0.72rem; font-weight: 500; } +.is-not-connected .status-label { color: var(--muted); } +.is-unavailable .status-label { color: var(--muted); } +.status-dot { display: inline-block; width: 0.42rem; height: 0.42rem; flex: 0 0 auto; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 12%, transparent); } + +.service-body { display: flex; flex: 1; flex-direction: column; gap: var(--space-xl); padding: var(--space-xl); } +.account-line { margin: 0; color: var(--muted); font-size: 0.78rem; } +.account-line strong { color: var(--ink); font-weight: 500; } +.service-note { max-width: 28ch; margin: auto 0; color: var(--muted); font-size: 0.9rem; line-height: 1.65; } +.latest-listen { margin-top: auto; } +.latest-listen .detail-label, .empty-listen .detail-label { color: var(--muted); } +.track-name { margin-bottom: 0.05rem; font-family: "Gabarito", sans-serif; font-size: 1.4rem; font-weight: 600; letter-spacing: -0.025em; line-height: 1.15; } +.track-artist { margin-bottom: var(--space-md); color: var(--muted); font-size: 0.85rem; } +.latest-listen time { color: var(--faint); font-size: 0.72rem; } +.empty-listen { margin-top: auto; color: var(--muted); font-size: 0.82rem; } +.empty-listen p:last-child { max-width: 27ch; margin-bottom: 0; } + +.service-actions { display: flex; align-items: center; min-height: 4.2rem; gap: var(--space-md); padding: var(--space-lg) var(--space-xl); border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent); } +.service-actions button { border-style: solid; cursor: pointer; font-family: inherit; } +.service-actions button:disabled { cursor: wait; opacity: 0.62; } +.service-inline-status { min-height: 0; margin: 0; padding-inline: var(--space-xl); color: var(--muted); font-size: 0.7rem; } +.service-inline-status:not(:empty) { min-height: 2.25rem; padding-bottom: var(--space-lg); } +.primary-action, .quiet-action, .text-action { display: inline-flex; align-items: center; justify-content: center; min-height: 2.35rem; border-radius: 999px; font-size: 0.76rem; font-weight: 600; transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1), background 160ms ease; } +.primary-action { padding-inline: 1rem; color: var(--paper); background: var(--ink); } +.primary-action:hover { transform: translateY(-1px); background: var(--teal-dark); } +.quiet-action { padding-inline: 0.9rem; border: 1px solid color-mix(in oklab, var(--ink) 18%, transparent); } +.quiet-action:hover { background: color-mix(in oklab, var(--paper) 45%, transparent); } +.text-action { margin-right: auto; padding: 0; border-radius: 0; } +.text-action span { margin-left: 0.35rem; transition: transform 160ms ease; } +.text-action:hover span { transform: translate(2px, -2px); } + +.connection-lines { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); height: 4.5rem; margin-inline: 8%; } +.connection-lines::after { content: ""; position: absolute; left: 16.666%; right: 16.666%; bottom: 1.4rem; border-top: 1px solid var(--line); } +.connection-lines span { position: relative; } +.connection-lines span::before { content: ""; position: absolute; top: 0; bottom: 1.4rem; left: 50%; border-left: 1px solid var(--line); } +.connection-lines span:nth-child(2)::after { content: ""; position: absolute; top: calc(100% - 1.4rem); left: 50%; height: 1.4rem; border-left: 1px solid var(--line); } + +.atmosphere-account { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + width: min(100%, 36rem); + gap: var(--space-lg); + margin-inline: auto; + padding: 1.2rem 1.35rem; + border: 1px solid color-mix(in oklab, var(--teal) 42%, var(--line)); + border-radius: 0.7rem; + background: color-mix(in oklab, var(--teal) 10%, var(--paper)); + box-shadow: var(--shadow); +} + +.atmosphere-symbol { display: inline-flex; align-items: flex-end; justify-content: center; width: 2.65rem; height: 2.65rem; gap: 0.2rem; padding: 0.62rem; border-radius: 50%; color: var(--paper); background: var(--teal-dark); } +.atmosphere-avatar { width: 2.65rem; height: 2.65rem; border: 1px solid color-mix(in oklab, var(--teal) 32%, var(--line)); border-radius: 50%; object-fit: cover; } +.atmosphere-symbol span { display: block; width: 0.22rem; border-radius: 99px; background: currentColor; } +.atmosphere-symbol span:nth-child(1) { height: 55%; transform: rotate(-10deg); } +.atmosphere-symbol span:nth-child(2) { height: 100%; } +.atmosphere-symbol span:nth-child(3) { height: 70%; transform: rotate(10deg); } +.atmosphere-account .detail-label { margin-bottom: 0.18rem; } +.atmosphere-account h2 { margin-bottom: 0.08rem; font-size: 1.18rem; font-weight: 600; letter-spacing: -0.025em; } +.atmosphere-account h2 + p { margin: 0; color: var(--muted); font-size: 0.76rem; } +.atmosphere-logout { display: inline-block; margin-top: var(--space-md); color: var(--teal-dark); font-size: 0.74rem; font-weight: 600; text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--teal) 45%, transparent); text-underline-offset: 0.2em; } +.atmosphere-logout:hover { text-decoration-color: currentColor; } + +.remove-connection { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); } +.remove-connection h2 { margin-bottom: var(--space-sm); } +.remove-connection p { margin: 0; color: var(--muted); font-size: 0.82rem; } +.danger-button { min-height: 2.7rem; padding-inline: 1rem; border: 1px solid color-mix(in oklab, var(--warning) 35%, var(--line)); border-radius: 0.4rem; color: var(--warning); background: transparent; cursor: pointer; font-size: 0.78rem; font-weight: 600; } +.danger-button:hover { background: var(--warning-bg); } + +.login-shell { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(22rem, 0.75fr); align-items: center; min-height: calc(100vh - 5.5rem); gap: clamp(3rem, 9vw, 8rem); padding-block: var(--space-4xl); } +.login-copy h1 { max-width: 13ch; margin-bottom: var(--space-xl); font-size: clamp(3rem, 6vw, 5.5rem); font-weight: 600; letter-spacing: -0.065em; line-height: 0.94; } +.login-copy > p:last-child { max-width: 48ch; margin: 0; color: var(--muted); font-size: 1rem; line-height: 1.7; } +.login-panel { padding: clamp(1.5rem, 4vw, 2.5rem); border: 1px solid var(--line); border-radius: 0.75rem; background: color-mix(in oklab, var(--paper) 80%, var(--paper-deep)); box-shadow: var(--shadow); transform: rotate(0.45deg); transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1); } +.login-panel:focus-within { transform: rotate(0deg); } +.login-panel-heading { display: flex; align-items: center; gap: var(--space-lg); margin-bottom: var(--space-2xl); } +.login-panel-heading h2 { margin: 0; font-size: 1.45rem; letter-spacing: -0.03em; } + +.stacked-form { display: flex; flex-direction: column; gap: var(--space-md); } +.stacked-form label { font-size: 0.78rem; font-weight: 600; } +.typeahead-control { position: relative; } +.typeahead-control::after { content: ""; position: absolute; z-index: 21; top: calc(100% - 2px); right: 0.55rem; left: 0.55rem; height: 2px; border-radius: 99px; background: var(--teal); opacity: 0; transform: scaleX(0); transform-origin: left; } +.typeahead-control.is-loading::after { opacity: 1; animation: typeahead-progress 1.1s cubic-bezier(0.22, 1, 0.36, 1) infinite; } +.input-group { display: grid; grid-template-columns: 1fr auto; gap: var(--space-sm); padding: 0.35rem; border: 1px solid var(--line); border-radius: 0.55rem; background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep)); transition: border-color 160ms ease, box-shadow 160ms ease; } +.input-group:focus-within { border-color: var(--teal); box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent); } +.input-group input { min-width: 0; padding: 0.7rem 0.65rem; border: 0; outline: 0; background: transparent; } +.input-group input::placeholder { color: var(--faint); } +.input-group button, .form-button { min-height: 2.7rem; padding-inline: 1rem; border: 0; border-radius: 0.4rem; color: var(--paper); background: var(--teal-dark); cursor: pointer; font-size: 0.78rem; font-weight: 600; } +.field-help { margin: 0; color: var(--faint); font-size: 0.7rem; line-height: 1.55; } + +.typeahead-results { + position: absolute; + z-index: 20; + top: calc(100% + 0.45rem); + right: 0; + left: 0; + max-height: min(22rem, 55vh); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.32rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--paper); + box-shadow: 0 14px 30px color-mix(in oklab, var(--ink) 10%, transparent); +} + +.typeahead-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + width: 100%; + gap: var(--space-md); + padding: 0.62rem 0.7rem; + border: 0; + border-radius: 0.4rem; + color: var(--ink); + background: transparent; + cursor: pointer; + text-align: left; +} + +.typeahead-option:hover, +.typeahead-option[aria-selected="true"] { + background: var(--paper-deep); +} + +.actor-avatar { + display: grid; + place-items: center; + width: 2.1rem; + height: 2.1rem; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 50%; + color: var(--teal-dark); + background: color-mix(in oklab, var(--teal) 12%, var(--paper)); + font-family: "Gabarito", sans-serif; + font-size: 0.8rem; + font-weight: 600; +} + +.actor-avatar img { width: 100%; height: 100%; object-fit: cover; } +.actor-identity { display: flex; min-width: 0; flex-direction: column; } +.actor-identity strong, .actor-identity span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.actor-identity strong { font-size: 0.78rem; font-weight: 600; } +.actor-identity span { color: var(--muted); font-size: 0.68rem; } + +@keyframes typeahead-progress { + 0% { transform: scaleX(0); transform-origin: left; } + 48% { transform: scaleX(0.72); transform-origin: left; } + 52% { transform: scaleX(0.72); transform-origin: right; } + 100% { transform: scaleX(0); transform-origin: right; } +} + +.content-page { width: min(100%, 48rem); margin-inline: auto; padding-block: var(--space-4xl); } +.content-page-heading { margin-bottom: var(--space-2xl); } +.content-page-heading h1 { margin-bottom: var(--space-md); font-size: clamp(2.5rem, 5vw, 4rem); font-weight: 600; letter-spacing: -0.055em; line-height: 1; } +.content-page-heading p { max-width: 58ch; margin: 0; color: var(--muted); } +.settings-section { padding-block: var(--space-2xl); border-top: 1px solid var(--line); } +.settings-section h2 { margin-bottom: var(--space-sm); font-size: 1.4rem; letter-spacing: -0.03em; } +.settings-section > p { color: var(--muted); } +.field { display: flex; flex-direction: column; gap: var(--space-sm); margin-top: var(--space-xl); } +.field label { font-size: 0.78rem; font-weight: 600; } +.text-input { width: 100%; min-height: 3rem; padding-inline: var(--space-lg); border: 1px solid var(--line); border-radius: 0.75rem; background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep)); outline: 0; } +.text-input:focus { border-color: var(--teal); box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent); } +.form-actions { display: flex; align-items: center; gap: var(--space-md); margin-top: var(--space-xl); } +.danger-button { color: oklch(49% 0.16 25); background: transparent; border: 1px solid oklch(76% 0.08 25); } +.inline-status { margin-top: var(--space-lg); color: var(--muted); font-size: 0.78rem; white-space: pre-wrap; } +.code-sample { display: block; overflow-x: auto; margin-block: var(--space-md); padding: var(--space-lg); border: 1px solid var(--line); border-radius: 0.7rem; background: var(--paper-deep); font-family: ui-monospace, monospace; font-size: 0.78rem; } +.key-table { width: 100%; margin-top: var(--space-lg); border-collapse: collapse; font-size: 0.82rem; } +.key-table th, .key-table td { padding: var(--space-md); border-bottom: 1px solid var(--line); text-align: left; } +.key-table th { color: var(--muted); font-size: 0.68rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; } + +@media (max-width: 56rem) { + .page-intro { grid-template-columns: 1fr; gap: var(--space-lg); } + .page-intro h1 { max-width: 12ch; } + .service-grid { grid-template-columns: 1fr; } + .service-panel { min-height: 21rem; } + .connection-lines { display: block; height: 3rem; margin: 0; } + .connection-lines::after { left: 50%; right: auto; top: 0; bottom: 0; border-top: 0; border-left: 1px solid var(--line); } + .connection-lines span { display: none; } + .login-shell { grid-template-columns: 1fr; align-content: center; min-height: auto; gap: var(--space-3xl); } + .login-copy h1 { max-width: 15ch; } +} + +@media (max-width: 38rem) { + .site-shell { width: min(100% - 1.25rem, 77rem); } + .site-header { min-height: 4.5rem; } + .site-header nav { gap: var(--space-lg); } + .site-header nav a[aria-current="page"], .site-header nav .logout-link { display: none; } + .page-intro { padding-block: var(--space-3xl) var(--space-xl); } + .page-intro h1 { font-size: 2.7rem; } + .service-header, .service-body { padding: var(--space-lg); } + .service-actions { padding-inline: var(--space-lg); } + .atmosphere-account { grid-template-columns: auto 1fr; } + .remove-connection { align-items: flex-start; flex-direction: column; } + .login-shell { padding-block: var(--space-3xl); } + .login-copy h1 { font-size: 3rem; } + .input-group { grid-template-columns: 1fr; } + .input-group button { width: 100%; } + .key-table { display: block; overflow-x: auto; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/pages/static/main.css b/pages/static/main.css index d4dedb1..c824273 100644 --- a/pages/static/main.css +++ b/pages/static/main.css @@ -7,23 +7,6 @@ "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --color-gray-100: oklch(96.7% 0.003 264.542); - --color-gray-200: oklch(92.8% 0.006 264.531); - --color-gray-300: oklch(87.2% 0.01 258.338); - --color-gray-400: oklch(70.7% 0.022 261.325); - --color-gray-600: oklch(44.6% 0.03 256.802); - --color-white: #fff; - --spacing: 0.25rem; - --text-lg: 1.125rem; - --text-lg--line-height: calc(1.75 / 1.125); - --text-xl: 1.25rem; - --text-xl--line-height: calc(1.75 / 1.25); - --font-weight-semibold: 600; - --font-weight-bold: 700; - --leading-relaxed: 1.625; - --radius-lg: 0.5rem; - --default-transition-duration: 150ms; - --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); } @@ -177,6 +160,20 @@ } } @layer utilities { + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + .fixed { + position: fixed; + } .static { position: static; } @@ -198,218 +195,1047 @@ max-width: 96rem; } } - .mx-auto { - margin-inline: auto; - } - .my-5 { - margin-block: calc(var(--spacing) * 5); - } - .mt-1 { - margin-top: calc(var(--spacing) * 1); - } - .mt-3 { - margin-top: calc(var(--spacing) * 3); - } - .mb-1 { - margin-bottom: calc(var(--spacing) * 1); - } - .mb-2 { - margin-bottom: calc(var(--spacing) * 2); - } - .mb-3 { - margin-bottom: calc(var(--spacing) * 3); - } - .mb-4 { - margin-bottom: calc(var(--spacing) * 4); - } - .mb-5 { - margin-bottom: calc(var(--spacing) * 5); - } .block { display: block; } .contents { display: contents; } - .flex { - display: flex; + .hidden { + display: none; } .table { display: table; } - .w-\[95\%\] { - width: 95%; - } - .w-full { - width: 100%; - } - .max-w-\[600px\] { - max-width: 600px; - } - .max-w-\[800px\] { - max-width: 800px; - } - .flex-shrink { - flex-shrink: 1; - } - .border-collapse { - border-collapse: collapse; + .grow { + flex-grow: 1; } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } - .cursor-not-allowed { - cursor: not-allowed; - } - .cursor-pointer { - cursor: pointer; - } - .list-disc { - list-style-type: disc; + .lowercase { + text-transform: lowercase; } - .flex-wrap { - flex-wrap: wrap; + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } - .space-y-2 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); - } +} +:root { + --ink: oklch(24% 0.018 175); + --muted: oklch(49% 0.018 175); + --faint: oklch(66% 0.012 175); + --paper: oklch(97.5% 0.009 155); + --paper-deep: oklch(94.5% 0.013 155); + --line: oklch(84% 0.016 160); + --teal: oklch(55% 0.105 180); + --teal-dark: oklch(38% 0.075 180); + --connected-bg: oklch(94% 0.055 150); + --connected-line: oklch(74% 0.085 150); + --connected: oklch(46% 0.12 150); + --warning-bg: oklch(94.5% 0.045 25); + --warning-line: oklch(78% 0.08 25); + --warning: oklch(50% 0.14 25); + --unavailable-bg: oklch(95.5% 0.008 175); + --unavailable-line: oklch(82% 0.012 175); + --shadow: 0 10px 28px var(--ink); + @supports (color: color-mix(in lab, red, red)) { + --shadow: 0 10px 28px color-mix(in oklab, var(--ink) 6%, transparent); + } + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1rem; + --space-xl: 1.5rem; + --space-2xl: 2rem; + --space-3xl: 3rem; + --space-4xl: 4rem; + color-scheme: light; +} +* { + box-sizing: border-box; +} +html { + min-width: 20rem; + background: var(--paper); +} +body { + margin: 0; + color: var(--ink); + background: radial-gradient(circle at 50% -20%, var(--teal), transparent 42rem), var(--paper); + @supports (color: color-mix(in lab, red, red)) { + background: radial-gradient(circle at 50% -20%, color-mix(in oklab, var(--teal) 7%, transparent), transparent 42rem), var(--paper); + } + font-family: "Geologica", sans-serif; + font-size: 0.9375rem; + font-weight: 400; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} +a { + color: inherit; + text-decoration: none; +} +button, input { + font: inherit; +} +button, a { + -webkit-tap-highlight-color: transparent; +} +:focus-visible { + outline: 3px solid var(--teal); + @supports (color: color-mix(in lab, red, red)) { + outline: 3px solid color-mix(in oklab, var(--teal) 55%, transparent); } - .gap-x-4 { - column-gap: calc(var(--spacing) * 4); + outline-offset: 3px; +} +::selection { + color: var(--ink); + background: var(--teal); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--teal) 25%, transparent); } - .gap-y-1 { - row-gap: calc(var(--spacing) * 1); +} +.site-shell { + width: min(100% - 2rem, 77rem); + margin-inline: auto; + padding-bottom: var(--space-4xl); +} +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 5.5rem; + gap: var(--space-xl); + border-bottom: 1px solid var(--line); + @supports (color: color-mix(in lab, red, red)) { + border-bottom: 1px solid color-mix(in oklab, var(--line) 68%, transparent); } - .rounded { - border-radius: 0.25rem; +} +.wordmark { + display: inline-flex; + align-items: center; + gap: 0.65rem; + font-family: "Gabarito", sans-serif; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.035em; +} +.wordmark-symbol { + position: relative; + display: inline-flex; + align-items: flex-end; + justify-content: center; + width: 1.7rem; + height: 1.7rem; + gap: 0.14rem; + padding: 0.34rem; + overflow: hidden; + border-radius: 50%; + color: var(--paper); + background: var(--ink); +} +.wordmark-symbol i { + display: block; + width: 0.18rem; + border-radius: 1rem; + background: currentColor; + transform-origin: bottom; +} +.wordmark-symbol i:nth-child(1) { + height: 45%; + transform: rotate(-12deg); +} +.wordmark-symbol i:nth-child(2) { + height: 90%; +} +.wordmark-symbol i:nth-child(3) { + height: 62%; + transform: rotate(12deg); +} +.site-header nav { + display: flex; + align-items: center; + gap: var(--space-xl); + color: var(--muted); + font-size: 0.82rem; + font-weight: 500; +} +.site-header nav a { + position: relative; + padding-block: 0.45rem; + transition: color 180ms cubic-bezier(0.22, 1, 0.36, 1); +} +.site-header nav a:hover { + color: var(--ink); +} +.site-header nav a[aria-current="page"] { + color: var(--ink); +} +.site-header nav a[aria-current="page"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: var(--ink); +} +.site-header nav .logout-link { + padding: 0.52rem 0.8rem; + border: 1px solid var(--line); + border-radius: 0.45rem; + color: var(--ink); +} +.page-intro { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.65fr); + align-items: end; + gap: var(--space-3xl); + padding-block: clamp(3rem, 7vw, 5.5rem) var(--space-3xl); +} +.eyebrow, .detail-label { + margin: 0 0 var(--space-sm); + color: var(--teal-dark); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.11em; + line-height: 1.2; + text-transform: uppercase; +} +h1, h2, p { + margin-top: 0; +} +h1, h2 { + font-family: "Gabarito", sans-serif; +} +.page-intro h1 { + max-width: 12ch; + margin-bottom: 0; + font-size: clamp(2.75rem, 6vw, 5rem); + font-weight: 600; + letter-spacing: -0.055em; + line-height: 0.91; +} +.intro-copy { + max-width: 38ch; + margin: 0 0 0.2rem; + color: var(--muted); + font-size: 1rem; + line-height: 1.65; +} +.connection-map { + position: relative; +} +.service-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-lg); +} +.service-panel { + position: relative; + display: flex; + flex-direction: column; + min-height: 28rem; + overflow: hidden; + border: 1px solid var(--connected-line); + border-radius: 0.8rem; + background: var(--connected-bg); + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), border-color 220ms ease; +} +.service-panel:hover { + transform: translateY(-2px); +} +.service-panel.is-not-connected { + border-color: var(--line); + background: var(--paper); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--paper) 65%, var(--paper-deep)); } - .rounded-lg { - border-radius: var(--radius-lg); +} +.service-panel.is-unavailable { + border-color: var(--unavailable-line); + background: var(--unavailable-bg); +} +.service-header { + padding: var(--space-xl) var(--space-xl) var(--space-lg); + border-bottom: 1px solid currentColor; + @supports (color: color-mix(in lab, red, red)) { + border-bottom: 1px solid color-mix(in oklab, currentColor 10%, transparent); } - .border { - border-style: var(--tw-border-style); - border-width: 1px; +} +.service-identity { + display: flex; + align-items: center; + gap: var(--space-md); +} +.service-identity h2 { + margin-bottom: 0.16rem; + font-size: 1.28rem; + font-weight: 600; + letter-spacing: -0.03em; +} +.service-mark { + display: grid; + place-items: center; + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + border: 1px solid currentColor; + @supports (color: color-mix(in lab, red, red)) { + border: 1px solid color-mix(in oklab, currentColor 13%, transparent); + } + border-radius: 50%; + background: var(--paper); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--paper) 65%, transparent); + } + font-family: "Gabarito", sans-serif; + font-weight: 700; +} +.spotify-mark { + color: oklch(45% 0.17 150); +} +.apple-mark { + color: oklch(52% 0.21 20); + font-size: 1.2rem; +} +.lastfm-mark { + color: oklch(49% 0.2 25); + font-size: 0.76rem; + letter-spacing: -0.05em; +} +.status-label { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + color: var(--connected); + font-size: 0.72rem; + font-weight: 500; +} +.is-not-connected .status-label { + color: var(--muted); +} +.is-unavailable .status-label { + color: var(--muted); +} +.status-dot { + display: inline-block; + width: 0.42rem; + height: 0.42rem; + flex: 0 0 auto; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 3px currentColor; + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 12%, transparent); } - .border-b { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 1px; +} +.service-body { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-xl); + padding: var(--space-xl); +} +.account-line { + margin: 0; + color: var(--muted); + font-size: 0.78rem; +} +.account-line strong { + color: var(--ink); + font-weight: 500; +} +.service-note { + max-width: 28ch; + margin: auto 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.65; +} +.latest-listen { + margin-top: auto; +} +.latest-listen .detail-label, .empty-listen .detail-label { + color: var(--muted); +} +.track-name { + margin-bottom: 0.05rem; + font-family: "Gabarito", sans-serif; + font-size: 1.4rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.15; +} +.track-artist { + margin-bottom: var(--space-md); + color: var(--muted); + font-size: 0.85rem; +} +.latest-listen time { + color: var(--faint); + font-size: 0.72rem; +} +.empty-listen { + margin-top: auto; + color: var(--muted); + font-size: 0.82rem; +} +.empty-listen p:last-child { + max-width: 27ch; + margin-bottom: 0; +} +.service-actions { + display: flex; + align-items: center; + min-height: 4.2rem; + gap: var(--space-md); + padding: var(--space-lg) var(--space-xl); + border-top: 1px solid currentColor; + @supports (color: color-mix(in lab, red, red)) { + border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent); } - .border-l-4 { - border-left-style: var(--tw-border-style); - border-left-width: 4px; +} +.service-actions button { + border-style: solid; + cursor: pointer; + font-family: inherit; +} +.service-actions button:disabled { + cursor: wait; + opacity: 0.62; +} +.service-inline-status { + min-height: 0; + margin: 0; + padding-inline: var(--space-xl); + color: var(--muted); + font-size: 0.7rem; +} +.service-inline-status:not(:empty) { + min-height: 2.25rem; + padding-bottom: var(--space-lg); +} +.primary-action, .quiet-action, .text-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.35rem; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 600; + transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1), background 160ms ease; +} +.primary-action { + padding-inline: 1rem; + color: var(--paper); + background: var(--ink); +} +.primary-action:hover { + transform: translateY(-1px); + background: var(--teal-dark); +} +.quiet-action { + padding-inline: 0.9rem; + border: 1px solid var(--ink); + @supports (color: color-mix(in lab, red, red)) { + border: 1px solid color-mix(in oklab, var(--ink) 18%, transparent); } - .border-\[\#1DB954\] { - border-color: #1DB954; +} +.quiet-action:hover { + background: var(--paper); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--paper) 45%, transparent); } - .border-gray-200 { - border-color: var(--color-gray-200); +} +.text-action { + margin-right: auto; + padding: 0; + border-radius: 0; +} +.text-action span { + margin-left: 0.35rem; + transition: transform 160ms ease; +} +.text-action:hover span { + transform: translate(2px, -2px); +} +.connection-lines { + position: relative; + display: grid; + grid-template-columns: repeat(3, 1fr); + height: 4.5rem; + margin-inline: 8%; +} +.connection-lines::after { + content: ""; + position: absolute; + left: 16.666%; + right: 16.666%; + bottom: 1.4rem; + border-top: 1px solid var(--line); +} +.connection-lines span { + position: relative; +} +.connection-lines span::before { + content: ""; + position: absolute; + top: 0; + bottom: 1.4rem; + left: 50%; + border-left: 1px solid var(--line); +} +.connection-lines span:nth-child(2)::after { + content: ""; + position: absolute; + top: calc(100% - 1.4rem); + left: 50%; + height: 1.4rem; + border-left: 1px solid var(--line); +} +.atmosphere-account { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + width: min(100%, 36rem); + gap: var(--space-lg); + margin-inline: auto; + padding: 1.2rem 1.35rem; + border: 1px solid var(--teal); + @supports (color: color-mix(in lab, red, red)) { + border: 1px solid color-mix(in oklab, var(--teal) 42%, var(--line)); + } + border-radius: 0.7rem; + background: var(--teal); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--teal) 10%, var(--paper)); + } + box-shadow: var(--shadow); +} +.atmosphere-symbol { + display: inline-flex; + align-items: flex-end; + justify-content: center; + width: 2.65rem; + height: 2.65rem; + gap: 0.2rem; + padding: 0.62rem; + border-radius: 50%; + color: var(--paper); + background: var(--teal-dark); +} +.atmosphere-avatar { + width: 2.65rem; + height: 2.65rem; + border: 1px solid var(--teal); + @supports (color: color-mix(in lab, red, red)) { + border: 1px solid color-mix(in oklab, var(--teal) 32%, var(--line)); + } + border-radius: 50%; + object-fit: cover; +} +.atmosphere-symbol span { + display: block; + width: 0.22rem; + border-radius: 99px; + background: currentColor; +} +.atmosphere-symbol span:nth-child(1) { + height: 55%; + transform: rotate(-10deg); +} +.atmosphere-symbol span:nth-child(2) { + height: 100%; +} +.atmosphere-symbol span:nth-child(3) { + height: 70%; + transform: rotate(10deg); +} +.atmosphere-account .detail-label { + margin-bottom: 0.18rem; +} +.atmosphere-account h2 { + margin-bottom: 0.08rem; + font-size: 1.18rem; + font-weight: 600; + letter-spacing: -0.025em; +} +.atmosphere-account h2 + p { + margin: 0; + color: var(--muted); + font-size: 0.76rem; +} +.atmosphere-logout { + display: inline-block; + margin-top: var(--space-md); + color: var(--teal-dark); + font-size: 0.74rem; + font-weight: 600; + text-decoration: underline; + text-decoration-color: var(--teal); + @supports (color: color-mix(in lab, red, red)) { + text-decoration-color: color-mix(in oklab, var(--teal) 45%, transparent); + } + text-underline-offset: 0.2em; +} +.atmosphere-logout:hover { + text-decoration-color: currentColor; +} +.remove-connection { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xl); +} +.remove-connection h2 { + margin-bottom: var(--space-sm); +} +.remove-connection p { + margin: 0; + color: var(--muted); + font-size: 0.82rem; +} +.danger-button { + min-height: 2.7rem; + padding-inline: 1rem; + border: 1px solid var(--warning); + @supports (color: color-mix(in lab, red, red)) { + border: 1px solid color-mix(in oklab, var(--warning) 35%, var(--line)); + } + border-radius: 0.4rem; + color: var(--warning); + background: transparent; + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; +} +.danger-button:hover { + background: var(--warning-bg); +} +.login-shell { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(22rem, 0.75fr); + align-items: center; + min-height: calc(100vh - 5.5rem); + gap: clamp(3rem, 9vw, 8rem); + padding-block: var(--space-4xl); +} +.login-copy h1 { + max-width: 13ch; + margin-bottom: var(--space-xl); + font-size: clamp(3rem, 6vw, 5.5rem); + font-weight: 600; + letter-spacing: -0.065em; + line-height: 0.94; +} +.login-copy > p:last-child { + max-width: 48ch; + margin: 0; + color: var(--muted); + font-size: 1rem; + line-height: 1.7; +} +.login-panel { + padding: clamp(1.5rem, 4vw, 2.5rem); + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--paper); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--paper) 80%, var(--paper-deep)); + } + box-shadow: var(--shadow); + transform: rotate(0.45deg); + transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1); +} +.login-panel:focus-within { + transform: rotate(0deg); +} +.login-panel-heading { + display: flex; + align-items: center; + gap: var(--space-lg); + margin-bottom: var(--space-2xl); +} +.login-panel-heading h2 { + margin: 0; + font-size: 1.45rem; + letter-spacing: -0.03em; +} +.stacked-form { + display: flex; + flex-direction: column; + gap: var(--space-md); +} +.stacked-form label { + font-size: 0.78rem; + font-weight: 600; +} +.typeahead-control { + position: relative; +} +.typeahead-control::after { + content: ""; + position: absolute; + z-index: 21; + top: calc(100% - 2px); + right: 0.55rem; + left: 0.55rem; + height: 2px; + border-radius: 99px; + background: var(--teal); + opacity: 0; + transform: scaleX(0); + transform-origin: left; +} +.typeahead-control.is-loading::after { + opacity: 1; + animation: typeahead-progress 1.1s cubic-bezier(0.22, 1, 0.36, 1) infinite; +} +.input-group { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-sm); + padding: 0.35rem; + border: 1px solid var(--line); + border-radius: 0.55rem; + background: var(--paper); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep)); + } + transition: border-color 160ms ease, box-shadow 160ms ease; +} +.input-group:focus-within { + border-color: var(--teal); + box-shadow: 0 0 0 3px var(--teal); + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent); } - .border-gray-300 { - border-color: var(--color-gray-300); +} +.input-group input { + min-width: 0; + padding: 0.7rem 0.65rem; + border: 0; + outline: 0; + background: transparent; +} +.input-group input::placeholder { + color: var(--faint); +} +.input-group button, .form-button { + min-height: 2.7rem; + padding-inline: 1rem; + border: 0; + border-radius: 0.4rem; + color: var(--paper); + background: var(--teal-dark); + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; +} +.field-help { + margin: 0; + color: var(--faint); + font-size: 0.7rem; + line-height: 1.55; +} +.typeahead-results { + position: absolute; + z-index: 20; + top: calc(100% + 0.45rem); + right: 0; + left: 0; + max-height: min(22rem, 55vh); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.32rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--paper); + box-shadow: 0 14px 30px var(--ink); + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 14px 30px color-mix(in oklab, var(--ink) 10%, transparent); } - .bg-\[\#1DB954\] { - background-color: #1DB954; +} +.typeahead-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + width: 100%; + gap: var(--space-md); + padding: 0.62rem 0.7rem; + border: 0; + border-radius: 0.4rem; + color: var(--ink); + background: transparent; + cursor: pointer; + text-align: left; +} +.typeahead-option:hover, .typeahead-option[aria-selected="true"] { + background: var(--paper-deep); +} +.actor-avatar { + display: grid; + place-items: center; + width: 2.1rem; + height: 2.1rem; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 50%; + color: var(--teal-dark); + background: var(--teal); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--teal) 12%, var(--paper)); + } + font-family: "Gabarito", sans-serif; + font-size: 0.8rem; + font-weight: 600; +} +.actor-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} +.actor-identity { + display: flex; + min-width: 0; + flex-direction: column; +} +.actor-identity strong, .actor-identity span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.actor-identity strong { + font-size: 0.78rem; + font-weight: 600; +} +.actor-identity span { + color: var(--muted); + font-size: 0.68rem; +} +@keyframes typeahead-progress { + 0% { + transform: scaleX(0); + transform-origin: left; } - .bg-\[\#d51007\] { - background-color: #d51007; + 48% { + transform: scaleX(0.72); + transform-origin: left; } - .bg-\[\#dc3545\] { - background-color: #dc3545; + 52% { + transform: scaleX(0.72); + transform-origin: right; } - .bg-gray-100 { - background-color: var(--color-gray-100); + 100% { + transform: scaleX(0); + transform-origin: right; } - .p-2 { - padding: calc(var(--spacing) * 2); +} +.content-page { + width: min(100%, 48rem); + margin-inline: auto; + padding-block: var(--space-4xl); +} +.content-page-heading { + margin-bottom: var(--space-2xl); +} +.content-page-heading h1 { + margin-bottom: var(--space-md); + font-size: clamp(2.5rem, 5vw, 4rem); + font-weight: 600; + letter-spacing: -0.055em; + line-height: 1; +} +.content-page-heading p { + max-width: 58ch; + margin: 0; + color: var(--muted); +} +.settings-section { + padding-block: var(--space-2xl); + border-top: 1px solid var(--line); +} +.settings-section h2 { + margin-bottom: var(--space-sm); + font-size: 1.4rem; + letter-spacing: -0.03em; +} +.settings-section > p { + color: var(--muted); +} +.field { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-xl); +} +.field label { + font-size: 0.78rem; + font-weight: 600; +} +.text-input { + width: 100%; + min-height: 3rem; + padding-inline: var(--space-lg); + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--paper); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep)); + } + outline: 0; +} +.text-input:focus { + border-color: var(--teal); + box-shadow: 0 0 0 3px var(--teal); + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent); } - .p-4 { - padding: calc(var(--spacing) * 4); +} +.form-actions { + display: flex; + align-items: center; + gap: var(--space-md); + margin-top: var(--space-xl); +} +.danger-button { + color: oklch(49% 0.16 25); + background: transparent; + border: 1px solid oklch(76% 0.08 25); +} +.inline-status { + margin-top: var(--space-lg); + color: var(--muted); + font-size: 0.78rem; + white-space: pre-wrap; +} +.code-sample { + display: block; + overflow-x: auto; + margin-block: var(--space-md); + padding: var(--space-lg); + border: 1px solid var(--line); + border-radius: 0.7rem; + background: var(--paper-deep); + font-family: ui-monospace, monospace; + font-size: 0.78rem; +} +.key-table { + width: 100%; + margin-top: var(--space-lg); + border-collapse: collapse; + font-size: 0.82rem; +} +.key-table th, .key-table td { + padding: var(--space-md); + border-bottom: 1px solid var(--line); + text-align: left; +} +.key-table th { + color: var(--muted); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +@media (max-width: 56rem) { + .page-intro { + grid-template-columns: 1fr; + gap: var(--space-lg); } - .p-5 { - padding: calc(var(--spacing) * 5); + .page-intro h1 { + max-width: 12ch; } - .px-3 { - padding-inline: calc(var(--spacing) * 3); + .service-grid { + grid-template-columns: 1fr; } - .px-4 { - padding-inline: calc(var(--spacing) * 4); + .service-panel { + min-height: 21rem; } - .py-1\.5 { - padding-block: calc(var(--spacing) * 1.5); + .connection-lines { + display: block; + height: 3rem; + margin: 0; } - .py-2 { - padding-block: calc(var(--spacing) * 2); + .connection-lines::after { + left: 50%; + right: auto; + top: 0; + bottom: 0; + border-top: 0; + border-left: 1px solid var(--line); } - .py-2\.5 { - padding-block: calc(var(--spacing) * 2.5); + .connection-lines span { + display: none; } - .pl-5 { - padding-left: calc(var(--spacing) * 5); + .login-shell { + grid-template-columns: 1fr; + align-content: center; + min-height: auto; + gap: var(--space-3xl); } - .text-left { - text-align: left; + .login-copy h1 { + max-width: 15ch; } - .font-mono { - font-family: var(--font-mono); +} +@media (max-width: 38rem) { + .site-shell { + width: min(100% - 1.25rem, 77rem); } - .font-sans { - font-family: var(--font-sans); + .site-header { + min-height: 4.5rem; } - .text-lg { - font-size: var(--text-lg); - line-height: var(--tw-leading, var(--text-lg--line-height)); + .site-header nav { + gap: var(--space-lg); } - .text-xl { - font-size: var(--text-xl); - line-height: var(--tw-leading, var(--text-xl--line-height)); + .site-header nav a[aria-current="page"], .site-header nav .logout-link { + display: none; } - .leading-relaxed { - --tw-leading: var(--leading-relaxed); - line-height: var(--leading-relaxed); + .page-intro { + padding-block: var(--space-3xl) var(--space-xl); } - .font-bold { - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); + .page-intro h1 { + font-size: 2.7rem; } - .font-semibold { - --tw-font-weight: var(--font-weight-semibold); - font-weight: var(--font-weight-semibold); + .service-header, .service-body { + padding: var(--space-lg); } - .text-\[\#1DB954\] { - color: #1DB954; + .service-actions { + padding-inline: var(--space-lg); } - .text-gray-400 { - color: var(--color-gray-400); + .atmosphere-account { + grid-template-columns: auto 1fr; } - .text-gray-600 { - color: var(--color-gray-600); + .remove-connection { + align-items: flex-start; + flex-direction: column; } - .text-white { - color: var(--color-white); + .login-shell { + padding-block: var(--space-3xl); } - .italic { - font-style: italic; + .login-copy h1 { + font-size: 3rem; } - .no-underline { - text-decoration-line: none; + .input-group { + grid-template-columns: 1fr; } - .filter { - filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + .input-group button { + width: 100%; } - .transition { - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); + .key-table { + display: block; + overflow-x: auto; } - .hover\:opacity-90 { - &:hover { - @media (hover: hover) { - opacity: 90%; - } - } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; } } @property --tw-rotate-x { @@ -432,24 +1258,6 @@ syntax: "*"; inherits: false; } -@property --tw-space-y-reverse { - syntax: "*"; - inherits: false; - initial-value: 0; -} -@property --tw-border-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} -@property --tw-leading { - syntax: "*"; - inherits: false; -} -@property --tw-font-weight { - syntax: "*"; - inherits: false; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -511,10 +1319,6 @@ --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; - --tw-space-y-reverse: 0; - --tw-border-style: solid; - --tw-leading: initial; - --tw-font-weight: initial; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/pages/templates/apiKeys.gohtml b/pages/templates/apiKeys.gohtml index c0d13bc..675f27e 100644 --- a/pages/templates/apiKeys.gohtml +++ b/pages/templates/apiKeys.gohtml @@ -1,90 +1,73 @@ - {{ define "content" }} - {{ template "components/navBar" .NavBar }} +
+
+

Developer access

+

API keys

+

Create a key when another app needs to read or submit listening data through your Piper account.

+
+ {{ if .NewKeyID }} +
+

Key created

+

{{ .NewKeyID }}

+

Your new key is ready. Store the secret shown in the API response now because Piper will not show it again.

+
+ {{ end }} -

API Key Management

- -
-

Create New API Key

-

API keys allow programmatic access to your Piper account data.

-
-
- - -
- +
+

Create a key

+

Use a name that tells you which app or script owns the key.

+ +
+ + +
+
-
+ -{{if .NewKeyID}} -
-

Your new API key (ID: {{.NewKeyID}}) has been created

- -

Important: If this is an ID, ensure you have copied the actual key if it was displayed previously. For keys generated via the API, the key is returned in the API response.

-
-{{end}} +
+

Your keys

+ {{ if .Keys }} + + + + {{ range .Keys }} + + + + + + + {{ end }} + +
NameCreatedExpiresActions
{{ .Name }}{{ formatTime .CreatedAt }}{{ formatTime .ExpiresAt }}
+ {{ else }} +

You have not created any API keys.

+ {{ end }} +
-
-

Your API Keys

- {{if .Keys}} - - - - - - - - - - - {{range .Keys}} - - - - - - - {{end}} - -
NameCreatedExpiresActions
{{.Name}}{{formatTime .CreatedAt}}{{formatTime .ExpiresAt}} - -
- {{else}} -

You don't have any API keys yet.

- {{end}} -
- -
-

API Usage

-

To use your API key, include it in the Authorization header of your HTTP requests:

-
Authorization: Bearer YOUR_API_KEY
-

Or include it as a query parameter (less secure for the key itself):

-
https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY
-
+
+

Using a key

+

Send the key in the Authorization header of each request.

+ Authorization: Bearer YOUR_API_KEY +

Query parameters also work, but URLs often appear in logs. Prefer the header.

+ https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY +
+
- -{{ end }} \ No newline at end of file +{{ end }} diff --git a/pages/templates/applemusic_link.gohtml b/pages/templates/applemusic_link.gohtml deleted file mode 100644 index a5357da..0000000 --- a/pages/templates/applemusic_link.gohtml +++ /dev/null @@ -1,244 +0,0 @@ -{{ define "applemusic_link" }} -{{ template "layouts/base" . }} -{{ end }} - -{{ define "layouts/base" }} - - - - - Link Apple Music - - - - -
-

- Link Apple Music -

-

- Authorize with Apple Music to enable MusicKit features and sync your - library. -

-
- -
- -
-
- -

-    
- - - -{{ end }} diff --git a/pages/templates/components/navBar.gohtml b/pages/templates/components/navBar.gohtml index dcdb94c..e164587 100644 --- a/pages/templates/components/navBar.gohtml +++ b/pages/templates/components/navBar.gohtml @@ -1,50 +1,16 @@ {{ define "components/navBar" }} - - + {{ end }} diff --git a/pages/templates/home.gohtml b/pages/templates/home.gohtml index 513bbb4..ff1371f 100644 --- a/pages/templates/home.gohtml +++ b/pages/templates/home.gohtml @@ -1,112 +1,444 @@ {{ define "content" }} - -

- Piper - Multi-User Spotify & Last.fm Tracker via ATProto -

{{ template "components/navBar" .NavBar }} -
-

Welcome to Piper

-

- Piper is a multi-user application that records what you're listening to on - Spotify and Last.fm, saving your listening history. -

- - {{if .NavBar.IsLoggedIn}} -

You're logged in!

- -

Once connected, you can check out your:

- -

- You can also manage your - API keys for - programmatic access. -

- - {{ if .NavBar.LastFMUsername }} -

- Last.fm Username: {{ .NavBar.LastFMUsername }} -

- {{else }} -

Last.fm account not linked.

- {{ end }} - - {{ else }} - -

Login with ATProto to get started!

-
- - - -
- - {{ end }} -
- +
+

Atmosphere account

+

{{ if .Atmosphere.Handle }}@{{ .Atmosphere.Handle }}{{ else }}Handle unavailable{{ end }}

+

PDS: {{ if .Atmosphere.PDS }}{{ .Atmosphere.PDS }}{{ else }}Unavailable{{ end }}

+ Logout +
+ + + +{{ if .NavBar.AppleMusicEnabled }} + + +{{ end }} + +{{ else }} +
+ + +
+ + +{{ end }} {{ end }} diff --git a/pages/templates/lastFMForm.gohtml b/pages/templates/lastFMForm.gohtml index 923f56c..c61d0bb 100644 --- a/pages/templates/lastFMForm.gohtml +++ b/pages/templates/lastFMForm.gohtml @@ -1,14 +1,34 @@ {{ define "content" }} - {{ template "components/navBar" .NavBar }} - -
-

Link Your Last.fm Account

-

Enter your Last.fm username to start tracking your scrobbles.

-
- - - -
+{{ template "components/navBar" .NavBar }} +
+
+

Last.fm connection

+

{{ if .CurrentUsername }}Update your username.{{ else }}Connect Last.fm.{{ end }}

+

Piper reads your public scrobbles and adds them to the listening record tied to your Atmosphere account.

+
+
+

Account details

+
+
+ + +
+
+ + Cancel +
+
+
+ {{ if .CurrentUsername }} +
+
+

Remove Last.fm

+

Stop syncing this account and remove the username from Piper.

- -{{ end }} \ No newline at end of file +
+ +
+
+ {{ end }} +
+{{ end }} diff --git a/pages/templates/layouts/base.gohtml b/pages/templates/layouts/base.gohtml index 62900b8..223427d 100644 --- a/pages/templates/layouts/base.gohtml +++ b/pages/templates/layouts/base.gohtml @@ -1,13 +1,20 @@ {{ define "layouts/base" }} - + - Piper - Spotify & Last.fm Tracker - + + + + Piper · Listening connections + + + + - -{{ block "content" . }}{{ end }} - + +
+ {{ block "content" . }}{{ end }} +
{{ end }} diff --git a/service/apikey/apikey.go b/service/apikey/apikey.go index 7abcd7e..b7567ff 100644 --- a/service/apikey/apikey.go +++ b/service/apikey/apikey.go @@ -211,7 +211,8 @@ func (s *Service) HandleAPIKeyManagement(database *db.DB, pg *pages.Pages) http. Keys: keys, NewKeyID: newKeyValueToShow, NavBar: pages.NavBar{ - IsLoggedIn: ok, + IsLoggedIn: ok, + CurrentPage: pages.NavAPIAccess, //Just leaving empty so we don't have to pull in the db here, may change LastFMUsername: lastfmUsername, }, diff --git a/service/profile/profile.go b/service/profile/profile.go new file mode 100644 index 0000000..3a9f48d --- /dev/null +++ b/service/profile/profile.go @@ -0,0 +1,150 @@ +package profile + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const tealProfileCollection = "fm.teal.actor.profile" + +type Account struct { + Handle string + PDS string + AvatarURL string +} + +type cacheEntry struct { + account Account + expiresAt time.Time +} + +type Resolver struct { + directory identity.Directory + client *http.Client + + mu sync.RWMutex + cache map[string]cacheEntry +} + +func NewResolver() *Resolver { + return &Resolver{ + directory: identity.DefaultDirectory(), + client: &http.Client{Timeout: 4 * time.Second}, + cache: make(map[string]cacheEntry), + } +} + +func (r *Resolver) Resolve(ctx context.Context, rawDID string) (Account, error) { + r.mu.RLock() + cached, ok := r.cache[rawDID] + r.mu.RUnlock() + if ok && time.Now().Before(cached.expiresAt) { + return cached.account, nil + } + + did, err := syntax.ParseDID(rawDID) + if err != nil { + return Account{}, fmt.Errorf("parse DID: %w", err) + } + ident, err := r.directory.LookupDID(ctx, did) + if err != nil { + return Account{}, fmt.Errorf("resolve DID: %w", err) + } + + handle := ident.Handle.String() + if handle == "handle.invalid" { + handle = "" + } + pds := strings.TrimRight(ident.PDSEndpoint(), "/") + account := Account{ + Handle: handle, + PDS: strings.TrimPrefix(strings.TrimPrefix(pds, "https://"), "http://"), + } + account.AvatarURL = r.tealAvatar(ctx, pds, rawDID) + if account.AvatarURL == "" { + account.AvatarURL = r.blueskyAvatar(ctx, rawDID) + } + + r.mu.Lock() + r.cache[rawDID] = cacheEntry{account: account, expiresAt: time.Now().Add(10 * time.Minute)} + r.mu.Unlock() + return account, nil +} + +func (r *Resolver) tealAvatar(ctx context.Context, pds, did string) string { + if pds == "" { + return "" + } + endpoint, err := url.Parse(pds + "/xrpc/com.atproto.repo.getRecord") + if err != nil { + return "" + } + query := endpoint.Query() + query.Set("repo", did) + query.Set("collection", tealProfileCollection) + query.Set("rkey", "self") + endpoint.RawQuery = query.Encode() + + var record struct { + Value struct { + Avatar struct { + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + } `json:"avatar"` + } `json:"value"` + } + if err := r.getJSON(ctx, endpoint.String(), &record); err != nil || record.Value.Avatar.Ref.Link == "" { + return "" + } + + blobURL, err := url.Parse(pds + "/xrpc/com.atproto.sync.getBlob") + if err != nil { + return "" + } + query = blobURL.Query() + query.Set("did", did) + query.Set("cid", record.Value.Avatar.Ref.Link) + blobURL.RawQuery = query.Encode() + return blobURL.String() +} + +func (r *Resolver) blueskyAvatar(ctx context.Context, did string) string { + endpoint, _ := url.Parse("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile") + query := endpoint.Query() + query.Set("actor", did) + endpoint.RawQuery = query.Encode() + + var actor struct { + Avatar string `json:"avatar"` + } + if err := r.getJSON(ctx, endpoint.String(), &actor); err != nil { + return "" + } + return actor.Avatar +} + +func (r *Resolver) getJSON(ctx context.Context, endpoint string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + resp, err := r.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(target) +} From c5c156e88619f84c1dad5e03f16f7a9da577e352 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Thu, 27 Aug 2026 17:22:04 -0500 Subject: [PATCH 2/5] fix issues --- cmd/handlers.go | 69 ++++++++++++-- cmd/listenbrainz_test.go | 4 +- cmd/main.go | 1 + cmd/origin_test.go | 30 ++++++ cmd/routes.go | 5 +- db/apikey/apikey.go | 126 ++++++++++++++++++++----- db/apikey/apikey_test.go | 71 ++++++++++++++ db/db.go | 7 +- db/lfm.go | 65 ++++++++++++- db/lfm_test.go | 37 ++++++++ pages/static/base.css | 2 +- pages/static/main.css | 2 +- pages/templates/apiKeys.gohtml | 11 +-- pages/templates/home.gohtml | 109 ++++++++++++++++++--- pages/templates/lastFMForm.gohtml | 1 - service/apikey/apikey.go | 66 ++++++++----- service/apikey/apikey_test.go | 31 ++++++ service/profile/profile.go | 152 +++++++++++++++++++++++++++--- service/profile/profile_test.go | 61 ++++++++++++ session/session.go | 21 +++-- session/session_cookie_test.go | 22 +++++ 21 files changed, 787 insertions(+), 106 deletions(-) create mode 100644 cmd/origin_test.go create mode 100644 db/apikey/apikey_test.go create mode 100644 service/apikey/apikey_test.go create mode 100644 service/profile/profile_test.go create mode 100644 session/session_cookie_test.go diff --git a/cmd/handlers.go b/cmd/handlers.go index b8c103e..03d8bdb 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" + "net/url" "strconv" "strings" "time" @@ -35,7 +36,7 @@ type HomeParams struct { AppleMusicDevToken string } -func home(database *db.DB, pg *pages.Pages, profileResolver *profileservice.Resolver, appleMusicService *applemusic.Service) http.HandlerFunc { +func home(database *db.DB, pg *pages.Pages, profileResolver *profileservice.Resolver, spotifyService *spotify.Service, appleMusicService *applemusic.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") @@ -59,12 +60,7 @@ func home(database *db.DB, pg *pages.Pages, profileResolver *profileservice.Reso 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) - } + atmosphere, _ = profileResolver.Cached(*user.ATProtoDID) } if appleMusicEnabled { appleMusicDevToken, _, err = appleMusicService.GenerateDeveloperToken() @@ -98,7 +94,7 @@ func home(database *db.DB, pg *pages.Pages, profileResolver *profileservice.Reso IsLoggedIn: isLoggedIn, CurrentPage: pages.NavConnections, LastFMUsername: lastfmUsername, - SpotifyEnabled: viper.GetBool("enable_spotify"), + SpotifyEnabled: spotifyService != nil, LastFMEnabled: viper.GetBool("enable_lastfm"), AppleMusicEnabled: appleMusicEnabled, }, @@ -197,12 +193,16 @@ func handleLinkLastfmSubmit(database *db.DB) http.HandlerFunc { } } -func handleUnlinkLastfm(database *db.DB) http.HandlerFunc { +func handleUnlinkLastfm(database *db.DB, allowedOrigin string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } + if !requestHasAllowedOrigin(r, allowedOrigin) { + http.Error(w, "Invalid request origin", http.StatusForbidden) + return + } 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) @@ -213,6 +213,32 @@ func handleUnlinkLastfm(database *db.DB) http.HandlerFunc { } } +func requestHasAllowedOrigin(r *http.Request, allowedOrigin string) bool { + origin := r.Header.Get("Origin") + if origin == "" || origin == "null" { + return false + } + actual, err := url.Parse(origin) + if err != nil || actual.Scheme == "" || actual.Host == "" || actual.User != nil { + return false + } + + expected := &url.URL{} + if allowedOrigin != "" { + expected, err = url.Parse(allowedOrigin) + if err != nil || expected.Scheme == "" || expected.Host == "" { + return false + } + } else { + expected.Scheme = "http" + if r.TLS != nil { + expected.Scheme = "https" + } + expected.Host = r.Host + } + return strings.EqualFold(actual.Scheme, expected.Scheme) && strings.EqualFold(actual.Host, expected.Host) +} + func apiCurrentTrack(spotifyService *spotify.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := session.GetUserID(r.Context()) @@ -403,6 +429,31 @@ func apiUnlinkLastfmHandler(database *db.DB) http.HandlerFunc { } } +func apiAtmosphereProfile(database *db.DB, resolver *profileservice.Resolver) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) + return + } + userID, authenticated := session.GetUserID(r.Context()) + if !authenticated { + jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) + return + } + user, err := database.GetUserByID(userID) + if err != nil || user == nil || user.ATProtoDID == nil { + jsonResponse(w, http.StatusNotFound, map[string]string{"error": "Atmosphere account not found"}) + return + } + account, ready := resolver.Cached(*user.ATProtoDID) + if !ready { + jsonResponse(w, http.StatusAccepted, map[string]string{"status": "pending"}) + return + } + jsonResponse(w, http.StatusOK, account) + } +} + // apiAppleMusicAuthorize stores a MusicKit user token for the current user func apiAppleMusicAuthorize(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/listenbrainz_test.go b/cmd/listenbrainz_test.go index 3b17bb5..3c21811 100644 --- a/cmd/listenbrainz_test.go +++ b/cmd/listenbrainz_test.go @@ -43,12 +43,12 @@ func createTestUser(t *testing.T, database *db.DB) (int64, string) { // Create API key for the user sessionManager := session.NewSessionManager(database) - apiKeyObj, err := sessionManager.CreateAPIKey(userID, "test-key", 30) // 30 days validity + _, rawKey, err := sessionManager.CreateAPIKey(userID, "test-key", 30) // 30 days validity if err != nil { t.Fatalf("Failed to create API key: %v", err) } - return userID, apiKeyObj.ID + return userID, rawKey } // Helper to create context with user ID (simulating auth middleware) diff --git a/cmd/main.go b/cmd/main.go index 9fbfbd0..aac7398 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -69,6 +69,7 @@ func main() { } sessionManager := session.NewSessionManager(database) + sessionManager.SetSecureCookies(strings.HasPrefix(viper.GetString("server.root_url"), "https://")) // --- Service Initializations --- diff --git a/cmd/origin_test.go b/cmd/origin_test.go new file mode 100644 index 0000000..a1e79d8 --- /dev/null +++ b/cmd/origin_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "net/http/httptest" + "testing" +) + +func TestRequestHasAllowedOrigin(t *testing.T) { + tests := []struct { + name string + origin string + want bool + }{ + {name: "same origin", origin: "https://piper.example", want: true}, + {name: "cross origin", origin: "https://attacker.example", want: false}, + {name: "missing origin", want: false}, + {name: "null origin", origin: "null", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest("POST", "https://piper.example/unlink-lastfm", nil) + if test.origin != "" { + request.Header.Set("Origin", test.origin) + } + if got := requestHasAllowedOrigin(request, "https://piper.example"); got != test.want { + t.Fatalf("got %v, want %v", got, test.want) + } + }) + } +} diff --git a/cmd/routes.go b/cmd/routes.go index 0337848..d5ca3d2 100644 --- a/cmd/routes.go +++ b/cmd/routes.go @@ -14,7 +14,7 @@ func (app *application) routes() http.Handler { //Handles static file routes mux.Handle("/static/{file_name}", app.pages.Static()) - mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages, app.profileResolver, app.appleMusicService), app.sessionManager)) + mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages, app.profileResolver, app.spotifyService, app.appleMusicService), app.sessionManager)) // OAuth Routes mux.HandleFunc("/login/atproto", app.oauthManager.HandleLogin("atproto")) @@ -30,7 +30,7 @@ func (app *application) routes() http.Handler { mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly - mux.HandleFunc("/unlink-lastfm", session.WithAuth(handleUnlinkLastfm(app.database), app.sessionManager)) + mux.HandleFunc("/unlink-lastfm", session.WithAuth(handleUnlinkLastfm(app.database, viper.GetString("server.root_url")), app.sessionManager)) mux.HandleFunc("/link-applemusic", session.WithAuth(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) }, app.sessionManager)) @@ -48,6 +48,7 @@ func (app *application) routes() http.Handler { // Apple Music user authorization (protected with session auth) mux.HandleFunc("/api/v1/applemusic/authorize", session.WithAuth(apiAppleMusicAuthorize(app.database), app.sessionManager)) mux.HandleFunc("/api/v1/applemusic/unlink", session.WithAuth(apiAppleMusicUnlink(app.database), app.sessionManager)) + mux.HandleFunc("/api/v1/atmosphere-profile", session.WithAuth(apiAtmosphereProfile(app.database, app.profileResolver), app.sessionManager)) // ListenBrainz-compatible endpoint mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager)) diff --git a/db/apikey/apikey.go b/db/apikey/apikey.go index 7d0952e..f53be22 100644 --- a/db/apikey/apikey.go +++ b/db/apikey/apikey.go @@ -2,8 +2,10 @@ package apikey import ( "crypto/rand" + "crypto/sha256" "database/sql" "encoding/base64" + "encoding/hex" "errors" "fmt" "log" @@ -18,6 +20,7 @@ import ( // ApiKey represents an API key for authenticating requests type ApiKey struct { ID string + KeyPrefix string UserID int64 Name string CreatedAt time.Time @@ -37,6 +40,8 @@ func NewApiKeyManager(database *db.DB) *Manager { _, err := database.Exec(` CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, + key_hash TEXT UNIQUE, + key_prefix TEXT, user_id INTEGER NOT NULL, name TEXT NOT NULL, created_at TIMESTAMP, @@ -47,6 +52,20 @@ func NewApiKeyManager(database *db.DB) *Manager { if err != nil { log.Printf("Error creating api_keys table: %v", err) } + for _, statement := range []string{ + `ALTER TABLE api_keys ADD COLUMN key_hash TEXT`, + `ALTER TABLE api_keys ADD COLUMN key_prefix TEXT`, + } { + if _, alterErr := database.Exec(statement); alterErr != nil && !strings.Contains(alterErr.Error(), "duplicate column name") { + log.Printf("Error updating api_keys table: %v", alterErr) + } + } + if err := migrateLegacyAPIKeys(database); err != nil { + log.Printf("Error migrating legacy API keys: %v", err) + } + if _, err := database.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)`); err != nil { + log.Printf("Error indexing API key hashes: %v", err) + } am := &Manager{ db: database, @@ -83,22 +102,30 @@ func (am *Manager) cleanupExpiredApiKeys() { } // CreateApiKey creates a new API key for a user -func (am *Manager) CreateApiKey(userID int64, name string, validityDays int) (*ApiKey, error) { +func (am *Manager) CreateApiKey(userID int64, name string, validityDays int) (*ApiKey, string, error) { am.mu.Lock() defer am.mu.Unlock() - // Generate random API key - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return nil, err + rawKey, err := randomToken(32) + if err != nil { + return nil, "", err + } + apiKeyID, err := randomToken(16) + if err != nil { + return nil, "", err + } + keyHash := hashAPIKey(rawKey) + keyPrefix := rawKey + if len(keyPrefix) > 8 { + keyPrefix = keyPrefix[:8] } - apiKeyID := base64.URLEncoding.EncodeToString(b) now := time.Now().UTC() expiresAt := now.AddDate(0, 0, validityDays) // Default to validityDays days validity apiKey := &ApiKey{ ID: apiKeyID, + KeyPrefix: keyPrefix, UserID: userID, Name: name, CreatedAt: now, @@ -106,26 +133,28 @@ func (am *Manager) CreateApiKey(userID int64, name string, validityDays int) (*A } // Store API key in memory - am.apiKeys[apiKeyID] = apiKey + am.apiKeys[keyHash] = apiKey // Store API key in database - _, err := am.db.Exec(` - INSERT INTO api_keys (id, user_id, name, created_at, expires_at) - VALUES (?, ?, ?, ?, ?)`, - apiKeyID, userID, name, now, expiresAt) + _, err = am.db.Exec(` + INSERT INTO api_keys (id, key_hash, key_prefix, user_id, name, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + apiKeyID, keyHash, keyPrefix, userID, name, now, expiresAt) if err != nil { - return nil, err + delete(am.apiKeys, keyHash) + return nil, "", err } - return apiKey, nil + return apiKey, rawKey, nil } // GetApiKey retrieves an API key by ID func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) { + keyHash := hashAPIKey(apiKeyID) // First check in-memory cache am.mu.RLock() - apiKey, exists := am.apiKeys[apiKeyID] + apiKey, exists := am.apiKeys[keyHash] am.mu.RUnlock() if exists { @@ -140,11 +169,11 @@ func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) { } // If not in memory, check database - apiKey = &ApiKey{ID: apiKeyID} + apiKey = &ApiKey{} err := am.db.QueryRow(` - SELECT user_id, name, created_at, expires_at - FROM api_keys WHERE id = ?`, apiKeyID).Scan( - &apiKey.UserID, &apiKey.Name, &apiKey.CreatedAt, &apiKey.ExpiresAt) + SELECT id, COALESCE(key_prefix, ''), user_id, name, created_at, expires_at + FROM api_keys WHERE key_hash = ?`, keyHash).Scan( + &apiKey.ID, &apiKey.KeyPrefix, &apiKey.UserID, &apiKey.Name, &apiKey.CreatedAt, &apiKey.ExpiresAt) if err != nil { return nil, false @@ -159,7 +188,7 @@ func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) { // Add to in-memory cache am.mu.Lock() - am.apiKeys[apiKeyID] = apiKey + am.apiKeys[keyHash] = apiKey am.mu.Unlock() return apiKey, true @@ -168,7 +197,11 @@ func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) { // DeleteApiKey removes an API key func (am *Manager) DeleteApiKey(apiKeyID string) error { am.mu.Lock() - delete(am.apiKeys, apiKeyID) + for keyHash, apiKey := range am.apiKeys { + if apiKey.ID == apiKeyID { + delete(am.apiKeys, keyHash) + } + } am.mu.Unlock() _, err := am.db.Exec("DELETE FROM api_keys WHERE id = ?", apiKeyID) @@ -178,7 +211,7 @@ func (am *Manager) DeleteApiKey(apiKeyID string) error { // GetUserApiKeys retrieves all API keys for a user func (am *Manager) GetUserApiKeys(userID int64) ([]*ApiKey, error) { rows, err := am.db.Query(` - SELECT id, user_id, name, created_at, expires_at + SELECT id, COALESCE(key_prefix, ''), user_id, name, created_at, expires_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, userID) @@ -198,6 +231,7 @@ func (am *Manager) GetUserApiKeys(userID int64) ([]*ApiKey, error) { apiKey := &ApiKey{} err := rows.Scan( &apiKey.ID, + &apiKey.KeyPrefix, &apiKey.UserID, &apiKey.Name, &apiKey.CreatedAt, @@ -212,6 +246,56 @@ func (am *Manager) GetUserApiKeys(userID int64) ([]*ApiKey, error) { return apiKeys, nil } +func randomToken(byteLength int) (string, error) { + bytes := make([]byte, byteLength) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func hashAPIKey(rawKey string) string { + hash := sha256.Sum256([]byte(rawKey)) + return hex.EncodeToString(hash[:]) +} + +func migrateLegacyAPIKeys(database *db.DB) error { + rows, err := database.Query(`SELECT id FROM api_keys WHERE key_hash IS NULL OR key_hash = ''`) + if err != nil { + return err + } + var legacyKeys []string + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + _ = rows.Close() + return err + } + legacyKeys = append(legacyKeys, key) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, rawKey := range legacyKeys { + newID, err := randomToken(16) + if err != nil { + return err + } + prefix := rawKey + if len(prefix) > 8 { + prefix = prefix[:8] + } + if _, err := database.Exec(`UPDATE api_keys SET id = ?, key_hash = ?, key_prefix = ? WHERE id = ?`, newID, hashAPIKey(rawKey), prefix, rawKey); err != nil { + return err + } + } + return nil +} + // ExtractApiKey extracts the API key from the request func ExtractApiKey(r *http.Request) (string, error) { // Try to get from Authorization header first diff --git a/db/apikey/apikey_test.go b/db/apikey/apikey_test.go new file mode 100644 index 0000000..edf1f9d --- /dev/null +++ b/db/apikey/apikey_test.go @@ -0,0 +1,71 @@ +package apikey + +import ( + "testing" + + "github.com/teal-fm/piper/db" +) + +func testDatabase(t *testing.T) *db.DB { + t.Helper() + database, err := db.New(":memory:") + if err != nil { + t.Fatalf("new database: %v", err) + } + if err := database.Initialize(); err != nil { + t.Fatalf("initialize database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func TestCreateAPIKeySeparatesSecretFromID(t *testing.T) { + database := testDatabase(t) + manager := NewApiKeyManager(database) + apiKey, rawKey, err := manager.CreateApiKey(1, "test", 30) + if err != nil { + t.Fatalf("create API key: %v", err) + } + if rawKey == "" || rawKey == apiKey.ID { + t.Fatalf("raw key %q must differ from public ID %q", rawKey, apiKey.ID) + } + if _, ok := manager.GetApiKey(rawKey); !ok { + t.Fatal("raw key did not authenticate") + } + if _, ok := manager.GetApiKey(apiKey.ID); ok { + t.Fatal("public ID authenticated as a secret") + } + var storedHash string + if err := database.QueryRow(`SELECT key_hash FROM api_keys WHERE id = ?`, apiKey.ID).Scan(&storedHash); err != nil { + t.Fatalf("load stored hash: %v", err) + } + if storedHash == rawKey || storedHash == "" { + t.Fatalf("database stored unsafe key value %q", storedHash) + } +} + +func TestLegacyAPIKeyMigrationPreservesAuthentication(t *testing.T) { + database := testDatabase(t) + if _, err := database.Exec(` + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP, + expires_at TIMESTAMP + )`); err != nil { + t.Fatalf("create legacy table: %v", err) + } + const legacyKey = "legacy-secret" + if _, err := database.Exec(`INSERT INTO api_keys (id, user_id, name, created_at, expires_at) VALUES (?, 1, 'legacy', CURRENT_TIMESTAMP, datetime('now', '+1 day'))`, legacyKey); err != nil { + t.Fatalf("insert legacy key: %v", err) + } + manager := NewApiKeyManager(database) + apiKey, ok := manager.GetApiKey(legacyKey) + if !ok { + t.Fatal("legacy key stopped authenticating after migration") + } + if apiKey.ID == legacyKey { + t.Fatal("legacy secret remained the public row ID") + } +} diff --git a/db/db.go b/db/db.go index 29921f7..f5489cf 100644 --- a/db/db.go +++ b/db/db.go @@ -71,9 +71,10 @@ func (db *DB) Initialize() error { return err } - // Older versions could store whitespace as a linked Last.fm account. - // Keep the persisted state aligned with what the UI considers connected. - if _, err = db.Exec(`UPDATE users SET lastfm_username = NULL WHERE TRIM(COALESCE(lastfm_username, '')) = ''`); err != nil { + // Older versions could store surrounding or whitespace-only values as a + // linked Last.fm account. Normalize them with the same Unicode-aware rule + // used when accepting new usernames. + if err = db.normalizeLastFMUsernames(); err != nil { return err } diff --git a/db/lfm.go b/db/lfm.go index 498ade7..436d7d1 100644 --- a/db/lfm.go +++ b/db/lfm.go @@ -33,7 +33,7 @@ func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) { rows, err := db.Query(` SELECT id, username, email, lastfm_username FROM users - WHERE lastfm_username IS NOT NULL AND TRIM(lastfm_username) != '' + WHERE lastfm_username IS NOT NULL ORDER BY id`) if err != nil { @@ -55,12 +55,75 @@ func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) { if err != nil { return nil, err } + lastfmUsername := strings.TrimSpace(*user.LastFMUsername) + if lastfmUsername == "" { + continue + } + user.LastFMUsername = &lastfmUsername users = append(users, user) } + if err := rows.Err(); err != nil { + return nil, err + } return users, nil } +func (db *DB) normalizeLastFMUsernames() error { + rows, err := db.Query(` + SELECT id, lastfm_username + FROM users + WHERE lastfm_username IS NOT NULL`) + if err != nil { + return err + } + + type usernameUpdate struct { + userID int64 + username string + } + var updates []usernameUpdate + for rows.Next() { + var update usernameUpdate + if err := rows.Scan(&update.userID, &update.username); err != nil { + _ = rows.Close() + return err + } + trimmed := strings.TrimSpace(update.username) + if trimmed != update.username { + update.username = trimmed + updates = append(updates, update) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + transaction, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = transaction.Rollback() }() + + for _, update := range updates { + if update.username == "" { + if _, err := transaction.Exec(`UPDATE users SET lastfm_username = NULL WHERE id = ?`, update.userID); err != nil { + return err + } + continue + } + if _, err := transaction.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, update.username, update.userID); err != nil { + return err + } + } + + return transaction.Commit() +} + func (db *DB) GetUserByLastFM(lastfmUsername string) (*models.User, error) { row := db.QueryRow(` SELECT id, username, email, atproto_did, most_recent_at_session_id, created_at, updated_at, lastfm_username diff --git a/db/lfm_test.go b/db/lfm_test.go index 8e361ff..f21ce0f 100644 --- a/db/lfm_test.go +++ b/db/lfm_test.go @@ -44,3 +44,40 @@ func TestBlankLastFMUsernameIsDisconnected(t *testing.T) { t.Fatalf("blank username persisted as %q", *user.LastFMUsername) } } + +func TestLegacyUnicodeWhitespaceLastFMUsernameIsNormalized(t *testing.T) { + database := newTestDB(t) + userID := createTestUser(t, database) + if _, err := database.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, "\t\n\u2003", userID); err != nil { + t.Fatalf("seed legacy username: %v", err) + } + if err := database.normalizeLastFMUsernames(); err != nil { + t.Fatalf("normalize usernames: %v", err) + } + user, err := database.GetUserByID(userID) + if err != nil { + t.Fatalf("load user: %v", err) + } + if user.LastFMUsername != nil { + t.Fatalf("legacy whitespace username remained %q", *user.LastFMUsername) + } +} + +func TestGetAllUsersWithLastFMUsesUnicodeWhitespaceRules(t *testing.T) { + database := newTestDB(t) + blankUserID := createTestUser(t, database) + connectedUserID := createTestUser(t, database) + if _, err := database.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, "\t\u2003", blankUserID); err != nil { + t.Fatalf("seed blank username: %v", err) + } + if _, err := database.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, "\tlistener\u2003", connectedUserID); err != nil { + t.Fatalf("seed connected username: %v", err) + } + users, err := database.GetAllUsersWithLastFM() + if err != nil { + t.Fatalf("get connected users: %v", err) + } + if len(users) != 1 || users[0].LastFMUsername == nil || *users[0].LastFMUsername != "listener" { + t.Fatalf("got %#v, want one normalized listener", users) + } +} diff --git a/pages/static/base.css b/pages/static/base.css index 2c5374a..d268650 100644 --- a/pages/static/base.css +++ b/pages/static/base.css @@ -117,7 +117,7 @@ button, a { -webkit-tap-highlight-color: transparent; } padding-block: clamp(3rem, 7vw, 5.5rem) var(--space-3xl); } -.eyebrow, .detail-label { +.detail-label { margin: 0 0 var(--space-sm); color: var(--teal-dark); font-size: 0.68rem; diff --git a/pages/static/main.css b/pages/static/main.css index c824273..b115fed 100644 --- a/pages/static/main.css +++ b/pages/static/main.css @@ -393,7 +393,7 @@ button, a { gap: var(--space-3xl); padding-block: clamp(3rem, 7vw, 5.5rem) var(--space-3xl); } -.eyebrow, .detail-label { +.detail-label { margin: 0 0 var(--space-sm); color: var(--teal-dark); font-size: 0.68rem; diff --git a/pages/templates/apiKeys.gohtml b/pages/templates/apiKeys.gohtml index 675f27e..e558056 100644 --- a/pages/templates/apiKeys.gohtml +++ b/pages/templates/apiKeys.gohtml @@ -2,16 +2,14 @@ {{ template "components/navBar" .NavBar }}
-

Developer access

API keys

Create a key when another app needs to read or submit listening data through your Piper account.

- {{ if .NewKeyID }} + {{ if .NewKey }}
-

Key created

-

{{ .NewKeyID }}

-

Your new key is ready. Store the secret shown in the API response now because Piper will not show it again.

+

{{ .NewKey }}

+

Store this API key now. Piper will not show it again.

{{ end }} @@ -31,11 +29,12 @@

Your keys

{{ if .Keys }} - + {{ range .Keys }} + diff --git a/pages/templates/home.gohtml b/pages/templates/home.gohtml index ff1371f..0a95252 100644 --- a/pages/templates/home.gohtml +++ b/pages/templates/home.gohtml @@ -4,7 +4,6 @@ {{ if .NavBar.IsLoggedIn }}
-

Listening connections

your music, piped into one place.

See which sources you use and the latest listen Piper received from each one.

@@ -137,38 +136,125 @@ - @@ -157,7 +157,7 @@ diff --git a/pages/templates/layouts/base.gohtml b/pages/templates/layouts/base.gohtml index 83786c7..b885014 100644 --- a/pages/templates/layouts/base.gohtml +++ b/pages/templates/layouts/base.gohtml @@ -9,7 +9,7 @@ - +
diff --git a/service/profile/profile.go b/service/profile/profile.go index a92c42c..5d23191 100644 --- a/service/profile/profile.go +++ b/service/profile/profile.go @@ -28,6 +28,11 @@ type Account struct { AvatarURL string `json:"avatar_url"` } +type RecordTarget struct { + TrackName string + PlayedAt time.Time +} + type cacheEntry struct { account Account expiresAt time.Time @@ -190,37 +195,86 @@ func (r *Resolver) blueskyAvatar(ctx context.Context, did string) string { return actor.Avatar } -func (r *Resolver) LatestRecord(ctx context.Context, rawDID string) (string, error) { +func (r *Resolver) LatestRecords(ctx context.Context, rawDID string, targets map[string]RecordTarget) (map[string]string, error) { account, ready := r.Cached(rawDID) if !ready || account.PDS == "" { - return "", ErrProfilePending + return nil, ErrProfilePending } endpoint, err := url.Parse("https://" + account.PDS + "/xrpc/com.atproto.repo.listRecords") if err != nil { - return "", err + return nil, err } - query := endpoint.Query() - query.Set("repo", rawDID) - query.Set("collection", playCollection) - query.Set("limit", "1") - query.Set("reverse", "true") - endpoint.RawQuery = query.Encode() - var result struct { - Records []struct { - URI string `json:"uri"` - } `json:"records"` + records := make(map[string]string) + if len(targets) == 0 { + return records, nil + } + cursor := "" + for page := 0; page < 10 && len(records) < len(targets); page++ { + query := endpoint.Query() + query.Set("repo", rawDID) + query.Set("collection", playCollection) + query.Set("limit", "100") + if cursor != "" { + query.Set("cursor", cursor) + } + endpoint.RawQuery = query.Encode() + var result struct { + Cursor string `json:"cursor"` + Records []struct { + URI string `json:"uri"` + Value struct { + MusicServiceURI string `json:"musicServiceUri"` + TrackName string `json:"trackName"` + PlayedTime string `json:"playedTime"` + } `json:"value"` + } `json:"records"` + } + if err := r.getJSON(ctx, endpoint.String(), &result); err != nil { + return nil, err + } + for _, record := range result.Records { + service := musicServiceName(record.Value.MusicServiceURI) + target, wanted := targets[service] + if !wanted || records[service] != "" || !recordMatchesTarget(record.Value.TrackName, record.Value.PlayedTime, target) { + continue + } + atURI, err := syntax.ParseATURI(record.URI) + if err != nil { + continue + } + records[service] = atURI.String() + } + cursor = result.Cursor + if cursor == "" { + break + } } - if err := r.getJSON(ctx, endpoint.String(), &result); err != nil { - return "", err + return records, nil +} + +func recordMatchesTarget(trackName, playedTime string, target RecordTarget) bool { + if target.TrackName != "" && !strings.EqualFold(strings.TrimSpace(trackName), strings.TrimSpace(target.TrackName)) { + return false } - if len(result.Records) == 0 { - return "", nil + if target.PlayedAt.IsZero() { + return true } - atURI, err := syntax.ParseATURI(result.Records[0].URI) - if err != nil { - return "", fmt.Errorf("invalid latest record URI: %w", err) + recordedAt, err := time.Parse(time.RFC3339Nano, playedTime) + return err == nil && recordedAt.Equal(target.PlayedAt) +} + +func musicServiceName(rawURI string) string { + normalized := strings.ToLower(rawURI) + switch { + case strings.Contains(normalized, "spotify"): + return "spotify" + case strings.Contains(normalized, "music.apple"), strings.Contains(normalized, "applemusic"): + return "applemusic" + case strings.Contains(normalized, "last.fm"), strings.Contains(normalized, "lastfm"): + return "lastfm" + default: + return "" } - return atURI.String(), nil } func (r *Resolver) getJSON(ctx context.Context, endpoint string, target any) error { diff --git a/service/profile/profile_test.go b/service/profile/profile_test.go index 832504b..deafeca 100644 --- a/service/profile/profile_test.go +++ b/service/profile/profile_test.go @@ -32,13 +32,18 @@ func TestCachedReturnsExpiredAccountWithoutWaiting(t *testing.T) { } } -func TestLatestRecordReturnsNewestATURI(t *testing.T) { +func TestLatestRecordsReturnsNewestRecordPerService(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/xrpc/com.atproto.repo.listRecords" || r.URL.Query().Get("reverse") != "true" || r.URL.Query().Get("limit") != "1" { + if r.URL.Path != "/xrpc/com.atproto.repo.listRecords" || r.URL.Query().Has("reverse") || r.URL.Query().Get("limit") != "100" { t.Errorf("unexpected request: %s", r.URL.String()) } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"records":[{"uri":"at://did:plc:listener/fm.teal.feed.play/3latest"}]}`)) + _, _ = w.Write([]byte(`{"records":[ + {"uri":"at://did:plc:listener/fm.teal.feed.play/3wrong","value":{"musicServiceUri":"https://last.fm","trackName":"Older","playedTime":"2026-08-26T22:36:19Z"}}, + {"uri":"at://did:plc:listener/fm.teal.feed.play/3lastfm","value":{"musicServiceUri":"https://last.fm","trackName":"Latest Last.fm","playedTime":"2026-08-27T22:36:19Z"}}, + {"uri":"at://did:plc:listener/fm.teal.feed.play/3spotify","value":{"musicServiceUri":"https://open.spotify.com","trackName":"Latest Spotify","playedTime":"2026-08-27T21:00:00Z"}}, + {"uri":"at://did:plc:listener/fm.teal.feed.play/3apple","value":{"musicServiceUri":"https://music.apple.com","trackName":"Latest Apple","playedTime":"2026-08-27T20:00:00Z"}} + ]}`)) })) defer server.Close() resolver := &Resolver{ @@ -51,12 +56,18 @@ func TestLatestRecordReturnsNewestATURI(t *testing.T) { }, refreshing: make(map[string]bool), } - atURI, err := resolver.LatestRecord(context.Background(), "did:plc:listener") + records, err := resolver.LatestRecords(context.Background(), "did:plc:listener", map[string]RecordTarget{ + "lastfm": {TrackName: "Latest Last.fm", PlayedAt: time.Date(2026, 8, 27, 22, 36, 19, 0, time.UTC)}, + "spotify": {TrackName: "Latest Spotify", PlayedAt: time.Date(2026, 8, 27, 21, 0, 0, 0, time.UTC)}, + "applemusic": {TrackName: "Latest Apple", PlayedAt: time.Date(2026, 8, 27, 20, 0, 0, 0, time.UTC)}, + }) if err != nil { - t.Fatalf("LatestRecord: %v", err) + t.Fatalf("LatestRecords: %v", err) } - if atURI != "at://did:plc:listener/fm.teal.feed.play/3latest" { - t.Fatalf("got %q", atURI) + if records["lastfm"] != "at://did:plc:listener/fm.teal.feed.play/3lastfm" || + records["spotify"] != "at://did:plc:listener/fm.teal.feed.play/3spotify" || + records["applemusic"] != "at://did:plc:listener/fm.teal.feed.play/3apple" { + t.Fatalf("got %#v", records) } }
NameCreatedExpiresActions
NameKeyCreatedExpiresActions
{{ .Name }}{{ .KeyPrefix }}… {{ formatTime .CreatedAt }} {{ formatTime .ExpiresAt }}