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:
parent
84de4ad16b
commit
4fee005dee
8 changed files with 2514 additions and 375 deletions
|
|
@ -686,13 +686,14 @@ class WatchlistScanner:
|
||||||
# Check if we have fresh similar artists cached (< 30 days old)
|
# Check if we have fresh similar artists cached (< 30 days old)
|
||||||
# If Spotify is authenticated, also require Spotify IDs to be present
|
# If Spotify is authenticated, also require Spotify IDs to be present
|
||||||
spotify_authenticated = self.spotify_client and self.spotify_client.is_spotify_authenticated()
|
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")
|
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)
|
# 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:
|
else:
|
||||||
logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...")
|
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}")
|
logger.info(f"Similar artists updated for {watchlist_artist.artist_name}")
|
||||||
except Exception as similar_error:
|
except Exception as similar_error:
|
||||||
logger.warning(f"Failed to update similar artists for {watchlist_artist.artist_name}: {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
|
'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(
|
success = self.database.add_to_wishlist(
|
||||||
spotify_track_data=spotify_track_data,
|
spotify_track_data=spotify_track_data,
|
||||||
failure_reason="Missing from library (found by watchlist scan)",
|
failure_reason="Missing from library (found by watchlist scan)",
|
||||||
|
|
@ -1193,7 +1194,8 @@ class WatchlistScanner:
|
||||||
'watchlist_artist_id': watchlist_artist.spotify_artist_id,
|
'watchlist_artist_id': watchlist_artist.spotify_artist_id,
|
||||||
'album_name': album_name,
|
'album_name': album_name,
|
||||||
'scan_timestamp': datetime.now().isoformat()
|
'scan_timestamp': datetime.now().isoformat()
|
||||||
}
|
},
|
||||||
|
profile_id=getattr(watchlist_artist, 'profile_id', 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|
@ -1407,20 +1409,21 @@ class WatchlistScanner:
|
||||||
logger.error(f"Error fetching similar artists from MusicMap: {e}")
|
logger.error(f"Error fetching similar artists from MusicMap: {e}")
|
||||||
return []
|
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.
|
Backfill missing iTunes IDs for cached similar artists.
|
||||||
This ensures seamless dual-source support without clearing cached data.
|
This ensures seamless dual-source support without clearing cached data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_artist_id: The source artist ID to backfill similar artists for
|
source_artist_id: The source artist ID to backfill similar artists for
|
||||||
|
profile_id: Profile to scope the backfill to
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of similar artists updated with iTunes IDs
|
Number of similar artists updated with iTunes IDs
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get similar artists that are missing iTunes IDs
|
# 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:
|
if not similar_artists:
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1455,7 +1458,7 @@ class WatchlistScanner:
|
||||||
logger.error(f"Error backfilling similar artists iTunes IDs: {e}")
|
logger.error(f"Error backfilling similar artists iTunes IDs: {e}")
|
||||||
return 0
|
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.
|
Fetch and store similar artists for a watchlist artist.
|
||||||
Called after each artist scan to build discovery pool.
|
Called after each artist scan to build discovery pool.
|
||||||
|
|
@ -1486,7 +1489,8 @@ class WatchlistScanner:
|
||||||
similar_artist_name=similar_artist['name'],
|
similar_artist_name=similar_artist['name'],
|
||||||
similar_artist_spotify_id=similar_artist.get('spotify_id'),
|
similar_artist_spotify_id=similar_artist.get('spotify_id'),
|
||||||
similar_artist_itunes_id=similar_artist.get('itunes_id'),
|
similar_artist_itunes_id=similar_artist.get('itunes_id'),
|
||||||
similarity_rank=rank
|
similarity_rank=rank,
|
||||||
|
profile_id=profile_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|
@ -1504,7 +1508,7 @@ class WatchlistScanner:
|
||||||
logger.error(f"Error fetching similar artists for {watchlist_artist.artist_name}: {e}")
|
logger.error(f"Error fetching similar artists for {watchlist_artist.artist_name}: {e}")
|
||||||
return False
|
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.
|
Populate discovery pool with tracks from top similar artists.
|
||||||
Called after watchlist scan completes.
|
Called after watchlist scan completes.
|
||||||
|
|
@ -1520,14 +1524,14 @@ class WatchlistScanner:
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# Check if we should run discovery pool population (prevents over-polling)
|
# 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:
|
if skip_pool_population:
|
||||||
logger.info("Discovery pool was populated recently (< 24 hours ago). Skipping 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...")
|
logger.info("But still refreshing recent albums cache and curated playlists...")
|
||||||
# Still run these even when skipping main pool population
|
# Still run these even when skipping main pool population
|
||||||
self.cache_discovery_recent_albums()
|
self.cache_discovery_recent_albums(profile_id=profile_id)
|
||||||
self.curate_discovery_playlists()
|
self.curate_discovery_playlists(profile_id=profile_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Populating discovery pool from similar artists...")
|
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}")
|
logger.info(f"Sources available - Spotify: {spotify_available}, iTunes: {itunes_available}")
|
||||||
|
|
||||||
# Get top similar artists across all watchlist (ordered by occurrence_count)
|
# 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)
|
similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit, profile_id=profile_id)
|
||||||
|
|
||||||
if not similar_artists:
|
if not similar_artists:
|
||||||
logger.info("No similar artists found to populate discovery pool from 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...")
|
logger.info("But still caching recent albums from watchlist artists and curating playlists...")
|
||||||
# Still run these even without similar artists - they use watchlist artists
|
# Still run these even without similar artists - they use watchlist artists
|
||||||
self.cache_discovery_recent_albums()
|
self.cache_discovery_recent_albums(profile_id=profile_id)
|
||||||
self.curate_discovery_playlists()
|
self.curate_discovery_playlists(profile_id=profile_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"Processing {len(similar_artists)} top similar artists for discovery pool")
|
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_album_id'] = album_data.get('id')
|
||||||
track_data['itunes_artist_id'] = similar_artist.similar_artist_itunes_id
|
track_data['itunes_artist_id'] = similar_artist.similar_artist_itunes_id
|
||||||
|
|
||||||
# Add to discovery pool with source
|
# Add to discovery pool with source (scoped to profile)
|
||||||
if self.database.add_to_discovery_pool(track_data, source=source):
|
if self.database.add_to_discovery_pool(track_data, source=source, profile_id=profile_id):
|
||||||
total_tracks_added += 1
|
total_tracks_added += 1
|
||||||
|
|
||||||
except Exception as track_error:
|
except Exception as track_error:
|
||||||
|
|
@ -1887,7 +1891,7 @@ class WatchlistScanner:
|
||||||
track_data['itunes_album_id'] = album_data.get('id')
|
track_data['itunes_album_id'] = album_data.get('id')
|
||||||
track_data['itunes_artist_id'] = artist_id_for_genres or ''
|
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
|
total_tracks_added += 1
|
||||||
except Exception as track_error:
|
except Exception as track_error:
|
||||||
continue
|
continue
|
||||||
|
|
@ -1918,23 +1922,23 @@ class WatchlistScanner:
|
||||||
final_count = cursor.fetchone()['count']
|
final_count = cursor.fetchone()['count']
|
||||||
|
|
||||||
# Update timestamp to mark when pool was last populated
|
# 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)")
|
logger.info(f"Discovery pool now contains {final_count} total tracks (built over time)")
|
||||||
|
|
||||||
# Cache recent albums for discovery page
|
# Cache recent albums for discovery page
|
||||||
logger.info("Caching 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
|
# Curate playlists for consistent daily experience
|
||||||
logger.info("Curating discovery playlists...")
|
logger.info("Curating discovery playlists...")
|
||||||
self.curate_discovery_playlists()
|
self.curate_discovery_playlists(profile_id=profile_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error populating discovery pool: {e}")
|
logger.error(f"Error populating discovery pool: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
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.
|
Lightweight incremental update for discovery pool - runs every 6 hours.
|
||||||
|
|
||||||
|
|
@ -1948,13 +1952,13 @@ class WatchlistScanner:
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
# Check if we should run (prevents over-polling Spotify)
|
# 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.")
|
logger.info("Discovery pool was updated recently (< 6 hours ago). Skipping incremental update.")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Starting incremental discovery pool update (watchlist artists only)...")
|
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:
|
if not watchlist_artists:
|
||||||
logger.info("No watchlist artists to check for incremental update")
|
logger.info("No watchlist artists to check for incremental update")
|
||||||
return
|
return
|
||||||
|
|
@ -2042,7 +2046,7 @@ class WatchlistScanner:
|
||||||
'artist_genres': artist_genres
|
'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
|
total_tracks_added += 1
|
||||||
|
|
||||||
except Exception as track_error:
|
except Exception as track_error:
|
||||||
|
|
@ -2071,7 +2075,7 @@ class WatchlistScanner:
|
||||||
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool")
|
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool")
|
||||||
current_count = cursor.fetchone()['count']
|
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")
|
logger.info(f"Discovery pool now contains {current_count} total tracks")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2079,7 +2083,7 @@ class WatchlistScanner:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
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.
|
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...")
|
logger.info("Caching recent albums for discover page...")
|
||||||
|
|
||||||
# Clear existing cache
|
# Clear existing cache for this profile
|
||||||
self.database.clear_discovery_recent_albums()
|
self.database.clear_discovery_recent_albums(profile_id=profile_id)
|
||||||
|
|
||||||
# 30-day window for recent releases
|
# 30-day window for recent releases
|
||||||
cutoff_date = datetime.now() - timedelta(days=30)
|
cutoff_date = datetime.now() - timedelta(days=30)
|
||||||
|
|
@ -2106,9 +2110,9 @@ class WatchlistScanner:
|
||||||
from core.itunes_client import iTunesClient
|
from core.itunes_client import iTunesClient
|
||||||
itunes_client = iTunesClient()
|
itunes_client = iTunesClient()
|
||||||
|
|
||||||
# Get artists to check
|
# Get artists to check (scoped to profile)
|
||||||
watchlist_artists = self.database.get_watchlist_artists()
|
watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id)
|
||||||
similar_artists = self.database.get_top_similar_artists(limit=50)
|
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"Checking albums from {len(watchlist_artists)} watchlist + {len(similar_artists)} similar artists")
|
||||||
logger.info(f"Sources: Spotify={spotify_available}, iTunes=True")
|
logger.info(f"Sources: Spotify={spotify_available}, iTunes=True")
|
||||||
|
|
@ -2141,7 +2145,7 @@ class WatchlistScanner:
|
||||||
'release_date': release_str[:10],
|
'release_date': release_str[:10],
|
||||||
'album_type': album.album_type if hasattr(album, 'album_type') else 'album'
|
'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
|
cached_count[source] += 1
|
||||||
logger.debug(f"Cached [{source}] recent album: {album.name} by {artist_name} ({release_str})")
|
logger.debug(f"Cached [{source}] recent album: {album.name} by {artist_name} ({release_str})")
|
||||||
return True
|
return True
|
||||||
|
|
@ -2256,7 +2260,7 @@ class WatchlistScanner:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
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.
|
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}...")
|
logger.info(f"Curating Release Radar for {source}...")
|
||||||
|
|
||||||
# 1. Curate Release Radar - 50 tracks from recent albums
|
# 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 = []
|
release_radar_tracks = []
|
||||||
|
|
||||||
if not recent_albums:
|
if not recent_albums:
|
||||||
|
|
@ -2405,18 +2409,18 @@ class WatchlistScanner:
|
||||||
formatted_track['itunes_track_id'] = track_data['id']
|
formatted_track['itunes_track_id'] = track_data['id']
|
||||||
formatted_track['itunes_album_id'] = track_data['album'].get('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:
|
except Exception as e:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Save with source suffix for multi-source support
|
# Save with source suffix for multi-source support
|
||||||
playlist_key = f'release_radar_{source}'
|
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")
|
logger.info(f"Release Radar ({source}) curated: {len(release_radar_tracks)} tracks")
|
||||||
|
|
||||||
# 2. Curate Discovery Weekly - 50 tracks from discovery pool
|
# 2. Curate Discovery Weekly - 50 tracks from discovery pool
|
||||||
logger.info(f"Curating Discovery Weekly for {source}...")
|
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:
|
if not discovery_tracks:
|
||||||
logger.warning(f"[{source.upper()}] No discovery pool tracks found for Discovery Weekly - check populate_discovery_pool()")
|
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)
|
discovery_weekly_tracks.append(track.itunes_track_id)
|
||||||
|
|
||||||
playlist_key = f'discovery_weekly_{source}'
|
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")
|
logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks")
|
||||||
|
|
||||||
# Also save without suffix for backward compatibility (use active source)
|
# Also save without suffix for backward compatibility (use active source)
|
||||||
|
|
@ -2467,10 +2471,10 @@ class WatchlistScanner:
|
||||||
discovery_weekly_key = f'discovery_weekly_{active_source}'
|
discovery_weekly_key = f'discovery_weekly_{active_source}'
|
||||||
|
|
||||||
# Copy active source playlists to non-suffixed keys
|
# Copy active source playlists to non-suffixed keys
|
||||||
release_radar_ids = self.database.get_curated_playlist(release_radar_key) or []
|
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) 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)
|
self.database.save_curated_playlist('release_radar', release_radar_ids, profile_id=profile_id)
|
||||||
self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids)
|
self.database.save_curated_playlist('discovery_weekly', discovery_weekly_ids, profile_id=profile_id)
|
||||||
|
|
||||||
logger.info("Playlist curation complete")
|
logger.info("Playlist curation complete")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ class WishlistService:
|
||||||
self._database = get_database(self.database_path)
|
self._database = get_database(self.database_path)
|
||||||
return self._database
|
return self._database
|
||||||
|
|
||||||
def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown",
|
def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown",
|
||||||
source_context: Dict[str, Any] = None) -> bool:
|
source_context: Dict[str, Any] = None, profile_id: int = 1) -> bool:
|
||||||
"""
|
"""
|
||||||
Add a failed track from a download modal to the wishlist.
|
Add a failed track from a download modal to the wishlist.
|
||||||
|
|
||||||
|
|
@ -74,7 +74,8 @@ class WishlistService:
|
||||||
spotify_track_data=spotify_track,
|
spotify_track_data=spotify_track,
|
||||||
failure_reason=failure_reason,
|
failure_reason=failure_reason,
|
||||||
source_type=source_type,
|
source_type=source_type,
|
||||||
source_info=source_info
|
source_info=source_info,
|
||||||
|
profile_id=profile_id
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -82,30 +83,33 @@ class WishlistService:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def add_spotify_track_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str,
|
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.
|
Directly add a Spotify track to the wishlist.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
spotify_track_data: Full Spotify track data dictionary
|
spotify_track_data: Full Spotify track data dictionary
|
||||||
failure_reason: Reason for the failure
|
failure_reason: Reason for the failure
|
||||||
source_type: Source type ('playlist', 'album', 'manual')
|
source_type: Source type ('playlist', 'album', 'manual')
|
||||||
source_context: Additional context information
|
source_context: Additional context information
|
||||||
|
profile_id: Profile to add to
|
||||||
"""
|
"""
|
||||||
return self.database.add_to_wishlist(
|
return self.database.add_to_wishlist(
|
||||||
spotify_track_data=spotify_track_data,
|
spotify_track_data=spotify_track_data,
|
||||||
failure_reason=failure_reason,
|
failure_reason=failure_reason,
|
||||||
source_type=source_type,
|
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.
|
Get wishlist tracks formatted for the download modal.
|
||||||
Returns tracks in a format similar to playlist tracks for compatibility.
|
Returns tracks in a format similar to playlist tracks for compatibility.
|
||||||
"""
|
"""
|
||||||
try:
|
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 = []
|
formatted_tracks = []
|
||||||
|
|
||||||
for wishlist_track in wishlist_tracks:
|
for wishlist_track in wishlist_tracks:
|
||||||
|
|
@ -144,28 +148,29 @@ class WishlistService:
|
||||||
logger.error(f"Error getting wishlist tracks for download: {e}")
|
logger.error(f"Error getting wishlist tracks for download: {e}")
|
||||||
return []
|
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.
|
Mark the result of a download attempt for a wishlist track.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
spotify_track_id: Spotify track ID
|
spotify_track_id: Spotify track ID
|
||||||
success: Whether the download was successful
|
success: Whether the download was successful
|
||||||
error_message: Error message if failed
|
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)"""
|
"""Remove a track from the wishlist (typically after successful download)"""
|
||||||
return self.database.remove_from_wishlist(spotify_track_id)
|
return self.database.remove_from_wishlist(spotify_track_id, profile_id=profile_id)
|
||||||
|
|
||||||
def get_wishlist_count(self) -> int:
|
def get_wishlist_count(self, profile_id: int = 1) -> int:
|
||||||
"""Get the total number of tracks in the wishlist"""
|
"""Get the total number of tracks in the wishlist"""
|
||||||
return self.database.get_wishlist_count()
|
return self.database.get_wishlist_count(profile_id=profile_id)
|
||||||
|
|
||||||
def clear_wishlist(self) -> bool:
|
def clear_wishlist(self, profile_id: int = 1) -> bool:
|
||||||
"""Clear all tracks from the wishlist"""
|
"""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:
|
def check_track_in_wishlist(self, spotify_track_id: str) -> bool:
|
||||||
"""Check if a track exists in the wishlist by Spotify track ID"""
|
"""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}")
|
logger.error(f"Error finding matching wishlist track: {e}")
|
||||||
return None
|
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"""
|
"""Get a summary of the wishlist for dashboard display"""
|
||||||
try:
|
try:
|
||||||
total_tracks = self.get_wishlist_count()
|
total_tracks = self.get_wishlist_count(profile_id=profile_id)
|
||||||
|
|
||||||
if total_tracks == 0:
|
if total_tracks == 0:
|
||||||
return {
|
return {
|
||||||
'total_tracks': 0,
|
'total_tracks': 0,
|
||||||
'by_source_type': {},
|
'by_source_type': {},
|
||||||
'recent_failures': []
|
'recent_failures': []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get detailed breakdown
|
# 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
|
# Group by source type
|
||||||
by_source_type = {}
|
by_source_type = {}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
613
web_server.py
613
web_server.py
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,63 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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">×</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">
|
<div class="main-container">
|
||||||
<!-- Mobile Navigation -->
|
<!-- Mobile Navigation -->
|
||||||
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
|
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
|
||||||
|
|
@ -26,6 +83,10 @@
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1 class="app-name">SoulSync</h1>
|
<h1 class="app-name">SoulSync</h1>
|
||||||
<p class="app-subtitle">Music Sync & Manager</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation Section -->
|
<!-- Navigation Section -->
|
||||||
|
|
|
||||||
|
|
@ -2151,4 +2151,25 @@
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: flex-start;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +147,10 @@ function initializeWebSocket() {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
socketConnected = true;
|
socketConnected = true;
|
||||||
resubscribeDownloadBatches();
|
resubscribeDownloadBatches();
|
||||||
|
// Join profile room for scoped watchlist/wishlist count updates
|
||||||
|
if (currentProfile) {
|
||||||
|
socket.emit('profile:join', { profile_id: currentProfile.id });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
|
|
@ -156,6 +160,10 @@ function initializeWebSocket() {
|
||||||
|
|
||||||
socket.on('reconnect', (attemptNumber) => {
|
socket.on('reconnect', (attemptNumber) => {
|
||||||
console.log(`WebSocket reconnected after ${attemptNumber} attempts`);
|
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
|
// Phase 1: Full state refresh on reconnect
|
||||||
fetchAndUpdateServiceStatus();
|
fetchAndUpdateServiceStatus();
|
||||||
updateWatchlistButtonCount();
|
updateWatchlistButtonCount();
|
||||||
|
|
@ -637,9 +645,571 @@ function initAccentColorListeners() {
|
||||||
if (saved) applyAccentColor(saved);
|
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...');
|
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
|
// Initialize components
|
||||||
initializeNavigation();
|
initializeNavigation();
|
||||||
initializeMobileNavigation();
|
initializeMobileNavigation();
|
||||||
|
|
@ -702,7 +1272,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('SoulSync WebUI initialized successfully!');
|
console.log('SoulSync WebUI initialized successfully!');
|
||||||
});
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// NAVIGATION SYSTEM
|
// NAVIGATION SYSTEM
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ body {
|
||||||
|
|
||||||
/* Sidebar Header */
|
/* Sidebar Header */
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
height: 95px;
|
min-height: 115px;
|
||||||
background: linear-gradient(180deg,
|
background: linear-gradient(180deg,
|
||||||
rgba(var(--accent-rgb), 0.14) 0%,
|
rgba(var(--accent-rgb), 0.14) 0%,
|
||||||
rgba(var(--accent-rgb), 0.08) 30%,
|
rgba(var(--accent-rgb), 0.08) 30%,
|
||||||
|
|
@ -102,11 +102,11 @@ body {
|
||||||
transparent 100%);
|
transparent 100%);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-top-right-radius: 20px;
|
border-top-right-radius: 20px;
|
||||||
padding: 28px 24px;
|
padding: 20px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
|
@ -27250,4 +27250,477 @@ body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
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;
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue