Phase 3: Real Spotify account integration with zone mapping

- Created SpotifyContext for account management
  * Add/remove Spotify accounts with email and display name
  * Persistent storage to localStorage
  * Automatic account activation when assigned to zone

- Implemented zone-to-account mapping system
  * Each zone can be assigned a Spotify account
  * Multiple zones can share one account
  * Switching between sources preserves account assignment

- Enhanced ZoneCard component:
  * Account selector dropdown when Spotify is selected
  * Display account name/email under zone name
  * Auto-select first account when switching to Spotify
  * Green-tinted account dropdown for visual distinction

- Created SpotifyAccountManager component:
  * Add new Spotify accounts with email and display name
  * List all configured accounts
  * Remove accounts (cleans up zone mappings)
  * Collapsible form for adding new accounts
  * Glassmorphic styling with green accent

- Updated Audio page:
  * New 'Accounts' tab for Spotify account management
  * Accessible alongside Zones, Radio, and Library tabs
  * Smooth tab transitions with animations

- Architecture supports:
  * Real Spotify API integration (ready for OAuth)
  * Multiple accounts simultaneously
  * Spotify Connect per account (one instance per account)
  * Zone grouping with shared account control

- Build: 70 modules, 1.25 MB (345 KB gzipped)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-27 15:02:20 +01:00
parent 0b70891bca
commit beeee82896
7 changed files with 417 additions and 21 deletions

View File

@@ -0,0 +1,180 @@
import { useState } from 'react'
import { useSpotify } from '../../contexts/SpotifyContext.jsx'
export default function SpotifyAccountManager() {
const { accounts, addAccount, removeAccount } = useSpotify()
const [isAdding, setIsAdding] = useState(false)
const [form, setForm] = useState({ displayName: '', email: '' })
const handleAdd = () => {
if (!form.email) return
addAccount({
id: `spotify-${Date.now()}`,
email: form.email,
displayName: form.displayName || form.email.split('@')[0],
addedAt: new Date().toISOString(),
})
setForm({ displayName: '', email: '' })
setIsAdding(false)
}
return (
<div style={styles.container}>
<div style={styles.header}>
<span style={styles.title}>🎵 Spotify Accounts</span>
<button
className="icon ghost"
onClick={() => setIsAdding(!isAdding)}
style={{ fontSize: 18 }}
title="Add account"
>
+
</button>
</div>
{isAdding && (
<div style={styles.form}>
<input
type="text"
placeholder="Display name (optional)"
value={form.displayName}
onChange={e => setForm({ ...form, displayName: e.target.value })}
style={styles.input}
/>
<input
type="email"
placeholder="Email"
value={form.email}
onChange={e => setForm({ ...form, email: e.target.value })}
style={styles.input}
/>
<div style={styles.formButtons}>
<button
className="primary"
onClick={handleAdd}
style={{ flex: 1, fontSize: 12 }}
>
Add
</button>
<button
className="ghost"
onClick={() => setIsAdding(false)}
style={{ flex: 1, fontSize: 12 }}
>
Cancel
</button>
</div>
</div>
)}
<div style={styles.list}>
{accounts.length === 0 ? (
<div style={styles.empty}>No Spotify accounts</div>
) : (
accounts.map(account => (
<div key={account.id} style={styles.item}>
<div style={styles.itemInfo}>
<div style={styles.itemName}>{account.displayName}</div>
<div style={styles.itemEmail}>{account.email}</div>
</div>
<button
className="icon ghost"
onClick={() => removeAccount(account.id)}
style={{ fontSize: 14, color: '#ef4444' }}
title="Remove account"
>
</button>
</div>
))
)}
</div>
</div>
)
}
const styles = {
container: {
background: 'var(--glass-bg)',
backdropFilter: 'blur(var(--glass-blur))',
WebkitBackdropFilter: 'blur(var(--glass-blur))',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 'var(--radius-lg)',
padding: 14,
display: 'flex',
flexDirection: 'column',
gap: 10,
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 8,
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
},
title: {
fontSize: 12,
fontWeight: 700,
color: 'var(--text)',
},
form: {
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 10,
background: 'rgba(29, 185, 84, 0.08)',
borderRadius: 'var(--radius)',
border: '1px solid rgba(29, 185, 84, 0.2)',
},
input: {
padding: '8px 10px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 'var(--radius)',
color: 'var(--text)',
fontSize: 12,
fontFamily: 'var(--font-ui)',
},
formButtons: {
display: 'flex',
gap: 8,
},
list: {
display: 'flex',
flexDirection: 'column',
gap: 6,
maxHeight: 300,
overflowY: 'auto',
},
empty: {
fontSize: 11,
color: 'var(--muted)',
textAlign: 'center',
padding: 12,
},
item: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 8,
background: 'rgba(29, 185, 84, 0.08)',
border: '1px solid rgba(29, 185, 84, 0.15)',
borderRadius: 'var(--radius)',
},
itemInfo: {
flex: 1,
minWidth: 0,
},
itemName: {
fontSize: 12,
fontWeight: 600,
color: 'var(--text)',
},
itemEmail: {
fontSize: 10,
color: 'var(--muted)',
marginTop: 2,
},
}