+52
README.md
+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
+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
+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
+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
icons/icon128.png
This is a binary file and will not be displayed.
icons/icon16.png
icons/icon16.png
This is a binary file and will not be displayed.
icons/icon48.png
icons/icon48.png
This is a binary file and will not be displayed.
+35
manifest.json
+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
+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
+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
+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
+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
+
}