A Chrome extension that scrobbles NTS Radio tracks to teal.fm

nts teal pipin

+3
.gitignore
··· 1 + node_modules/ 2 + *.log 3 + .DS_Store
+52
README.md
··· 1 + # NTS Radio Scrobbler for Teal.fm 2 + 3 + A Chrome extension that scrobbles NTS Radio tracks to Teal.fm on the AT 4 + Protocol. 5 + 6 + ## Features 7 + 8 + - Automatically detects currently playing tracks on NTS Radio 9 + - Posts track info to your Bluesky feed 10 + - Auto-scrobble toggle 11 + - Secure authentication with Bluesky 12 + 13 + ## Installation 14 + 15 + 1. Clone this repository 16 + 2. Open Chrome and navigate to `chrome://extensions/` 17 + 3. Enable "Developer mode" in the top right 18 + 4. Click "Load unpacked" and select this directory 19 + 5. Create placeholder icons in an `icons/` directory (16x16, 48x48, 128x128 PNG 20 + files) 21 + 22 + ## Usage 23 + 24 + 1. Click the extension icon to open the popup 25 + 2. Log in with your Bluesky handle/email and password 26 + 3. Visit [nts.live](https://www.nts.live) and start listening 27 + 4. Tracks will automatically be scrobbled to your Bluesky feed 28 + 29 + ## Setup Icons 30 + 31 + Before loading the extension, create an `icons/` directory with three PNG files: 32 + 33 + - `icon16.png` (16x16) 34 + - `icon48.png` (48x48) 35 + - `icon128.png` (128x128) 36 + 37 + ## Development 38 + 39 + The extension consists of: 40 + 41 + - `manifest.json` - Extension configuration 42 + - `content.js` - Scrapes track info from NTS Radio 43 + - `background.js` - Service worker handling scrobbling logic 44 + - `atproto.js` - AT Protocol client 45 + - `popup.html/js` - Authentication and settings UI 46 + 47 + ## Notes 48 + 49 + - The content script may need adjustments if NTS Radio changes their website 50 + structure 51 + - Session tokens are stored locally and refreshed automatically 52 + - Only works on nts.live domains
+162
atproto.js
··· 1 + // AT Protocol integration module 2 + 3 + class ATProtoClient { 4 + constructor() { 5 + this.session = null; 6 + this.pds = 'https://bsky.social'; 7 + } 8 + 9 + async login(identifier, password, pdsUrl) { 10 + try { 11 + // Update PDS URL if provided 12 + if (pdsUrl) { 13 + this.pds = pdsUrl; 14 + await chrome.storage.local.set({ pdsUrl }); 15 + } 16 + 17 + const response = await fetch(`${this.pds}/xrpc/com.atproto.server.createSession`, { 18 + method: 'POST', 19 + headers: { 20 + 'Content-Type': 'application/json', 21 + }, 22 + body: JSON.stringify({ 23 + identifier, 24 + password, 25 + }), 26 + }); 27 + 28 + if (!response.ok) { 29 + throw new Error(`Login failed: ${response.statusText}`); 30 + } 31 + 32 + this.session = await response.json(); 33 + 34 + // Store session 35 + await chrome.storage.local.set({ atprotoSession: this.session }); 36 + 37 + return this.session; 38 + } catch (error) { 39 + console.error('AT Proto login error:', error); 40 + throw error; 41 + } 42 + } 43 + 44 + async loadSession() { 45 + const data = await chrome.storage.local.get(['atprotoSession', 'pdsUrl']); 46 + if (data.pdsUrl) { 47 + this.pds = data.pdsUrl; 48 + } 49 + if (data.atprotoSession) { 50 + this.session = data.atprotoSession; 51 + return true; 52 + } 53 + return false; 54 + } 55 + 56 + async logout() { 57 + this.session = null; 58 + await chrome.storage.local.remove('atprotoSession'); 59 + } 60 + 61 + async createScrobbleRecord(trackInfo) { 62 + if (!this.session) { 63 + throw new Error('Not authenticated'); 64 + } 65 + 66 + // Build the teal.fm play record 67 + const record = { 68 + $type: 'fm.teal.alpha.feed.play', 69 + trackName: trackInfo.track, 70 + playedTime: new Date(trackInfo.timestamp).toISOString(), 71 + submissionClientAgent: 'fm.teal.nts-scrobbler/1.0.0' 72 + }; 73 + 74 + // Add MusicBrainz metadata if available 75 + if (trackInfo.musicbrainz) { 76 + const mb = trackInfo.musicbrainz; 77 + 78 + // Prefer MusicBrainz track name 79 + if (mb.trackName) record.trackName = mb.trackName; 80 + 81 + // Recording metadata 82 + if (mb.recordingMbId) record.recordingMbId = mb.recordingMbId; 83 + if (mb.duration) record.duration = mb.duration; 84 + 85 + // Artist information - use artists array if available 86 + if (mb.artists && mb.artists.length > 0) { 87 + record.artists = mb.artists; 88 + } 89 + 90 + // Release information 91 + if (mb.releaseName) record.releaseName = mb.releaseName; 92 + if (mb.releaseMbId) record.releaseMbId = mb.releaseMbId; 93 + if (mb.isrc) record.isrc = mb.isrc; 94 + } 95 + 96 + // Add optional fields 97 + if (trackInfo.originUrl) { 98 + record.originUrl = trackInfo.originUrl; 99 + } 100 + 101 + try { 102 + const response = await fetch(`${this.pds}/xrpc/com.atproto.repo.createRecord`, { 103 + method: 'POST', 104 + headers: { 105 + 'Content-Type': 'application/json', 106 + 'Authorization': `Bearer ${this.session.accessJwt}`, 107 + }, 108 + body: JSON.stringify({ 109 + repo: this.session.did, 110 + collection: 'fm.teal.alpha.feed.play', 111 + record, 112 + }), 113 + }); 114 + 115 + if (!response.ok) { 116 + const errorText = await response.text(); 117 + throw new Error(`Failed to create record: ${response.statusText} - ${errorText}`); 118 + } 119 + 120 + return await response.json(); 121 + } catch (error) { 122 + console.error('Error creating scrobble record:', error); 123 + throw error; 124 + } 125 + } 126 + 127 + async refreshSession() { 128 + if (!this.session?.refreshJwt) { 129 + throw new Error('No refresh token available'); 130 + } 131 + 132 + try { 133 + const response = await fetch(`${this.pds}/xrpc/com.atproto.server.refreshSession`, { 134 + method: 'POST', 135 + headers: { 136 + 'Authorization': `Bearer ${this.session.refreshJwt}`, 137 + }, 138 + }); 139 + 140 + if (!response.ok) { 141 + throw new Error('Session refresh failed'); 142 + } 143 + 144 + this.session = await response.json(); 145 + await chrome.storage.local.set({ atprotoSession: this.session }); 146 + 147 + return this.session; 148 + } catch (error) { 149 + console.error('Session refresh error:', error); 150 + throw error; 151 + } 152 + } 153 + 154 + isAuthenticated() { 155 + return !!this.session; 156 + } 157 + } 158 + 159 + // Make available to other scripts 160 + if (typeof module !== 'undefined' && module.exports) { 161 + module.exports = ATProtoClient; 162 + }
+109
background.js
··· 1 + // Background service worker 2 + 3 + importScripts("atproto.js"); 4 + importScripts("musicbrainz.js"); 5 + 6 + const atproto = new ATProtoClient(); 7 + const musicbrainz = new MusicBrainzClient(); 8 + let scrobbleQueue = []; 9 + let pendingScrobbleTimeout = null; 10 + 11 + // Initialize on install 12 + chrome.runtime.onInstalled.addListener(async () => { 13 + console.log("NTS Radio Scrobbler installed"); 14 + await atproto.loadSession(); 15 + }); 16 + 17 + // Load session on startup 18 + chrome.runtime.onStartup.addListener(async () => { 19 + await atproto.loadSession(); 20 + }); 21 + 22 + // Listen for messages from content script 23 + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 24 + if (message.type === "NEW_TRACK") { 25 + handleNewTrack(message.data); 26 + sendResponse({ received: true }); 27 + } else if (message.type === "LOGIN") { 28 + handleLogin(message.data) 29 + .then(sendResponse) 30 + .catch((error) => sendResponse({ error: error.message })); 31 + return true; // Keep channel open for async response 32 + } else if (message.type === "LOGOUT") { 33 + handleLogout() 34 + .then(sendResponse) 35 + .catch((error) => sendResponse({ error: error.message })); 36 + return true; 37 + } else if (message.type === "GET_AUTH_STATUS") { 38 + sendResponse({ authenticated: atproto.isAuthenticated() }); 39 + } 40 + }); 41 + 42 + async function handleNewTrack(trackInfo) { 43 + if (!atproto.isAuthenticated()) { 44 + console.log("Not authenticated - skipping scrobble"); 45 + return; 46 + } 47 + 48 + // Check if auto-scrobble is enabled 49 + const settings = await chrome.storage.local.get("autoScrobble"); 50 + if (settings.autoScrobble === false) { 51 + return; 52 + } 53 + 54 + // Cancel any pending scrobble 55 + if (pendingScrobbleTimeout) { 56 + clearTimeout(pendingScrobbleTimeout); 57 + pendingScrobbleTimeout = null; 58 + } 59 + 60 + // Wait 30 seconds before scrobbling 61 + pendingScrobbleTimeout = setTimeout(async () => { 62 + try { 63 + // Fetch MusicBrainz metadata 64 + const mbInfo = await musicbrainz.searchRecording(trackInfo.artist, trackInfo.track); 65 + if (mbInfo) { 66 + trackInfo.musicbrainz = mbInfo; 67 + } 68 + 69 + await atproto.createScrobbleRecord(trackInfo); 70 + console.log('✓ Scrobbled:', `${trackInfo.artist} - ${trackInfo.track}`); 71 + 72 + // Show notification 73 + chrome.notifications.create({ 74 + type: "basic", 75 + iconUrl: "icons/icon48.png", 76 + title: "Track Scrobbled", 77 + message: `${trackInfo.artist} - ${trackInfo.track}`, 78 + }); 79 + } catch (error) { 80 + console.error("Failed to scrobble:", error); 81 + 82 + // If token expired, try to refresh 83 + if (error.message.includes("token") || error.message.includes("auth")) { 84 + try { 85 + await atproto.refreshSession(); 86 + await atproto.createScrobbleRecord(trackInfo); 87 + } catch (refreshError) { 88 + console.error("Session refresh failed:", refreshError); 89 + } 90 + } 91 + } finally { 92 + pendingScrobbleTimeout = null; 93 + } 94 + }, 30000); // 30 seconds 95 + } 96 + 97 + async function handleLogin({ identifier, password, pdsUrl }) { 98 + try { 99 + const session = await atproto.login(identifier, password, pdsUrl); 100 + return { success: true, session }; 101 + } catch (error) { 102 + return { success: false, error: error.message }; 103 + } 104 + } 105 + 106 + async function handleLogout() { 107 + await atproto.logout(); 108 + return { success: true }; 109 + }
+84
content.js
··· 1 + // Content script for NTS Radio - extracts currently playing track info 2 + 3 + let lastTrack = null; 4 + 5 + function extractTrackInfo() { 6 + let track = null; 7 + let artist = null; 8 + 9 + // Try episode player first 10 + const trackDetail = document.querySelector('.episode-player-tracklist__detail'); 11 + if (trackDetail) { 12 + const artistElement = trackDetail.querySelector('.episode-player-tracklist__artist'); 13 + const trackElement = trackDetail.querySelector('.episode-player-tracklist__title'); 14 + 15 + if (trackElement && artistElement) { 16 + track = trackElement.textContent.trim(); 17 + artist = artistElement.textContent.trim(); 18 + } 19 + } 20 + 21 + // If not found, try live radio 22 + if (!track || !artist) { 23 + const liveTracksList = document.querySelector('.live-tracks-list'); 24 + if (liveTracksList) { 25 + // Get the first track (most recent = currently playing) 26 + const firstLiveTrack = liveTracksList.querySelector('.live-track:first-child'); 27 + if (firstLiveTrack) { 28 + const artistElement = firstLiveTrack.querySelector('.live-track__artist-title'); 29 + const trackElement = firstLiveTrack.querySelector('.live-track__song-title'); 30 + 31 + if (trackElement && artistElement) { 32 + track = trackElement.textContent.trim(); 33 + artist = artistElement.textContent.trim(); 34 + } 35 + } 36 + } 37 + } 38 + 39 + if (track && artist) { 40 + return { 41 + track, 42 + artist, 43 + timestamp: Date.now(), 44 + station: 'NTS Radio', 45 + originUrl: window.location.href 46 + }; 47 + } 48 + 49 + return null; 50 + } 51 + 52 + function checkForNewTrack() { 53 + const trackInfo = extractTrackInfo(); 54 + 55 + if (trackInfo) { 56 + // Compare only artist and track to detect changes (exclude timestamp) 57 + const trackChanged = !lastTrack || 58 + lastTrack.artist !== trackInfo.artist || 59 + lastTrack.track !== trackInfo.track; 60 + 61 + if (trackChanged) { 62 + lastTrack = trackInfo; 63 + console.log('🎵 New track:', `${trackInfo.artist} - ${trackInfo.track}`); 64 + 65 + // Send to background script 66 + chrome.runtime.sendMessage({ 67 + type: 'NEW_TRACK', 68 + data: trackInfo 69 + }, (response) => { 70 + if (chrome.runtime.lastError) { 71 + console.error('Error sending message:', chrome.runtime.lastError); 72 + } 73 + }); 74 + } 75 + } 76 + } 77 + 78 + console.log('NTS Radio scrobbler loaded'); 79 + 80 + // Check for track changes every 5 seconds 81 + setInterval(checkForNewTrack, 5000); 82 + 83 + // Initial check 84 + checkForNewTrack();
icons/icon128.png

This is a binary file and will not be displayed.

icons/icon16.png

This is a binary file and will not be displayed.

icons/icon48.png

This is a binary file and will not be displayed.

+35
manifest.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "NTS Radio Scrobbler for AT Protocol", 4 + "version": "1.0.0", 5 + "description": "Scrobbles NTS Radio tracks to teal.fm", 6 + "permissions": ["storage", "activeTab"], 7 + "host_permissions": [ 8 + "https://www.nts.live/*", 9 + "https://bsky.social/*", 10 + "https://musicbrainz.org/*" 11 + ], 12 + "background": { 13 + "service_worker": "background.js" 14 + }, 15 + "content_scripts": [ 16 + { 17 + "matches": ["https://www.nts.live/*"], 18 + "js": ["content.js"], 19 + "run_at": "document_idle" 20 + } 21 + ], 22 + "action": { 23 + "default_popup": "popup.html", 24 + "default_icon": { 25 + "16": "icons/icon16.png", 26 + "48": "icons/icon48.png", 27 + "128": "icons/icon128.png" 28 + } 29 + }, 30 + "icons": { 31 + "16": "icons/icon16.png", 32 + "48": "icons/icon48.png", 33 + "128": "icons/icon128.png" 34 + } 35 + }
+97
musicbrainz.js
··· 1 + // MusicBrainz API integration 2 + 3 + class MusicBrainzClient { 4 + constructor() { 5 + this.baseUrl = 'https://musicbrainz.org/ws/2'; 6 + this.userAgent = 'NTSRadioScrobbler/1.0.0 ( teal-piper )'; 7 + } 8 + 9 + async searchRecording(artist, track) { 10 + try { 11 + // Clean up track name - remove version info in parentheses for better matching 12 + const cleanTrack = track.replace(/\s*\([^)]*\)\s*$/g, '').trim(); 13 + 14 + // Clean up artist name - replace commas with spaces for better matching 15 + const cleanArtist = artist.replace(/,\s*/g, ' ').trim(); 16 + 17 + // Build search query - use looser matching 18 + const query = `artist:${cleanArtist} AND recording:${cleanTrack}`; 19 + const url = `${this.baseUrl}/recording/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 20 + 21 + const response = await fetch(url, { 22 + headers: { 23 + 'User-Agent': this.userAgent, 24 + 'Accept': 'application/json' 25 + } 26 + }); 27 + 28 + if (!response.ok) { 29 + throw new Error(`MusicBrainz API error: ${response.statusText}`); 30 + } 31 + 32 + const data = await response.json(); 33 + 34 + if (data.recordings && data.recordings.length > 0) { 35 + const recording = data.recordings[0]; 36 + return this.extractRecordingInfo(recording); 37 + } 38 + 39 + return null; 40 + } catch (error) { 41 + console.error('MusicBrainz search error:', error); 42 + return null; 43 + } 44 + } 45 + 46 + extractRecordingInfo(recording) { 47 + const info = { 48 + recordingMbId: recording.id, 49 + trackName: recording.title, 50 + duration: recording.length ? Math.floor(recording.length / 1000) : null, 51 + artistNames: [], 52 + artistMbIds: [], 53 + artists: [], 54 + isrc: null, 55 + releaseName: null, 56 + releaseMbId: null 57 + }; 58 + 59 + // Extract artist information 60 + if (recording['artist-credit']) { 61 + recording['artist-credit'].forEach(credit => { 62 + if (credit.artist) { 63 + info.artistNames.push(credit.artist.name); 64 + info.artistMbIds.push(credit.artist.id); 65 + info.artists.push({ 66 + artistName: credit.artist.name, 67 + artistMbId: credit.artist.id 68 + }); 69 + } 70 + }); 71 + } 72 + 73 + // Extract ISRC 74 + if (recording.isrcs && recording.isrcs.length > 0) { 75 + info.isrc = recording.isrcs[0]; 76 + } 77 + 78 + // Extract release information 79 + if (recording.releases && recording.releases.length > 0) { 80 + const release = recording.releases[0]; 81 + info.releaseName = release.title; 82 + info.releaseMbId = release.id; 83 + } 84 + 85 + return info; 86 + } 87 + 88 + // Rate limiting helper - MusicBrainz requests max 1 req/second 89 + async delay(ms = 1000) { 90 + return new Promise(resolve => setTimeout(resolve, ms)); 91 + } 92 + } 93 + 94 + // Make available to other scripts 95 + if (typeof module !== 'undefined' && module.exports) { 96 + module.exports = MusicBrainzClient; 97 + }
+44
popup.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <title>NTS Radio Scrobbler</title> 6 + <link rel="stylesheet" href="styles.css"> 7 + </head> 8 + <body> 9 + <div class="container"> 10 + <h1>NTS Radio Scrobbler</h1> 11 + 12 + <div id="auth-section"> 13 + <div id="login-form"> 14 + <h2>Login to Bluesky</h2> 15 + <input type="text" id="pds-url" placeholder="PDS URL (default: https://bsky.social)" /> 16 + <input type="text" id="identifier" placeholder="Handle or email" /> 17 + <input type="password" id="password" placeholder="Password" /> 18 + <button id="login-btn">Login</button> 19 + <div id="error-msg" class="error"></div> 20 + </div> 21 + 22 + <div id="logged-in" style="display: none;"> 23 + <h2>Connected to Bluesky</h2> 24 + <p id="user-info"></p> 25 + <button id="logout-btn">Logout</button> 26 + </div> 27 + </div> 28 + 29 + <div id="settings-section"> 30 + <h2>Settings</h2> 31 + <label> 32 + <input type="checkbox" id="auto-scrobble" checked /> 33 + Auto-scrobble tracks 34 + </label> 35 + </div> 36 + 37 + <div id="status-section"> 38 + <p class="info">Visit <a href="https://www.nts.live" target="_blank">nts.live</a> and start listening to scrobble tracks!</p> 39 + </div> 40 + </div> 41 + 42 + <script src="popup.js"></script> 43 + </body> 44 + </html>
+90
popup.js
··· 1 + // Popup script 2 + 3 + document.addEventListener('DOMContentLoaded', async () => { 4 + const loginForm = document.getElementById('login-form'); 5 + const loggedInSection = document.getElementById('logged-in'); 6 + const loginBtn = document.getElementById('login-btn'); 7 + const logoutBtn = document.getElementById('logout-btn'); 8 + const pdsUrlInput = document.getElementById('pds-url'); 9 + const identifierInput = document.getElementById('identifier'); 10 + const passwordInput = document.getElementById('password'); 11 + const errorMsg = document.getElementById('error-msg'); 12 + const autoScrobbleCheckbox = document.getElementById('auto-scrobble'); 13 + 14 + // Check auth status 15 + const response = await chrome.runtime.sendMessage({ type: 'GET_AUTH_STATUS' }); 16 + 17 + if (response.authenticated) { 18 + showLoggedIn(); 19 + } else { 20 + showLogin(); 21 + } 22 + 23 + // Load settings 24 + const settings = await chrome.storage.local.get(['autoScrobble', 'pdsUrl']); 25 + if (settings.autoScrobble !== undefined) { 26 + autoScrobbleCheckbox.checked = settings.autoScrobble; 27 + } 28 + if (settings.pdsUrl) { 29 + pdsUrlInput.value = settings.pdsUrl; 30 + } 31 + 32 + // Login handler 33 + loginBtn.addEventListener('click', async () => { 34 + const identifier = identifierInput.value.trim(); 35 + const password = passwordInput.value; 36 + let pdsUrl = pdsUrlInput.value.trim(); 37 + 38 + if (!pdsUrl) { 39 + pdsUrl = 'https://bsky.social'; 40 + } 41 + 42 + if (!identifier || !password) { 43 + errorMsg.textContent = 'Please enter both handle/email and password'; 44 + return; 45 + } 46 + 47 + loginBtn.disabled = true; 48 + loginBtn.textContent = 'Logging in...'; 49 + errorMsg.textContent = ''; 50 + 51 + // Save PDS URL 52 + await chrome.storage.local.set({ pdsUrl }); 53 + 54 + const result = await chrome.runtime.sendMessage({ 55 + type: 'LOGIN', 56 + data: { identifier, password, pdsUrl } 57 + }); 58 + 59 + if (result.success) { 60 + showLoggedIn(); 61 + passwordInput.value = ''; 62 + } else { 63 + errorMsg.textContent = result.error || 'Login failed'; 64 + } 65 + 66 + loginBtn.disabled = false; 67 + loginBtn.textContent = 'Login'; 68 + }); 69 + 70 + // Logout handler 71 + logoutBtn.addEventListener('click', async () => { 72 + await chrome.runtime.sendMessage({ type: 'LOGOUT' }); 73 + showLogin(); 74 + }); 75 + 76 + // Auto-scrobble setting 77 + autoScrobbleCheckbox.addEventListener('change', async (e) => { 78 + await chrome.storage.local.set({ autoScrobble: e.target.checked }); 79 + }); 80 + 81 + function showLogin() { 82 + loginForm.style.display = 'block'; 83 + loggedInSection.style.display = 'none'; 84 + } 85 + 86 + function showLoggedIn() { 87 + loginForm.style.display = 'none'; 88 + loggedInSection.style.display = 'block'; 89 + } 90 + });
+112
styles.css
··· 1 + body { 2 + width: 350px; 3 + padding: 0; 4 + margin: 0; 5 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 6 + font-size: 14px; 7 + color: #333; 8 + } 9 + 10 + .container { 11 + padding: 20px; 12 + } 13 + 14 + h1 { 15 + font-size: 18px; 16 + margin: 0 0 20px 0; 17 + color: #000; 18 + } 19 + 20 + h2 { 21 + font-size: 14px; 22 + margin: 0 0 12px 0; 23 + color: #666; 24 + font-weight: 600; 25 + } 26 + 27 + input[type="text"], 28 + input[type="password"] { 29 + width: 100%; 30 + padding: 8px 12px; 31 + margin-bottom: 10px; 32 + border: 1px solid #ddd; 33 + border-radius: 4px; 34 + box-sizing: border-box; 35 + font-size: 14px; 36 + } 37 + 38 + input[type="text"]:focus, 39 + input[type="password"]:focus { 40 + outline: none; 41 + border-color: #4a9eff; 42 + } 43 + 44 + button { 45 + width: 100%; 46 + padding: 10px; 47 + background: #000; 48 + color: #fff; 49 + border: none; 50 + border-radius: 4px; 51 + font-size: 14px; 52 + cursor: pointer; 53 + font-weight: 500; 54 + } 55 + 56 + button:hover { 57 + background: #333; 58 + } 59 + 60 + button:disabled { 61 + background: #ccc; 62 + cursor: not-allowed; 63 + } 64 + 65 + .error { 66 + color: #d32f2f; 67 + font-size: 12px; 68 + margin-top: 8px; 69 + min-height: 16px; 70 + } 71 + 72 + #auth-section { 73 + margin-bottom: 20px; 74 + padding-bottom: 20px; 75 + border-bottom: 1px solid #eee; 76 + } 77 + 78 + #settings-section { 79 + margin-bottom: 20px; 80 + } 81 + 82 + #settings-section label { 83 + display: flex; 84 + align-items: center; 85 + cursor: pointer; 86 + } 87 + 88 + #settings-section input[type="checkbox"] { 89 + margin-right: 8px; 90 + cursor: pointer; 91 + } 92 + 93 + .info { 94 + font-size: 12px; 95 + color: #666; 96 + line-height: 1.5; 97 + } 98 + 99 + a { 100 + color: #4a9eff; 101 + text-decoration: none; 102 + } 103 + 104 + a:hover { 105 + text-decoration: underline; 106 + } 107 + 108 + #user-info { 109 + font-size: 12px; 110 + color: #666; 111 + margin-bottom: 12px; 112 + }