Add multi-profile support with Netflix-style profile picker

Allow multiple users to share a single SoulSync instance with isolated personal data. Each profile gets its own watchlist, wishlist, discovery pool, similar artists, and bubble snapshots — while sharing the same music library, database, and service credentials.

  - Netflix-style profile picker on startup when multiple profiles exist
  - Optional PIN protection per profile; admin PIN required when >1 profiles
  - Admin-only profile management (create, edit, rename, delete)
  - Profile avatar images via URL with colored-initial fallback
  - Zero-downtime SQLite migration — all existing data maps to auto-created
    admin profile
  - Single-user installs see no changes — profile system is invisible until
    a second profile is created
  - WebSocket count emitters scoped to profile rooms (watchlist/wishlist)
  - Background scanners (watchlist, wishlist, discovery) iterate all profiles
This commit is contained in:
Broque Thomas 2026-03-04 10:46:08 -08:00
parent 84de4ad16b
commit 4fee005dee
8 changed files with 2514 additions and 375 deletions

View file

@ -686,13 +686,14 @@ class WatchlistScanner:
# Check if we have fresh similar artists cached (< 30 days old)
# If Spotify is authenticated, also require Spotify IDs to be present
spotify_authenticated = self.spotify_client and self.spotify_client.is_spotify_authenticated()
if self.database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated):
artist_profile_id = getattr(watchlist_artist, 'profile_id', 1)
if self.database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated, profile_id=artist_profile_id):
logger.info(f"Similar artists for {watchlist_artist.artist_name} are cached and fresh, skipping MusicMap fetch")
# Even if cached, backfill missing iTunes IDs (seamless dual-source support)
self._backfill_similar_artists_itunes_ids(source_artist_id)
self._backfill_similar_artists_itunes_ids(source_artist_id, profile_id=artist_profile_id)
else:
logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...")
self.update_similar_artists(watchlist_artist)
self.update_similar_artists(watchlist_artist, profile_id=artist_profile_id)
logger.info(f"Similar artists updated for {watchlist_artist.artist_name}")
except Exception as similar_error:
logger.warning(f"Failed to update similar artists for {watchlist_artist.artist_name}: {similar_error}")
@ -1183,7 +1184,7 @@ class WatchlistScanner:
'is_local': False
}
# Add to wishlist with watchlist context
# Add to wishlist with watchlist context (scoped to artist's profile)
success = self.database.add_to_wishlist(
spotify_track_data=spotify_track_data,
failure_reason="Missing from library (found by watchlist scan)",
@ -1193,7 +1194,8 @@ class WatchlistScanner:
'watchlist_artist_id': watchlist_artist.spotify_artist_id,
'album_name': album_name,
'scan_timestamp': datetime.now().isoformat()
}
},
profile_id=getattr(watchlist_artist, 'profile_id', 1)
)
if success:
@ -1407,20 +1409,21 @@ class WatchlistScanner:
logger.error(f"Error fetching similar artists from MusicMap: {e}")
return []
def _backfill_similar_artists_itunes_ids(self, source_artist_id: str) -> int:
def _backfill_similar_artists_itunes_ids(self, source_artist_id: str, profile_id: int = 1) -> int:
"""
Backfill missing iTunes IDs for cached similar artists.
This ensures seamless dual-source support without clearing cached data.
Args:
source_artist_id: The source artist ID to backfill similar artists for
profile_id: Profile to scope the backfill to
Returns:
Number of similar artists updated with iTunes IDs
"""
try:
# Get similar artists that are missing iTunes IDs
similar_artists = self.database.get_similar_artists_missing_itunes_ids(source_artist_id)
similar_artists = self.database.get_similar_artists_missing_itunes_ids(source_artist_id, profile_id=profile_id)
if not similar_artists:
return 0
@ -1455,7 +1458,7 @@ class WatchlistScanner:
logger.error(f"Error backfilling similar artists iTunes IDs: {e}")
return 0
def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10) -> bool:
def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10, profile_id: int = 1) -> bool:
"""
Fetch and store similar artists for a watchlist artist.
Called after each artist scan to build discovery pool.
@ -1486,7 +1489,8 @@ class WatchlistScanner:
similar_artist_name=similar_artist['name'],
similar_artist_spotify_id=similar_artist.get('spotify_id'),
similar_artist_itunes_id=similar_artist.get('itunes_id'),
similarity_rank=rank
similarity_rank=rank,
profile_id=profile_id
)
if success:
@ -1504,7 +1508,7 @@ class WatchlistScanner:
logger.error(f"Error fetching similar artists for {watchlist_artist.artist_name}: {e}")
return False
def populate_discovery_pool(self, top_artists_limit: int = 50, albums_per_artist: int = 10):
def populate_discovery_pool(self, top_artists_limit: int = 50, albums_per_artist: int = 10, profile_id: int = 1):
"""
Populate discovery pool with tracks from top similar artists.
Called after watchlist scan completes.
@ -1520,14 +1524,14 @@ class WatchlistScanner:
import random
# Check if we should run discovery pool population (prevents over-polling)
skip_pool_population = not self.database.should_populate_discovery_pool(hours_threshold=24)
skip_pool_population = not self.database.should_populate_discovery_pool(hours_threshold=24, profile_id=profile_id)
if skip_pool_population:
logger.info("Discovery pool was populated recently (< 24 hours ago). Skipping pool population.")
logger.info("But still refreshing recent albums cache and curated playlists...")
# Still run these even when skipping main pool population
self.cache_discovery_recent_albums()
self.curate_discovery_playlists()
self.cache_discovery_recent_albums(profile_id=profile_id)
self.curate_discovery_playlists(profile_id=profile_id)
return
logger.info("Populating discovery pool from similar artists...")
@ -1546,15 +1550,15 @@ class WatchlistScanner:
logger.info(f"Sources available - Spotify: {spotify_available}, iTunes: {itunes_available}")
# Get top similar artists across all watchlist (ordered by occurrence_count)
similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit)
# Get top similar artists for this profile's watchlist (ordered by occurrence_count)
similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit, profile_id=profile_id)
if not similar_artists:
logger.info("No similar artists found to populate discovery pool from similar artists")
logger.info("But still caching recent albums from watchlist artists and curating playlists...")
# Still run these even without similar artists - they use watchlist artists
self.cache_discovery_recent_albums()
self.curate_discovery_playlists()
self.cache_discovery_recent_albums(profile_id=profile_id)
self.curate_discovery_playlists(profile_id=profile_id)
return
logger.info(f"Processing {len(similar_artists)} top similar artists for discovery pool")
@ -1734,8 +1738,8 @@ class WatchlistScanner:
track_data['itunes_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = similar_artist.similar_artist_itunes_id
# Add to discovery pool with source
if self.database.add_to_discovery_pool(track_data, source=source):
# Add to discovery pool with source (scoped to profile)
if self.database.add_to_discovery_pool(track_data, source=source, profile_id=profile_id):
total_tracks_added += 1
except Exception as track_error:
@ -1887,7 +1891,7 @@ class WatchlistScanner:
track_data['itunes_album_id'] = album_data.get('id')
track_data['itunes_artist_id'] = artist_id_for_genres or ''
if self.database.add_to_discovery_pool(track_data, source=db_source):
if self.database.add_to_discovery_pool(track_data, source=db_source, profile_id=profile_id):
total_tracks_added += 1
except Exception as track_error:
continue
@ -1918,23 +1922,23 @@ class WatchlistScanner:
final_count = cursor.fetchone()['count']
# Update timestamp to mark when pool was last populated
self.database.update_discovery_pool_timestamp(track_count=final_count)
self.database.update_discovery_pool_timestamp(track_count=final_count, profile_id=profile_id)
logger.info(f"Discovery pool now contains {final_count} total tracks (built over time)")
# Cache recent albums for discovery page
logger.info("Caching recent albums for discovery page...")
self.cache_discovery_recent_albums()
self.cache_discovery_recent_albums(profile_id=profile_id)
# Curate playlists for consistent daily experience
logger.info("Curating discovery playlists...")
self.curate_discovery_playlists()
self.curate_discovery_playlists(profile_id=profile_id)
except Exception as e:
logger.error(f"Error populating discovery pool: {e}")
import traceback
traceback.print_exc()
def update_discovery_pool_incremental(self):
def update_discovery_pool_incremental(self, profile_id: int = 1):
"""
Lightweight incremental update for discovery pool - runs every 6 hours.
@ -1948,13 +1952,13 @@ class WatchlistScanner:
from datetime import datetime, timedelta
# Check if we should run (prevents over-polling Spotify)
if not self.database.should_populate_discovery_pool(hours_threshold=6):
if not self.database.should_populate_discovery_pool(hours_threshold=6, profile_id=profile_id):
logger.info("Discovery pool was updated recently (< 6 hours ago). Skipping incremental update.")
return
logger.info("Starting incremental discovery pool update (watchlist artists only)...")
watchlist_artists = self.database.get_watchlist_artists()
watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id)
if not watchlist_artists:
logger.info("No watchlist artists to check for incremental update")
return
@ -2042,7 +2046,7 @@ class WatchlistScanner:
'artist_genres': artist_genres
}
if self.database.add_to_discovery_pool(track_data):
if self.database.add_to_discovery_pool(track_data, profile_id=profile_id):
total_tracks_added += 1
except Exception as track_error:
@ -2071,7 +2075,7 @@ class WatchlistScanner:
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool")
current_count = cursor.fetchone()['count']
self.database.update_discovery_pool_timestamp(track_count=current_count)
self.database.update_discovery_pool_timestamp(track_count=current_count, profile_id=profile_id)
logger.info(f"Discovery pool now contains {current_count} total tracks")
except Exception as e:
@ -2079,7 +2083,7 @@ class WatchlistScanner:
import traceback
traceback.print_exc()
def cache_discovery_recent_albums(self):
def cache_discovery_recent_albums(self, profile_id: int = 1):
"""
Cache recent albums from watchlist and similar artists for discover page.
@ -2091,8 +2095,8 @@ class WatchlistScanner:
logger.info("Caching recent albums for discover page...")
# Clear existing cache
self.database.clear_discovery_recent_albums()
# Clear existing cache for this profile
self.database.clear_discovery_recent_albums(profile_id=profile_id)
# 30-day window for recent releases
cutoff_date = datetime.now() - timedelta(days=30)
@ -2106,9 +2110,9 @@ class WatchlistScanner:
from core.itunes_client import iTunesClient
itunes_client = iTunesClient()
# Get artists to check
watchlist_artists = self.database.get_watchlist_artists()
similar_artists = self.database.get_top_similar_artists(limit=50)
# Get artists to check (scoped to profile)
watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id)
similar_artists = self.database.get_top_similar_artists(limit=50, profile_id=profile_id)
logger.info(f"Checking albums from {len(watchlist_artists)} watchlist + {len(similar_artists)} similar artists")
logger.info(f"Sources: Spotify={spotify_available}, iTunes=True")
@ -2141,7 +2145,7 @@ class WatchlistScanner:
'release_date': release_str[:10],
'album_type': album.album_type if hasattr(album, 'album_type') else 'album'
}
if self.database.cache_discovery_recent_album(album_data, source=source):
if self.database.cache_discovery_recent_album(album_data, source=source, profile_id=profile_id):
cached_count[source] += 1
logger.debug(f"Cached [{source}] recent album: {album.name} by {artist_name} ({release_str})")
return True
@ -2256,7 +2260,7 @@ class WatchlistScanner:
import traceback
traceback.print_exc()
def curate_discovery_playlists(self):
def curate_discovery_playlists(self, profile_id: int = 1):
"""
Curate consistent playlist selections that stay the same until next discovery pool update.
@ -2286,7 +2290,7 @@ class WatchlistScanner:
logger.info(f"Curating Release Radar for {source}...")
# 1. Curate Release Radar - 50 tracks from recent albums
recent_albums = self.database.get_discovery_recent_albums(limit=50, source=source)
recent_albums = self.database.get_discovery_recent_albums(limit=50, source=source, profile_id=profile_id)
release_radar_tracks = []
if not recent_albums:
@ -2405,18 +2409,18 @@ class WatchlistScanner:
formatted_track['itunes_track_id'] = track_data['id']
formatted_track['itunes_album_id'] = track_data['album'].get('id', '')
self.database.add_to_discovery_pool(formatted_track, source=source)
self.database.add_to_discovery_pool(formatted_track, source=source, profile_id=profile_id)
except Exception as e:
continue
# Save with source suffix for multi-source support
playlist_key = f'release_radar_{source}'
self.database.save_curated_playlist(playlist_key, release_radar_tracks)
self.database.save_curated_playlist(playlist_key, release_radar_tracks, profile_id=profile_id)
logger.info(f"Release Radar ({source}) curated: {len(release_radar_tracks)} tracks")
# 2. Curate Discovery Weekly - 50 tracks from discovery pool
logger.info(f"Curating Discovery Weekly for {source}...")
discovery_tracks = self.database.get_discovery_pool_tracks(limit=2000, new_releases_only=False, source=source)
discovery_tracks = self.database.get_discovery_pool_tracks(limit=2000, new_releases_only=False, source=source, profile_id=profile_id)
if not discovery_tracks:
logger.warning(f"[{source.upper()}] No discovery pool tracks found for Discovery Weekly - check populate_discovery_pool()")
@ -2458,7 +2462,7 @@ class WatchlistScanner:
discovery_weekly_tracks.append(track.itunes_track_id)
playlist_key = f'discovery_weekly_{source}'
self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks)
self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks, profile_id=profile_id)
logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks")
# Also save without suffix for backward compatibility (use active source)
@ -2467,10 +2471,10 @@ class WatchlistScanner:
discovery_weekly_key = f'discovery_weekly_{active_source}'
# Copy active source playlists to non-suffixed keys
release_radar_ids = self.database.get_curated_playlist(release_radar_key) or []
discovery_weekly_ids = self.database.get_curated_playlist(discovery_weekly_key) or []
self.database.save_curated_playlist('release_radar', release_radar_ids)
self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids)
release_radar_ids = self.database.get_curated_playlist(release_radar_key, profile_id=profile_id) or []
discovery_weekly_ids = self.database.get_curated_playlist(discovery_weekly_key, profile_id=profile_id) or []
self.database.save_curated_playlist('release_radar', release_radar_ids, profile_id=profile_id)
self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids, profile_id=profile_id)
logger.info("Playlist curation complete")

View file

@ -25,8 +25,8 @@ class WishlistService:
self._database = get_database(self.database_path)
return self._database
def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown",
source_context: Dict[str, Any] = None) -> bool:
def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown",
source_context: Dict[str, Any] = None, profile_id: int = 1) -> bool:
"""
Add a failed track from a download modal to the wishlist.
@ -74,7 +74,8 @@ class WishlistService:
spotify_track_data=spotify_track,
failure_reason=failure_reason,
source_type=source_type,
source_info=source_info
source_info=source_info,
profile_id=profile_id
)
except Exception as e:
@ -82,30 +83,33 @@ class WishlistService:
return False
def add_spotify_track_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str,
source_type: str = "manual", source_context: Dict[str, Any] = None) -> bool:
source_type: str = "manual", source_context: Dict[str, Any] = None,
profile_id: int = 1) -> bool:
"""
Directly add a Spotify track to the wishlist.
Args:
spotify_track_data: Full Spotify track data dictionary
failure_reason: Reason for the failure
source_type: Source type ('playlist', 'album', 'manual')
source_context: Additional context information
profile_id: Profile to add to
"""
return self.database.add_to_wishlist(
spotify_track_data=spotify_track_data,
failure_reason=failure_reason,
source_type=source_type,
source_info=source_context or {}
source_info=source_context or {},
profile_id=profile_id
)
def get_wishlist_tracks_for_download(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
def get_wishlist_tracks_for_download(self, limit: Optional[int] = None, profile_id: int = 1) -> List[Dict[str, Any]]:
"""
Get wishlist tracks formatted for the download modal.
Returns tracks in a format similar to playlist tracks for compatibility.
"""
try:
wishlist_tracks = self.database.get_wishlist_tracks(limit=limit)
wishlist_tracks = self.database.get_wishlist_tracks(limit=limit, profile_id=profile_id)
formatted_tracks = []
for wishlist_track in wishlist_tracks:
@ -144,28 +148,29 @@ class WishlistService:
logger.error(f"Error getting wishlist tracks for download: {e}")
return []
def mark_track_download_result(self, spotify_track_id: str, success: bool, error_message: str = None) -> bool:
def mark_track_download_result(self, spotify_track_id: str, success: bool, error_message: str = None, profile_id: int = 1) -> bool:
"""
Mark the result of a download attempt for a wishlist track.
Args:
spotify_track_id: Spotify track ID
success: Whether the download was successful
error_message: Error message if failed
profile_id: Profile to scope the operation to
"""
return self.database.update_wishlist_retry(spotify_track_id, success, error_message)
return self.database.update_wishlist_retry(spotify_track_id, success, error_message, profile_id=profile_id)
def remove_track_from_wishlist(self, spotify_track_id: str) -> bool:
def remove_track_from_wishlist(self, spotify_track_id: str, profile_id: int = 1) -> bool:
"""Remove a track from the wishlist (typically after successful download)"""
return self.database.remove_from_wishlist(spotify_track_id)
def get_wishlist_count(self) -> int:
return self.database.remove_from_wishlist(spotify_track_id, profile_id=profile_id)
def get_wishlist_count(self, profile_id: int = 1) -> int:
"""Get the total number of tracks in the wishlist"""
return self.database.get_wishlist_count()
def clear_wishlist(self) -> bool:
return self.database.get_wishlist_count(profile_id=profile_id)
def clear_wishlist(self, profile_id: int = 1) -> bool:
"""Clear all tracks from the wishlist"""
return self.database.clear_wishlist()
return self.database.clear_wishlist(profile_id=profile_id)
def check_track_in_wishlist(self, spotify_track_id: str) -> bool:
"""Check if a track exists in the wishlist by Spotify track ID"""
@ -213,20 +218,20 @@ class WishlistService:
logger.error(f"Error finding matching wishlist track: {e}")
return None
def get_wishlist_summary(self) -> Dict[str, Any]:
def get_wishlist_summary(self, profile_id: int = 1) -> Dict[str, Any]:
"""Get a summary of the wishlist for dashboard display"""
try:
total_tracks = self.get_wishlist_count()
total_tracks = self.get_wishlist_count(profile_id=profile_id)
if total_tracks == 0:
return {
'total_tracks': 0,
'by_source_type': {},
'recent_failures': []
}
# Get detailed breakdown
wishlist_tracks = self.database.get_wishlist_tracks()
wishlist_tracks = self.database.get_wishlist_tracks(profile_id=profile_id)
# Group by source type
by_source_type = {}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,63 @@
</head>
<body>
<!-- Profile Picker Overlay -->
<div id="profile-picker-overlay" class="profile-picker-overlay" style="display: none;">
<div class="profile-picker-container">
<h2 class="profile-picker-title">Who's listening?</h2>
<div id="profile-picker-grid" class="profile-picker-grid"></div>
<div id="profile-picker-actions" class="profile-picker-actions" style="display: none;">
<button id="manage-profiles-btn" class="profile-manage-btn">Manage Profiles</button>
</div>
</div>
<!-- PIN Dialog -->
<div id="profile-pin-dialog" class="profile-pin-dialog" style="display: none;">
<div class="profile-pin-content">
<div id="profile-pin-avatar" class="profile-pin-avatar"></div>
<p id="profile-pin-name" class="profile-pin-name"></p>
<input type="password" id="profile-pin-input" class="profile-pin-input" maxlength="6" placeholder="Enter PIN" autocomplete="off">
<div class="profile-pin-buttons">
<button id="profile-pin-cancel" class="profile-pin-cancel">Cancel</button>
<button id="profile-pin-submit" class="profile-pin-submit">Submit</button>
</div>
<p id="profile-pin-error" class="profile-pin-error" style="display: none;"></p>
</div>
</div>
<!-- Profile Management Panel -->
<div id="profile-manage-panel" class="profile-manage-panel" style="display: none;">
<div class="profile-manage-content">
<div class="profile-manage-header">
<h3>Manage Profiles</h3>
<button id="profile-manage-close" class="profile-manage-close-btn">&times;</button>
</div>
<div id="profile-manage-list" class="profile-manage-list"></div>
<div class="profile-manage-add">
<h4>Add Profile</h4>
<input type="text" id="new-profile-name" class="profile-input" placeholder="Profile name" maxlength="20">
<input type="url" id="new-profile-avatar-url" class="profile-input" placeholder="Avatar image URL (optional)">
<div class="profile-color-picker">
<span class="profile-color-swatch" data-color="#6366f1" style="background:#6366f1" title="Indigo"></span>
<span class="profile-color-swatch" data-color="#ec4899" style="background:#ec4899" title="Pink"></span>
<span class="profile-color-swatch" data-color="#10b981" style="background:#10b981" title="Green"></span>
<span class="profile-color-swatch" data-color="#f59e0b" style="background:#f59e0b" title="Amber"></span>
<span class="profile-color-swatch" data-color="#3b82f6" style="background:#3b82f6" title="Blue"></span>
<span class="profile-color-swatch" data-color="#ef4444" style="background:#ef4444" title="Red"></span>
<span class="profile-color-swatch" data-color="#8b5cf6" style="background:#8b5cf6" title="Purple"></span>
<span class="profile-color-swatch" data-color="#14b8a6" style="background:#14b8a6" title="Teal"></span>
</div>
<input type="password" id="new-profile-pin" class="profile-input" placeholder="PIN (optional)" maxlength="6">
<button id="create-profile-btn" class="profile-create-btn">Create Profile</button>
</div>
<div id="admin-pin-section" class="admin-pin-section" style="display: none;">
<h4>Admin PIN</h4>
<p class="admin-pin-note">Required when multiple profiles exist</p>
<input type="password" id="admin-pin-input" class="profile-input" placeholder="Set admin PIN" maxlength="6">
<button id="set-admin-pin-btn" class="profile-create-btn">Set Admin PIN</button>
</div>
</div>
</div>
</div>
<div class="main-container">
<!-- Mobile Navigation -->
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
@ -26,6 +83,10 @@
<div class="sidebar-header">
<h1 class="app-name">SoulSync</h1>
<p class="app-subtitle">Music Sync & Manager</p>
<div id="profile-indicator" class="profile-indicator" style="display: none;" title="Switch profile">
<div id="profile-indicator-avatar" class="profile-indicator-avatar"></div>
<span id="profile-indicator-name" class="profile-indicator-name"></span>
</div>
</div>
<!-- Navigation Section -->

View file

@ -2151,4 +2151,25 @@
gap: 4px;
align-items: flex-start;
}
/* Profile Picker - Mobile */
.profile-picker-grid {
gap: 20px;
}
.profile-avatar {
width: 64px;
height: 64px;
font-size: 26px;
}
.profile-manage-content {
width: calc(100vw - 32px);
max-width: 400px;
}
.profile-pin-content {
width: calc(100vw - 32px);
max-width: 340px;
}
}

View file

@ -147,6 +147,10 @@ function initializeWebSocket() {
console.log('WebSocket connected');
socketConnected = true;
resubscribeDownloadBatches();
// Join profile room for scoped watchlist/wishlist count updates
if (currentProfile) {
socket.emit('profile:join', { profile_id: currentProfile.id });
}
});
socket.on('disconnect', (reason) => {
@ -156,6 +160,10 @@ function initializeWebSocket() {
socket.on('reconnect', (attemptNumber) => {
console.log(`WebSocket reconnected after ${attemptNumber} attempts`);
// Rejoin profile room for scoped WebSocket emits
if (currentProfile) {
socket.emit('profile:join', { profile_id: currentProfile.id });
}
// Phase 1: Full state refresh on reconnect
fetchAndUpdateServiceStatus();
updateWatchlistButtonCount();
@ -637,9 +645,571 @@ function initAccentColorListeners() {
if (saved) applyAccentColor(saved);
})();
document.addEventListener('DOMContentLoaded', function () {
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
function renderProfileAvatar(el, profile) {
// Renders avatar as image (if avatar_url set) or colored initial fallback
// Preserves existing classes, ensures 'profile-avatar' is present
if (!el.classList.contains('profile-avatar') && !el.classList.contains('profile-indicator-avatar') && !el.classList.contains('profile-pin-avatar')) {
el.className = 'profile-avatar';
}
el.style.background = profile.avatar_color || '#6366f1';
el.textContent = '';
if (profile.avatar_url) {
const img = document.createElement('img');
img.src = profile.avatar_url;
img.alt = profile.name;
img.className = 'profile-avatar-img';
img.onerror = () => {
img.remove();
el.textContent = profile.name.charAt(0).toUpperCase();
};
el.appendChild(img);
} else {
el.textContent = profile.name.charAt(0).toUpperCase();
}
}
async function initProfileSystem() {
try {
// Check if a session already has a profile selected
const currentRes = await fetch('/api/profiles/current');
const currentData = await currentRes.json();
if (currentData.success && currentData.profile) {
currentProfile = currentData.profile;
updateProfileIndicator();
return true; // Profile already selected, skip picker
}
// Fetch all profiles
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
if (profiles.length === 0) {
// No profiles yet — auto-select admin profile 1
await selectProfile(1);
return true;
}
if (profiles.length === 1) {
// Only one profile — always auto-select (PIN only matters with multiple profiles)
await selectProfile(profiles[0].id);
return true;
}
// Multiple profiles or PIN required — show picker
showProfilePicker(profiles);
return false; // App init deferred until profile selected
} catch (e) {
console.error('Profile init error:', e);
return true; // Fall through to normal init
}
}
function showProfilePicker(profiles, canCancel = false) {
const overlay = document.getElementById('profile-picker-overlay');
const grid = document.getElementById('profile-picker-grid');
const actions = document.getElementById('profile-picker-actions');
grid.innerHTML = '';
profiles.forEach(p => {
const card = document.createElement('div');
card.className = 'profile-picker-card';
const avatarEl = document.createElement('div');
renderProfileAvatar(avatarEl, p);
card.appendChild(avatarEl);
const nameEl = document.createElement('span');
nameEl.className = 'profile-name';
nameEl.textContent = p.name;
card.appendChild(nameEl);
if (p.is_admin) {
const badge = document.createElement('span');
badge.className = 'profile-badge';
badge.textContent = 'Admin';
card.appendChild(badge);
}
card.onclick = () => handleProfileClick(p);
grid.appendChild(card);
});
// Show manage button only if current profile is admin (not on first load before selection)
const isAdmin = currentProfile ? currentProfile.is_admin : false;
if (isAdmin) {
actions.style.display = '';
} else {
actions.style.display = 'none';
}
// Show/remove cancel button when opened from sidebar indicator
let cancelBtn = overlay.querySelector('.profile-picker-cancel');
if (cancelBtn) cancelBtn.remove();
if (canCancel) {
cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => hideProfilePicker();
actions.parentElement.appendChild(cancelBtn);
}
overlay.style.display = 'flex';
document.querySelector('.main-container').style.display = 'none';
}
async function handleProfileClick(profile) {
// Fetch profile count — PIN only matters with multiple profiles
let profileCount = 1;
try {
const r = await fetch('/api/profiles');
const d = await r.json();
profileCount = (d.profiles || []).length;
} catch (e) {}
if (profile.has_pin && profileCount > 1) {
showPinDialog(profile);
} else {
const wasSwitching = !!currentProfile;
await selectProfile(profile.id);
if (wasSwitching) {
window.location.reload();
return;
}
hideProfilePicker();
initApp();
}
}
function showPinDialog(profile) {
const dialog = document.getElementById('profile-pin-dialog');
const avatar = document.getElementById('profile-pin-avatar');
const nameEl = document.getElementById('profile-pin-name');
const input = document.getElementById('profile-pin-input');
const errorEl = document.getElementById('profile-pin-error');
renderProfileAvatar(avatar, profile);
nameEl.textContent = profile.name;
input.value = '';
errorEl.style.display = 'none';
dialog.style.display = 'flex';
setTimeout(() => input.focus(), 100);
const submit = document.getElementById('profile-pin-submit');
const cancel = document.getElementById('profile-pin-cancel');
const wasSwitching = !!currentProfile;
const handleSubmit = async () => {
const pin = input.value;
if (!pin) return;
try {
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, pin })
});
const data = await res.json();
if (data.success) {
cleanup();
if (wasSwitching) {
window.location.reload();
return;
}
currentProfile = data.profile;
dialog.style.display = 'none';
hideProfilePicker();
updateProfileIndicator();
initApp();
return;
} else {
errorEl.textContent = data.error || 'Invalid PIN';
errorEl.style.display = '';
input.value = '';
input.focus();
}
} catch (e) {
errorEl.textContent = 'Connection error';
errorEl.style.display = '';
}
cleanup();
};
const handleCancel = () => {
dialog.style.display = 'none';
cleanup();
};
const handleKeydown = (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
};
const cleanup = () => {
submit.removeEventListener('click', handleSubmit);
cancel.removeEventListener('click', handleCancel);
input.removeEventListener('keydown', handleKeydown);
};
submit.addEventListener('click', handleSubmit);
cancel.addEventListener('click', handleCancel);
input.addEventListener('keydown', handleKeydown);
}
async function selectProfile(profileId) {
try {
const oldProfileId = currentProfile ? currentProfile.id : null;
const res = await fetch('/api/profiles/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileId })
});
const data = await res.json();
if (data.success) {
currentProfile = data.profile;
updateProfileIndicator();
// Join profile-scoped WebSocket room for watchlist/wishlist count updates
if (socket && socket.connected) {
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
}
}
return data.success;
} catch (e) {
console.error('Error selecting profile:', e);
return false;
}
}
function hideProfilePicker() {
document.getElementById('profile-picker-overlay').style.display = 'none';
document.querySelector('.main-container').style.display = 'flex';
}
function updateProfileIndicator() {
const indicator = document.getElementById('profile-indicator');
if (!currentProfile || !indicator) return;
const avatar = document.getElementById('profile-indicator-avatar');
const name = document.getElementById('profile-indicator-name');
renderProfileAvatar(avatar, currentProfile);
name.textContent = currentProfile.name;
indicator.style.display = 'flex';
indicator.onclick = async () => {
const res = await fetch('/api/profiles');
const data = await res.json();
if (data.profiles && data.profiles.length > 0) {
showProfilePicker(data.profiles, true);
}
};
// Hide settings nav for non-admin
const settingsBtn = document.querySelector('.nav-button[data-page="settings"]');
if (settingsBtn && !currentProfile.is_admin) {
settingsBtn.style.display = 'none';
} else if (settingsBtn) {
settingsBtn.style.display = '';
}
}
function initProfileManagement() {
const manageBtn = document.getElementById('manage-profiles-btn');
const closeBtn = document.getElementById('profile-manage-close');
const createBtn = document.getElementById('create-profile-btn');
const adminPinBtn = document.getElementById('set-admin-pin-btn');
if (manageBtn) {
manageBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'flex';
loadProfileManageList();
};
}
if (closeBtn) {
closeBtn.onclick = () => {
document.getElementById('profile-manage-panel').style.display = 'none';
// Refresh picker — keep cancel button if user already has a profile selected
const hasCancel = !!currentProfile;
fetch('/api/profiles').then(r => r.json()).then(d => {
showProfilePicker(d.profiles || [], hasCancel);
});
};
}
// Color picker
let selectedColor = '#6366f1';
document.querySelectorAll('.profile-color-swatch').forEach(swatch => {
swatch.onclick = () => {
document.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
selectedColor = swatch.dataset.color;
};
});
// Select first by default
const firstSwatch = document.querySelector('.profile-color-swatch');
if (firstSwatch) firstSwatch.classList.add('selected');
if (createBtn) {
createBtn.onclick = async () => {
const name = document.getElementById('new-profile-name').value.trim();
const avatarUrl = document.getElementById('new-profile-avatar-url').value.trim();
const pin = document.getElementById('new-profile-pin').value;
if (!name) return;
const res = await fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, avatar_color: selectedColor, avatar_url: avatarUrl || undefined, pin: pin || undefined })
});
const data = await res.json();
if (data.success) {
document.getElementById('new-profile-name').value = '';
document.getElementById('new-profile-avatar-url').value = '';
document.getElementById('new-profile-pin').value = '';
loadProfileManageList();
// Show admin PIN section if >1 profiles and admin has no PIN
checkAdminPinRequired();
} else {
alert(data.error || 'Failed to create profile');
}
};
}
if (adminPinBtn) {
adminPinBtn.onclick = async () => {
const pin = document.getElementById('admin-pin-input').value;
if (!pin || pin.length < 1) return;
// Find admin profile
const res = await fetch('/api/profiles');
const data = await res.json();
const admin = (data.profiles || []).find(p => p.is_admin);
if (!admin) return;
try {
const pinRes = await fetch(`/api/profiles/${admin.id}/set-pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin })
});
const pinData = await pinRes.json();
if (!pinData.success) {
alert(pinData.error || 'Failed to set PIN');
return;
}
} catch (e) {
alert('Connection error');
return;
}
document.getElementById('admin-pin-input').value = '';
document.getElementById('admin-pin-section').style.display = 'none';
loadProfileManageList();
};
}
}
async function loadProfileManageList() {
const list = document.getElementById('profile-manage-list');
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
list.innerHTML = '';
profiles.forEach(p => {
const item = document.createElement('div');
item.className = 'profile-manage-item';
const av = document.createElement('div');
renderProfileAvatar(av, p);
item.appendChild(av);
const info = document.createElement('div');
info.className = 'profile-info';
const nameDiv = document.createElement('div');
nameDiv.className = 'name';
nameDiv.textContent = p.name + (p.has_pin ? ' 🔒' : '');
info.appendChild(nameDiv);
if (p.is_admin) {
const roleDiv = document.createElement('div');
roleDiv.className = 'role';
roleDiv.textContent = 'Admin';
info.appendChild(roleDiv);
}
item.appendChild(info);
const actions = document.createElement('div');
actions.className = 'profile-manage-actions';
const editBtn = document.createElement('button');
editBtn.className = 'profile-edit-btn';
editBtn.dataset.id = p.id;
editBtn.dataset.name = p.name;
editBtn.dataset.color = p.avatar_color || '#6366f1';
editBtn.dataset.avatarUrl = p.avatar_url || '';
editBtn.title = 'Edit profile';
editBtn.textContent = '✏️';
actions.appendChild(editBtn);
if (!p.is_admin) {
const delBtn = document.createElement('button');
delBtn.className = 'profile-delete-btn';
delBtn.dataset.id = p.id;
delBtn.title = 'Delete profile';
delBtn.textContent = '🗑️';
actions.appendChild(delBtn);
}
item.appendChild(actions);
list.appendChild(item);
});
// Bind edit buttons
list.querySelectorAll('.profile-edit-btn').forEach(btn => {
btn.onclick = () => {
showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl);
};
});
// Bind delete buttons
list.querySelectorAll('.profile-delete-btn').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Delete this profile and all its data?')) return;
try {
const res = await fetch(`/api/profiles/${btn.dataset.id}`, { method: 'DELETE' });
const data = await res.json();
if (!data.success) {
alert(data.error || 'Failed to delete profile');
}
} catch (e) {
alert('Connection error');
}
loadProfileManageList();
};
});
checkAdminPinRequired();
}
function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl) {
const list = document.getElementById('profile-manage-list');
// Remove any existing edit form
const existing = document.getElementById('profile-edit-form');
if (existing) existing.remove();
const editColors = ['#6366f1','#ec4899','#10b981','#f59e0b','#3b82f6','#ef4444','#8b5cf6','#14b8a6'];
const form = document.createElement('div');
form.id = 'profile-edit-form';
form.className = 'profile-edit-form';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'profile-input';
nameInput.value = currentName;
nameInput.maxLength = 20;
nameInput.placeholder = 'Profile name';
form.appendChild(nameInput);
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.className = 'profile-input';
urlInput.value = currentAvatarUrl || '';
urlInput.placeholder = 'Avatar image URL (optional)';
form.appendChild(urlInput);
const colorRow = document.createElement('div');
colorRow.className = 'profile-color-picker';
let editColor = currentColor;
editColors.forEach(c => {
const swatch = document.createElement('span');
swatch.className = 'profile-color-swatch' + (c === currentColor ? ' selected' : '');
swatch.style.background = c;
swatch.dataset.color = c;
swatch.onclick = () => {
colorRow.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
editColor = c;
};
colorRow.appendChild(swatch);
});
form.appendChild(colorRow);
const btnRow = document.createElement('div');
btnRow.className = 'profile-edit-buttons';
const saveBtn = document.createElement('button');
saveBtn.className = 'profile-create-btn';
saveBtn.textContent = 'Save';
saveBtn.onclick = async () => {
const newName = nameInput.value.trim();
if (!newName) { alert('Name cannot be empty'); return; }
const newAvatarUrl = urlInput.value.trim() || null;
try {
const res = await fetch(`/api/profiles/${profileId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, avatar_color: editColor, avatar_url: newAvatarUrl })
});
const data = await res.json();
if (data.success) {
// Update sidebar indicator if editing current profile
if (currentProfile && currentProfile.id == profileId) {
currentProfile.name = newName;
currentProfile.avatar_color = editColor;
currentProfile.avatar_url = newAvatarUrl;
updateProfileIndicator();
}
loadProfileManageList();
} else {
alert(data.error || 'Failed to update profile');
}
} catch (e) {
alert('Connection error');
}
};
btnRow.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'profile-picker-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => form.remove();
btnRow.appendChild(cancelBtn);
form.appendChild(btnRow);
list.appendChild(form);
nameInput.focus();
nameInput.select();
}
async function checkAdminPinRequired() {
const res = await fetch('/api/profiles');
const data = await res.json();
const profiles = data.profiles || [];
const admin = profiles.find(p => p.is_admin);
const section = document.getElementById('admin-pin-section');
if (profiles.length > 1 && admin && !admin.has_pin && section) {
section.style.display = '';
} else if (section) {
section.style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', async function () {
console.log('SoulSync WebUI initializing...');
// Initialize profile management UI handlers
initProfileManagement();
// Check profiles first — may show picker instead of app
const profileReady = await initProfileSystem();
if (!profileReady) {
console.log('Waiting for profile selection...');
return; // App init deferred until profile is selected via picker
}
initApp();
});
function initApp() {
// Initialize components
initializeNavigation();
initializeMobileNavigation();
@ -702,7 +1272,7 @@ document.addEventListener('DOMContentLoaded', function () {
});
console.log('SoulSync WebUI initialized successfully!');
});
}
// ===============================
// NAVIGATION SYSTEM

View file

@ -94,7 +94,7 @@ body {
/* Sidebar Header */
.sidebar-header {
height: 95px;
min-height: 115px;
background: linear-gradient(180deg,
rgba(var(--accent-rgb), 0.14) 0%,
rgba(var(--accent-rgb), 0.08) 30%,
@ -102,11 +102,11 @@ body {
transparent 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-top-right-radius: 20px;
padding: 28px 24px;
padding: 20px 24px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
gap: 8px;
position: relative;
overflow: hidden;
@ -27250,4 +27250,477 @@ body {
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* ── Profile Picker ─────────────────────────────────────────────── */
.profile-picker-overlay {
position: fixed;
inset: 0;
z-index: 99999;
background: linear-gradient(135deg, #0a0a1a 0%, #111827 50%, #0a0a1a 100%);
display: flex;
align-items: center;
justify-content: center;
animation: profileFadeIn 0.4s ease;
}
@keyframes profileFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.profile-picker-container {
text-align: center;
max-width: 700px;
width: 100%;
padding: 40px 20px;
}
.profile-picker-title {
font-size: 28px;
font-weight: 600;
color: #e5e7eb;
margin-bottom: 40px;
letter-spacing: -0.02em;
}
.profile-picker-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 32px;
margin-bottom: 40px;
}
.profile-picker-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
transition: transform 0.2s ease;
min-width: 100px;
}
.profile-picker-card:hover {
transform: scale(1.08);
}
.profile-picker-card:hover .profile-avatar {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.3);
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
color: #fff;
transition: box-shadow 0.2s ease;
user-select: none;
overflow: hidden;
position: relative;
}
.profile-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.profile-picker-card .profile-name {
font-size: 14px;
color: #9ca3af;
font-weight: 500;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-picker-card .profile-badge {
font-size: 10px;
color: #6366f1;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.profile-picker-actions {
margin-top: 16px;
}
.profile-manage-btn {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
padding: 10px 24px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
.profile-manage-btn:hover {
border-color: rgba(255, 255, 255, 0.3);
color: #e5e7eb;
}
.profile-picker-cancel {
background: transparent;
border: none;
color: #6b7280;
padding: 10px 24px;
font-size: 13px;
cursor: pointer;
margin-top: 8px;
transition: color 0.2s ease;
}
.profile-picker-cancel:hover {
color: #e5e7eb;
}
/* PIN Dialog */
.profile-pin-dialog {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
}
.profile-pin-content {
background: #1f2937;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 32px;
text-align: center;
min-width: 300px;
}
.profile-pin-avatar {
width: 60px;
height: 60px;
border-radius: 10px;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
color: #fff;
overflow: hidden;
position: relative;
}
.profile-pin-name {
color: #e5e7eb;
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
}
.profile-pin-input {
width: 160px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #e5e7eb;
font-size: 18px;
text-align: center;
letter-spacing: 8px;
outline: none;
}
.profile-pin-input:focus {
border-color: #6366f1;
}
.profile-pin-buttons {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
.profile-pin-cancel, .profile-pin-submit {
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
border: none;
}
.profile-pin-cancel {
background: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
.profile-pin-submit {
background: #6366f1;
color: #fff;
}
.profile-pin-error {
color: #ef4444;
font-size: 13px;
margin-top: 12px;
}
/* Profile Management Panel */
.profile-manage-panel {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
}
.profile-manage-content {
background: #1f2937;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 28px;
width: 420px;
max-height: 80vh;
overflow-y: auto;
}
.profile-manage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.profile-manage-header h3 {
color: #e5e7eb;
font-size: 18px;
margin: 0;
}
.profile-manage-close-btn {
background: none;
border: none;
color: #9ca3af;
font-size: 24px;
cursor: pointer;
}
.profile-manage-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 24px;
}
.profile-manage-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.profile-manage-item .profile-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 16px;
}
.profile-manage-item .profile-info {
flex: 1;
}
.profile-manage-item .profile-info .name {
color: #e5e7eb;
font-size: 14px;
font-weight: 500;
}
.profile-manage-item .profile-info .role {
color: #6366f1;
font-size: 11px;
text-transform: uppercase;
}
.profile-manage-actions {
display: flex;
gap: 4px;
align-items: center;
}
.profile-manage-item .profile-edit-btn,
.profile-manage-item .profile-delete-btn {
background: none;
border: none;
color: #6b7280;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.profile-manage-item .profile-edit-btn:hover {
color: #6366f1;
background: rgba(99, 102, 241, 0.1);
}
.profile-manage-item .profile-delete-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.profile-edit-form {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background: rgba(99, 102, 241, 0.05);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 8px;
margin-top: 4px;
}
.profile-edit-buttons {
display: flex;
gap: 8px;
}
.profile-edit-buttons .profile-create-btn {
flex: 1;
}
.profile-edit-buttons .profile-picker-cancel {
flex: 1;
}
.profile-manage-add h4, .admin-pin-section h4 {
color: #e5e7eb;
font-size: 14px;
margin: 0 0 12px;
}
.profile-input {
width: 100%;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #e5e7eb;
font-size: 14px;
margin-bottom: 10px;
outline: none;
box-sizing: border-box;
}
.profile-input:focus {
border-color: #6366f1;
}
.profile-color-picker {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.profile-color-swatch {
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.2s, transform 0.15s;
}
.profile-color-swatch:hover {
transform: scale(1.15);
}
.profile-color-swatch.selected {
border-color: #fff;
}
.profile-create-btn {
width: 100%;
padding: 10px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.profile-create-btn:hover {
background: #4f46e5;
}
.admin-pin-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-pin-note {
color: #9ca3af;
font-size: 12px;
margin: 0 0 10px;
}
/* Profile Indicator in Sidebar */
.profile-indicator {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
margin-top: 4px;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s;
}
.profile-indicator:hover {
background: rgba(255, 255, 255, 0.08);
}
.profile-indicator-avatar {
width: 22px;
height: 22px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.profile-indicator-name {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}