From d497492186d017acc3a1430fc04dd8ed7862c8cc Mon Sep 17 00:00:00 2001 From: oib Date: Mon, 28 Jul 2025 16:42:46 +0200 Subject: [PATCH] feat: Overhaul client-side navigation and clean up project - Implement a unified SPA routing system in nav.js, removing all legacy and conflicting navigation scripts (router.js, inject-nav.js, fix-nav.js). - Refactor dashboard.js to delegate all navigation handling to the new nav.js module. - Create new modular JS files (auth.js, personal-player.js, logger.js) to improve code organization. - Fix all navigation-related bugs, including guest access and broken footer links. - Clean up the project root by moving development scripts and backups to a dedicated /dev directory. - Add a .gitignore file to exclude the database, logs, and other transient files from the repository. --- .gitignore | 3 + account_router.py | 60 +- concat_opus.py | 78 -- convert_to_opus.py | 39 - create_silent_opus.py | 70 -- deletefile.py | 212 ------ import_streams.py | 94 --- init_db.py | 36 - list_streams.py | 137 ---- list_user_files.py | 23 - migrate_uid_to_email.py | 174 ----- nohup.out | 4 - public_streams.txt.backup | 3 - register.py | 101 ++- reload.txt | 0 run_migrations.py | 29 - static/app.js | 1274 +------------------------------- static/audio-player.js | 442 +++++++++++ static/auth-ui.js | 6 +- static/auth.js | 252 +++++++ static/dashboard.js | 845 +++++---------------- static/fix-nav.js | 140 ---- static/generate-test-audio.sh | 13 - static/global-audio-manager.js | 1 + static/index.html | 30 +- static/inject-nav.js | 184 ----- static/logger.js | 6 + static/magic-login.js | 6 +- static/nav.js | 515 ++----------- static/personal-player.js | 140 ++++ static/reload.txt | 0 static/router.js | 168 ----- static/streams-ui.js | 2 +- static/upload.js | 2 +- 34 files changed, 1279 insertions(+), 3810 deletions(-) delete mode 100644 concat_opus.py delete mode 100644 convert_to_opus.py delete mode 100644 create_silent_opus.py delete mode 100644 deletefile.py delete mode 100644 import_streams.py delete mode 100644 init_db.py delete mode 100644 list_streams.py delete mode 100644 list_user_files.py delete mode 100644 migrate_uid_to_email.py delete mode 100644 nohup.out delete mode 100644 public_streams.txt.backup delete mode 100644 reload.txt delete mode 100644 run_migrations.py create mode 100644 static/audio-player.js create mode 100644 static/auth.js delete mode 100644 static/fix-nav.js delete mode 100755 static/generate-test-audio.sh delete mode 100644 static/inject-nav.js create mode 100644 static/logger.js create mode 100644 static/personal-player.js delete mode 100644 static/reload.txt delete mode 100644 static/router.js diff --git a/.gitignore b/.gitignore index 6bf9ca1..3e7ccc3 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Local Database +dicta2stream.db + # Development directory dev/ diff --git a/account_router.py b/account_router.py index de8ac10..39951ec 100644 --- a/account_router.py +++ b/account_router.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, HTTPException, Depends from fastapi.responses import JSONResponse from sqlmodel import Session, select -from models import User, UserQuota, UploadLog, DBSession +from models import User, UserQuota, UploadLog, DBSession, PublicStream from database import get_db import os from typing import Dict, Any @@ -23,43 +23,71 @@ async def delete_account(data: Dict[str, Any], request: Request, db: Session = D print(f"[DELETE_ACCOUNT] Processing delete request for UID: {uid} from IP: {ip}") # Verify user exists and IP matches - user = db.exec(select(User).where(User.username == uid)).first() + # Handle both email-based and username-based UIDs for backward compatibility + user = None + + # First try to find by email (new UID format) + if '@' in uid: + user = db.exec(select(User).where(User.email == uid)).first() + print(f"[DELETE_ACCOUNT] Looking up user by email: {uid}") + + # If not found by email, try by username (legacy UID format) if not user: - print(f"[DELETE_ACCOUNT] Error: User {uid} not found") + user = db.exec(select(User).where(User.username == uid)).first() + print(f"[DELETE_ACCOUNT] Looking up user by username: {uid}") + + if not user: + print(f"[DELETE_ACCOUNT] Error: User {uid} not found (tried both email and username lookup)") raise HTTPException(status_code=404, detail="User not found") + # Use the actual email as the UID for database operations + actual_uid = user.email + print(f"[DELETE_ACCOUNT] Found user: {user.username} ({user.email}), using email as UID: {actual_uid}") + if user.ip != ip: print(f"[DELETE_ACCOUNT] Error: IP mismatch. User IP: {user.ip}, Request IP: {ip}") raise HTTPException(status_code=403, detail="Unauthorized: IP address does not match") # Start transaction try: - # Delete user's upload logs - uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all() + # Delete user's upload logs (use actual_uid which is always the email) + uploads = db.exec(select(UploadLog).where(UploadLog.uid == actual_uid)).all() for upload in uploads: db.delete(upload) - print(f"[DELETE_ACCOUNT] Deleted {len(uploads)} upload logs for user {uid}") + print(f"[DELETE_ACCOUNT] Deleted {len(uploads)} upload logs for user {actual_uid}") + + # Delete user's public streams + streams = db.exec(select(PublicStream).where(PublicStream.uid == actual_uid)).all() + for stream in streams: + db.delete(stream) + print(f"[DELETE_ACCOUNT] Deleted {len(streams)} public streams for user {actual_uid}") # Delete user's quota - quota = db.get(UserQuota, uid) + quota = db.get(UserQuota, actual_uid) if quota: db.delete(quota) - print(f"[DELETE_ACCOUNT] Deleted quota for user {uid}") + print(f"[DELETE_ACCOUNT] Deleted quota for user {actual_uid}") - # Delete user's active sessions - sessions = db.exec(select(DBSession).where(DBSession.user_id == uid)).all() - for session in sessions: + # Delete user's active sessions (check both email and username as user_id) + sessions_by_email = db.exec(select(DBSession).where(DBSession.user_id == actual_uid)).all() + sessions_by_username = db.exec(select(DBSession).where(DBSession.user_id == user.username)).all() + + all_sessions = list(sessions_by_email) + list(sessions_by_username) + # Remove duplicates using token (primary key) instead of id + unique_sessions = {session.token: session for session in all_sessions}.values() + + for session in unique_sessions: db.delete(session) - print(f"[DELETE_ACCOUNT] Deleted {len(sessions)} active sessions for user {uid}") + print(f"[DELETE_ACCOUNT] Deleted {len(unique_sessions)} active sessions for user {actual_uid} (checked both email and username)") # Delete user account - user_obj = db.get(User, user.email) + user_obj = db.get(User, actual_uid) # Use actual_uid which is the email if user_obj: db.delete(user_obj) - print(f"[DELETE_ACCOUNT] Deleted user account {uid} ({user.email})") + print(f"[DELETE_ACCOUNT] Deleted user account {actual_uid}") db.commit() - print(f"[DELETE_ACCOUNT] Database changes committed for user {uid}") + print(f"[DELETE_ACCOUNT] Database changes committed for user {actual_uid}") except Exception as e: db.rollback() @@ -87,7 +115,7 @@ async def delete_account(data: Dict[str, Any], request: Request, db: Session = D print(f"[DELETE_ACCOUNT] Error deleting user files: {str(e)}") # Continue even if file deletion fails, as the account is already deleted from the DB - print(f"[DELETE_ACCOUNT] Successfully deleted account for user {uid}") + print(f"[DELETE_ACCOUNT] Successfully deleted account for user {actual_uid} (original UID: {uid})") return {"status": "success", "message": "Account and all associated data have been deleted"} except HTTPException as he: diff --git a/concat_opus.py b/concat_opus.py deleted file mode 100644 index 3a109f8..0000000 --- a/concat_opus.py +++ /dev/null @@ -1,78 +0,0 @@ -# concat_opus.py — Concatenate all opus files in a user directory in random order into a single stream.opus -import os -import random -import subprocess -from pathlib import Path - -def concat_opus_files(user_dir: Path, output_file: Path): - """ - Concatenate all .opus files in user_dir (except stream.opus) in random order into output_file. - Overwrites output_file if exists. Creates it if missing. - """ - # Clean up any existing filelist.txt to prevent issues - filelist_path = user_dir / 'filelist.txt' - if filelist_path.exists(): - try: - filelist_path.unlink() - except Exception as e: - print(f"Warning: Could not clean up old filelist.txt: {e}") - - # Get all opus files except stream.opus and remove any duplicates - import hashlib - file_hashes = set() - files = [] - - for f in user_dir.glob('*.opus'): - if f.name == 'stream.opus': - continue - - try: - # Calculate file hash for duplicate detection - hasher = hashlib.md5() - with open(f, 'rb') as file: - buf = file.read(65536) # Read in 64kb chunks - while len(buf) > 0: - hasher.update(buf) - buf = file.read(65536) - file_hash = hasher.hexdigest() - - # Skip if we've seen this exact file before - if file_hash in file_hashes: - print(f"Removing duplicate file: {f.name}") - f.unlink() - continue - - file_hashes.add(file_hash) - files.append(f) - - except Exception as e: - print(f"Error processing {f}: {e}") - - if not files: - # If no files, create an empty stream.opus - output_file.write_bytes(b'') - return output_file - - random.shuffle(files) - - # Create a filelist for ffmpeg concat - filelist_path = user_dir / 'filelist.txt' - with open(filelist_path, 'w') as f: - for opusfile in files: - f.write(f"file '{opusfile.resolve()}'\n") - - # ffmpeg concat demuxer (no re-encoding) - cmd = [ - 'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', str(filelist_path), - '-c', 'copy', str(output_file) - ] - try: - subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError as e: - raise RuntimeError(f"FFmpeg concat failed: {e}") - finally: - if filelist_path.exists(): - filelist_path.unlink() - if not output_file.exists(): - raise RuntimeError("Concatenation did not produce output.") - return output_file diff --git a/convert_to_opus.py b/convert_to_opus.py deleted file mode 100644 index a62fb00..0000000 --- a/convert_to_opus.py +++ /dev/null @@ -1,39 +0,0 @@ -# convert_to_opus.py — Default voice pipeline: bandpass + compressor + limiter + gate - -import subprocess -import os - -def convert_to_opus(input_path, output_path): - if not os.path.exists(input_path): - raise FileNotFoundError(f"Input file not found: {input_path}") - - filters = [ - "highpass=f=400", # low-cut below 400 Hz - "lowpass=f=12000", # high-cut above 12 kHz - "acompressor=threshold=-18dB", - "alimiter=limit=-1dB", - "agate=threshold=0.02" - ] - - cmd = [ - "ffmpeg", "-y", - "-i", input_path, - "-af", ",".join(filters), - "-ac", "1", - "-ar", "24000", - "-c:a", "libopus", - "-b:a", "40k", - "-vbr", "on", - "-application", "voip", - output_path - ] - - try: - subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except subprocess.CalledProcessError as e: - raise RuntimeError(f"FFmpeg conversion failed: {e}") - - if not os.path.exists(output_path): - raise RuntimeError("Conversion did not produce output.") - - return output_path diff --git a/create_silent_opus.py b/create_silent_opus.py deleted file mode 100644 index 2858b2c..0000000 --- a/create_silent_opus.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -""" -Create a silent OPUS audio file with 1 second of silence. -""" -import os -import opuslib -import numpy as np -import struct - -# Configuration -SAMPLE_RATE = 48000 -CHANNELS = 1 -FRAME_SIZE = 960 # 20ms at 48kHz -SILENCE_DURATION = 1.0 # seconds -OUTPUT_FILE = "silent.opus" - -# Calculate number of frames needed -num_frames = int((SAMPLE_RATE * SILENCE_DURATION) / (FRAME_SIZE * CHANNELS)) - -# Initialize Opus encoder -enc = opuslib.Encoder(SAMPLE_RATE, CHANNELS, 'voip') - -# Create silent audio data (all zeros) -silent_frame = struct.pack('h' * FRAME_SIZE * CHANNELS, *([0] * FRAME_SIZE * CHANNELS)) - -# Create Ogg Opus file -with open(OUTPUT_FILE, 'wb') as f: - # Write Ogg header - f.write(b'OggS') # Magic number - f.write(b'\x00') # Version - f.write(b'\x00') # Header type (0 = normal) - f.write(b'\x00\x00\x00\x00\x00\x00\x00\x00') # Granule position - f.write(b'\x00\x00\x00\x00') # Bitstream serial number - f.write(b'\x00\x00\x00\x00') # Page sequence number - f.write(b'\x00\x00\x00\x00') # Checksum - f.write(b'\x01') # Number of segments - f.write(b'\x00') # Segment table (0 = 1 byte segment) - - # Write Opus header - f.write(b'OpusHead') # Magic signature - f.write(b'\x01') # Version - f.write(chr(CHANNELS).encode('latin1')) # Channel count - f.write(struct.pack(' User: - """ - Get current user from authorization token with enhanced security. - - Args: - authorization: The Authorization header containing the Bearer token - db: Database session dependency - - Returns: - User: The authenticated user - - Raises: - HTTPException: If authentication fails or user not found - """ - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required" - ) - - token = authorization.split(" ")[1] - try: - with Session(db) as session: - # Check if session is valid - session_stmt = select(DBSession).where( - and_( - DBSession.token == token, - DBSession.is_active == True, - DBSession.expires_at > datetime.utcnow() - ) - ) - db_session = session.exec(session_stmt).first() - if not db_session: - print(f"[DELETE_FILE] Invalid or expired session token") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired session" - ) - - # Get the user - user = session.get(User, db_session.user_id) - if not user: - print(f"[DELETE_FILE] User not found for session token") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" - ) - - return user - except Exception as e: - print(f"[DELETE_FILE] Error during user authentication: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error during authentication" - ) - -@router.delete("/delete/{filename}") -async def delete_file( - request: Request, - filename: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) -) -> Dict[str, Any]: - """ - Delete a file for the authenticated user with enhanced security and error handling. - - Args: - request: The HTTP request object - filename: The name of the file to delete - db: Database session - current_user: The authenticated user - - Returns: - Dict: Status and message of the operation - - Raises: - HTTPException: If file not found, permission denied, or other errors - """ - print(f"[DELETE_FILE] Processing delete request for file '{filename}' from user {current_user.username}") - - try: - # Security: Validate filename to prevent directory traversal - if not filename or any(c in filename for c in ['..', '/', '\\']): - print(f"[DELETE_FILE] Security alert: Invalid filename '{filename}'") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid filename" - ) - - # Construct full path with security checks - user_dir = DATA_ROOT / current_user.username - file_path = (user_dir / filename).resolve() - - # Security: Ensure the file is within the user's directory - if not file_path.is_relative_to(user_dir.resolve()): - print(f"[DELETE_FILE] Security alert: Attempted path traversal: {file_path}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied" - ) - - # Verify file exists and is a file - if not file_path.exists() or not file_path.is_file(): - print(f"[DELETE_FILE] File not found: {file_path}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="File not found" - ) - - # Get file size before deletion for quota update - file_size = file_path.stat().st_size - print(f"[DELETE_FILE] Deleting file: {file_path} (size: {file_size} bytes)") - - # Start database transaction - with Session(db) as session: - try: - # Delete the file - try: - os.unlink(file_path) - print(f"[DELETE_FILE] Successfully deleted file: {file_path}") - except OSError as e: - print(f"[DELETE_FILE] Error deleting file: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete file" - ) - - # Clean up any associated raw files - raw_pattern = f"raw.*{filename}" - raw_files = list(file_path.parent.glob(raw_pattern)) - for raw_file in raw_files: - try: - os.unlink(raw_file) - print(f"[DELETE_FILE] Deleted raw file: {raw_file}") - except OSError as e: - print(f"[DELETE_FILE] Warning: Could not delete raw file {raw_file}: {str(e)}") - - # Delete the upload log entry - result = session.execute( - delete(UploadLog).where( - and_( - UploadLog.uid == current_user.username, - UploadLog.processed_filename == filename - ) - ) - ) - - if result.rowcount == 0: - print(f"[DELETE_FILE] Warning: No upload log entry found for {filename}") - else: - print(f"[DELETE_FILE] Deleted upload log entry for {filename}") - - # Update user quota - quota = session.exec( - select(UserQuota) - .where(UserQuota.uid == current_user.username) - .with_for_update() - ).first() - - if quota: - new_quota = max(0, quota.storage_bytes - file_size) - print(f"[DELETE_FILE] Updating quota: {quota.storage_bytes} -> {new_quota}") - quota.storage_bytes = new_quota - session.add(quota) - - session.commit() - print(f"[DELETE_FILE] Successfully updated database") - - return { - "status": "success", - "message": "File deleted successfully", - "bytes_freed": file_size - } - - except Exception as e: - session.rollback() - print(f"[DELETE_FILE] Database error: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Database error during file deletion" - ) - - except HTTPException as he: - print(f"[DELETE_FILE] HTTP Error {he.status_code}: {he.detail}") - raise - - except Exception as e: - print(f"[DELETE_FILE] Unexpected error: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred" - ) diff --git a/import_streams.py b/import_streams.py deleted file mode 100644 index 133176b..0000000 --- a/import_streams.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to import stream data from backup file into the publicstream table. -""" -import json -from datetime import datetime -from pathlib import Path -from sqlalchemy import create_engine, select -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session -from models import PublicStream, User, UserQuota, DBSession, UploadLog -from database import engine - -# Database connection URL - using the same as in database.py -DATABASE_URL = "postgresql://d2s:kuTy4ZKs2VcjgDh6@localhost:5432/dictastream" - -def import_streams_from_backup(backup_file: str): - """Import stream data from backup file into the database.""" - # Set up database connection - SessionLocal = sessionmaker(bind=engine) - - with Session(engine) as session: - try: - # Read the backup file - with open(backup_file, 'r') as f: - for line in f: - line = line.strip() - if not line: - continue - - try: - # Parse the JSON data - stream_data = json.loads(line) - uid = stream_data.get('uid') - size = stream_data.get('size', 0) - mtime = stream_data.get('mtime', int(datetime.now().timestamp())) - - if not uid: - print(f"Skipping invalid entry (missing uid): {line}") - continue - - # Check if the stream already exists - existing = session.exec( - select(PublicStream).where(PublicStream.uid == uid) - ).first() - - now = datetime.utcnow() - - if existing: - # Update existing record - existing.size = size - existing.mtime = mtime - existing.updated_at = now - session.add(existing) - print(f"Updated stream: {uid}") - else: - # Create new record - stream = PublicStream( - uid=uid, - size=size, - mtime=mtime, - created_at=now, - updated_at=now - ) - session.add(stream) - print(f"Added stream: {uid}") - - # Commit after each record to ensure data integrity - session.commit() - - except json.JSONDecodeError as e: - print(f"Error parsing line: {line}") - print(f"Error: {e}") - session.rollback() - except Exception as e: - print(f"Error processing line: {line}") - print(f"Error: {e}") - session.rollback() - - print("Import completed successfully!") - - except Exception as e: - session.rollback() - print(f"Error during import: {e}") - raise - -if __name__ == "__main__": - backup_file = "public_streams.txt.backup" - if not Path(backup_file).exists(): - print(f"Error: Backup file '{backup_file}' not found.") - exit(1) - - print(f"Starting import from {backup_file}...") - import_streams_from_backup(backup_file) diff --git a/init_db.py b/init_db.py deleted file mode 100644 index 7dcde40..0000000 --- a/init_db.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -"""Initialize the database with required tables""" -import os -import sys -from sqlmodel import SQLModel, create_engine -from dotenv import load_dotenv - -# Add the parent directory to the path so we can import our models -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from models import User, UserQuota, UploadLog, PublicStream, Session - -def init_db(): - """Initialize the database with required tables""" - # Load environment variables - load_dotenv() - - # Get database URL from environment or use default - database_url = os.getenv( - "DATABASE_URL", - "postgresql://postgres:postgres@localhost/dicta2stream" - ) - - print(f"Connecting to database: {database_url}") - - # Create database engine - engine = create_engine(database_url) - - # Create all tables - print("Creating database tables...") - SQLModel.metadata.create_all(engine) - - print("Database initialized successfully!") - -if __name__ == "__main__": - init_db() diff --git a/list_streams.py b/list_streams.py deleted file mode 100644 index 1b221b6..0000000 --- a/list_streams.py +++ /dev/null @@ -1,137 +0,0 @@ -# list_streams.py — FastAPI route to list all public streams (users with stream.opus) - -from fastapi import APIRouter, Request, Depends -from fastapi.responses import StreamingResponse, Response -from sqlalchemy.orm import Session -from sqlalchemy import select -from models import PublicStream -from database import get_db -from pathlib import Path -import asyncio -import os -import json - -router = APIRouter() -DATA_ROOT = Path("./data") - -@router.get("/streams-sse") -async def streams_sse(request: Request, db: Session = Depends(get_db)): - # Add CORS headers for SSE - origin = request.headers.get('origin', '') - allowed_origins = ["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"] - - # Use the request origin if it's in the allowed list, otherwise use the first allowed origin - cors_origin = origin if origin in allowed_origins else allowed_origins[0] - - headers = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": cors_origin, - "Access-Control-Allow-Credentials": "true", - "Access-Control-Expose-Headers": "Content-Type", - "X-Accel-Buffering": "no" # Disable buffering for nginx - } - - # Handle preflight requests - if request.method == "OPTIONS": - headers.update({ - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": request.headers.get("access-control-request-headers", "*"), - "Access-Control-Max-Age": "86400" # 24 hours - }) - return Response(status_code=204, headers=headers) - - async def event_wrapper(): - try: - async for event in list_streams_sse(db): - yield event - except Exception as e: - # Only log errors if DEBUG is enabled - if os.getenv("DEBUG") == "1": - import traceback - traceback.print_exc() - yield f"data: {json.dumps({'error': True, 'message': 'An error occurred'})}\n\n" - - return StreamingResponse( - event_wrapper(), - media_type="text/event-stream", - headers=headers - ) - -async def list_streams_sse(db): - """Stream public streams from the database as Server-Sent Events""" - try: - # Send initial ping - yield ":ping\n\n" - - # Query all public streams from the database with required fields - stmt = select(PublicStream).order_by(PublicStream.mtime.desc()) - result = db.execute(stmt) - streams = result.scalars().all() - - if not streams: - print("No public streams found in the database") - yield f"data: {json.dumps({'end': True})}\n\n" - return - - print(f"Found {len(streams)} public streams in the database") - - # Send each stream as an SSE event - for stream in streams: - try: - # Ensure we have all required fields with fallbacks - stream_data = { - 'uid': stream.uid or '', - 'size': stream.storage_bytes or 0, - 'mtime': int(stream.mtime) if stream.mtime is not None else 0, - 'username': stream.username or '', - 'created_at': stream.created_at.isoformat() if stream.created_at else None, - 'updated_at': stream.updated_at.isoformat() if stream.updated_at else None - } - print(f"Sending stream data: {stream_data}") - yield f"data: {json.dumps(stream_data)}\n\n" - # Small delay to prevent overwhelming the client - await asyncio.sleep(0.1) - except Exception as e: - print(f"Error processing stream {stream.uid}: {str(e)}") - if os.getenv("DEBUG") == "1": - import traceback - traceback.print_exc() - continue - - # Send end of stream marker - print("Finished sending all streams") - yield f"data: {json.dumps({'end': True})}\n\n" - - except Exception as e: - print(f"Error in list_streams_sse: {str(e)}") - if os.getenv("DEBUG") == "1": - import traceback - traceback.print_exc() - yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n" - -def list_streams(db: Session = Depends(get_db)): - """List all public streams from the database""" - try: - stmt = select(PublicStream).order_by(PublicStream.mtime.desc()) - result = db.execute(stmt) - streams = result.scalars().all() - - return { - "streams": [ - { - 'uid': stream.uid, - 'size': stream.size, - 'mtime': stream.mtime, - 'created_at': stream.created_at.isoformat() if stream.created_at else None, - 'updated_at': stream.updated_at.isoformat() if stream.updated_at else None - } - for stream in streams - ] - } - except Exception as e: - if os.getenv("DEBUG") == "1": - import traceback - traceback.print_exc() - return {"streams": []} diff --git a/list_user_files.py b/list_user_files.py deleted file mode 100644 index b104cbf..0000000 --- a/list_user_files.py +++ /dev/null @@ -1,23 +0,0 @@ -# list_user_files.py -from fastapi import APIRouter, Depends, HTTPException -from pathlib import Path -from models import User -from database import get_db - -router = APIRouter() - -@router.get("/user-files/{uid}") -def list_user_files(uid: str, db = Depends(get_db)): - # Check user exists and is confirmed - from sqlmodel import select - user = db.exec(select(User).where((User.username == uid) | (User.email == uid))).first() - if user is not None and not isinstance(user, User) and hasattr(user, "__getitem__"): - user = user[0] - if not user or not user.confirmed: - raise HTTPException(status_code=403, detail="Account not confirmed") - user_dir = Path("data") / uid - if not user_dir.exists() or not user_dir.is_dir(): - return {"files": []} - files = [f.name for f in user_dir.iterdir() if f.is_file() and not f.name.startswith(".")] - files.sort() - return {"files": files} diff --git a/migrate_uid_to_email.py b/migrate_uid_to_email.py deleted file mode 100644 index d7d5c5c..0000000 --- a/migrate_uid_to_email.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration script to update PublicStream UIDs from usernames to email addresses. - -This script: -1. Maps current username-based UIDs to their corresponding email addresses -2. Updates the publicstream table to use email addresses as UIDs -3. Updates any other tables that reference the old UID format -4. Provides rollback capability -""" - -import sys -from sqlmodel import Session, select -from database import engine -from models import User, PublicStream, UploadLog, UserQuota - -def get_username_to_email_mapping(): - """Get mapping of username -> email from user table""" - with Session(engine) as session: - users = session.exec(select(User)).all() - mapping = {} - for user in users: - mapping[user.username] = user.email - return mapping - -def migrate_publicstream_uids(): - """Migrate PublicStream UIDs from usernames to emails""" - mapping = get_username_to_email_mapping() - - with Session(engine) as session: - # Get all public streams with username-based UIDs - streams = session.exec(select(PublicStream)).all() - - updates = [] - for stream in streams: - if stream.uid in mapping: - old_uid = stream.uid - new_uid = mapping[stream.uid] - updates.append((old_uid, new_uid, stream)) - print(f"Will update: {old_uid} -> {new_uid}") - else: - print(f"WARNING: No email found for username: {stream.uid}") - - if not updates: - print("No updates needed - all UIDs are already in correct format") - return - - # Confirm before proceeding - response = input(f"\nProceed with updating {len(updates)} records? (y/N): ") - if response.lower() != 'y': - print("Migration cancelled") - return - - # Perform the updates - for old_uid, new_uid, stream in updates: - # Delete the old record - session.delete(stream) - session.flush() # Ensure deletion is committed before insert - - # Create new record with email-based UID - new_stream = PublicStream( - uid=new_uid, - username=stream.username, - display_name=stream.display_name, - storage_bytes=stream.storage_bytes, - mtime=stream.mtime, - last_updated=stream.last_updated, - created_at=stream.created_at, - updated_at=stream.updated_at - ) - session.add(new_stream) - print(f"Updated: {old_uid} -> {new_uid}") - - session.commit() - print(f"\nSuccessfully migrated {len(updates)} PublicStream records") - -def migrate_related_tables(): - """Update other tables that reference UIDs""" - mapping = get_username_to_email_mapping() - - with Session(engine) as session: - # Update UploadLog table - upload_logs = session.exec(select(UploadLog)).all() - upload_updates = 0 - - for log in upload_logs: - if log.uid in mapping: - old_uid = log.uid - new_uid = mapping[log.uid] - log.uid = new_uid - upload_updates += 1 - print(f"Updated UploadLog: {old_uid} -> {new_uid}") - - # Update UserQuota table - quotas = session.exec(select(UserQuota)).all() - quota_updates = 0 - - for quota in quotas: - if quota.uid in mapping: - old_uid = quota.uid - new_uid = mapping[quota.uid] - quota.uid = new_uid - quota_updates += 1 - print(f"Updated UserQuota: {old_uid} -> {new_uid}") - - if upload_updates > 0 or quota_updates > 0: - session.commit() - print(f"\nUpdated {upload_updates} UploadLog and {quota_updates} UserQuota records") - else: - print("No related table updates needed") - -def verify_migration(): - """Verify the migration was successful""" - print("\n=== Migration Verification ===") - - with Session(engine) as session: - # Check PublicStream UIDs - streams = session.exec(select(PublicStream)).all() - print(f"PublicStream records: {len(streams)}") - - for stream in streams: - if '@' in stream.uid: - print(f"✓ {stream.uid} (email format)") - else: - print(f"✗ {stream.uid} (still username format)") - - # Check if all UIDs correspond to actual user emails - users = session.exec(select(User)).all() - user_emails = {user.email for user in users} - - orphaned_streams = [] - for stream in streams: - if stream.uid not in user_emails: - orphaned_streams.append(stream.uid) - - if orphaned_streams: - print(f"\nWARNING: Found {len(orphaned_streams)} streams with UIDs not matching any user email:") - for uid in orphaned_streams: - print(f" - {uid}") - else: - print("\n✓ All stream UIDs correspond to valid user emails") - -def main(): - print("=== UID Migration: Username -> Email ===") - print("This script will update PublicStream UIDs from usernames to email addresses") - - # Show current mapping - mapping = get_username_to_email_mapping() - print(f"\nFound {len(mapping)} users:") - for username, email in mapping.items(): - print(f" {username} -> {email}") - - if len(sys.argv) > 1 and sys.argv[1] == '--verify-only': - verify_migration() - return - - # Perform migration - print("\n1. Migrating PublicStream table...") - migrate_publicstream_uids() - - print("\n2. Migrating related tables...") - migrate_related_tables() - - print("\n3. Verifying migration...") - verify_migration() - - print("\n=== Migration Complete ===") - print("Remember to:") - print("1. Restart the application service") - print("2. Test the streams functionality") - print("3. Check for any frontend issues with the new UID format") - -if __name__ == "__main__": - main() diff --git a/nohup.out b/nohup.out deleted file mode 100644 index 30a0b95..0000000 --- a/nohup.out +++ /dev/null @@ -1,4 +0,0 @@ -INFO: Will watch for changes in these directories: ['/home/oib/games/dicta2stream'] -ERROR: [Errno 98] Address already in use -INFO: Will watch for changes in these directories: ['/home/oib/games/dicta2stream'] -ERROR: [Errno 98] Address already in use diff --git a/public_streams.txt.backup b/public_streams.txt.backup deleted file mode 100644 index e97e65a..0000000 --- a/public_streams.txt.backup +++ /dev/null @@ -1,3 +0,0 @@ -{"uid":"devuser","size":90059327,"mtime":1752911461} -{"uid":"oibchello","size":16262818,"mtime":1752911899} -{"uid":"orangeicebear","size":1734396,"mtime":1748767975} diff --git a/register.py b/register.py index 8015f32..7f2caf5 100644 --- a/register.py +++ b/register.py @@ -51,30 +51,49 @@ def initialize_user_directory(username: str): @router.post("/register") def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)): from sqlalchemy.exc import IntegrityError - # Try to find user by email or username - existing_user = db.get(User, email) - if not existing_user: - # Try by username (since username is not primary key, need to query) - stmt = select(User).where(User.username == user) - existing_user = db.exec(stmt).first() + from datetime import datetime + + # Check if user exists by email + existing_user_by_email = db.get(User, email) + + # Check if user exists by username + stmt = select(User).where(User.username == user) + existing_user_by_username = db.exec(stmt).first() + token = str(uuid.uuid4()) - if existing_user: - # Update token, timestamp, and ip, set confirmed False - from datetime import datetime - existing_user.token = token - existing_user.token_created = datetime.utcnow() - existing_user.confirmed = False - existing_user.ip = request.client.host - db.add(existing_user) + + # Case 1: Email and username match in db - it's a login + if existing_user_by_email and existing_user_by_username and existing_user_by_email.email == existing_user_by_username.email: + # Update token for existing user (login) + existing_user_by_email.token = token + existing_user_by_email.token_created = datetime.utcnow() + existing_user_by_email.confirmed = False + existing_user_by_email.ip = request.client.host + db.add(existing_user_by_email) try: db.commit() except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"Database error: {e}") - else: + + action = "login" + + # Case 2: Email matches but username does not - only one account per email + elif existing_user_by_email and (not existing_user_by_username or existing_user_by_email.email != existing_user_by_username.email): + raise HTTPException(status_code=409, detail="📧 This email is already registered with a different username.\nOnly one account per email is allowed.") + + # Case 3: Email does not match but username is in db - username already taken + elif not existing_user_by_email and existing_user_by_username: + raise HTTPException(status_code=409, detail="👤 This username is already taken.\nPlease choose a different username.") + + # Case 4: Neither email nor username exist - create new user + elif not existing_user_by_email and not existing_user_by_username: # Register new user - db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host)) - db.add(UserQuota(uid=user)) + new_user = User(email=email, username=user, token=token, confirmed=False, ip=request.client.host) + new_quota = UserQuota(uid=email) # Use email as UID for quota tracking + + db.add(new_user) + db.add(new_quota) try: # First commit the user to the database @@ -86,30 +105,46 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db db.rollback() if isinstance(e, IntegrityError): # Race condition: user created after our check - # Try again as login - stmt = select(User).where((User.email == email) | (User.username == user)) - existing_user = db.exec(stmt).first() - if existing_user: - existing_user.token = token - existing_user.confirmed = False - existing_user.ip = request.client.host - db.add(existing_user) - db.commit() + # Check which constraint was violated to provide specific feedback + error_str = str(e).lower() + + if 'username' in error_str or 'user_username_key' in error_str: + raise HTTPException(status_code=409, detail="👤 This username is already taken.\nPlease choose a different username.") + elif 'email' in error_str or 'user_pkey' in error_str: + raise HTTPException(status_code=409, detail="📧 This email is already registered with a different username.\nOnly one account per email is allowed.") else: - raise HTTPException(status_code=409, detail="Username or email already exists.") + # Generic fallback if we can't determine the specific constraint + raise HTTPException(status_code=409, detail="⚠️ Registration failed due to a conflict.\nPlease try again with different credentials.") else: raise HTTPException(status_code=500, detail=f"Database error: {e}") - # Send magic link + + action = "registration" + + else: + # This should not happen, but handle it gracefully + raise HTTPException(status_code=500, detail="Unexpected error during registration.") + # Send magic link with appropriate message based on action msg = EmailMessage() msg["From"] = MAGIC_FROM msg["To"] = email - msg["Subject"] = "Your magic login link" - msg.set_content( - f"Hello {user},\n\nClick to confirm your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time login." - ) + + if action == "login": + msg["Subject"] = "Your magic login link" + msg.set_content( + f"Hello {user},\n\nClick to log in to your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time login." + ) + response_message = "📧 Check your email for a magic login link!" + else: # registration + msg["Subject"] = "Welcome to dicta2stream - Confirm your account" + msg.set_content( + f"Hello {user},\n\nWelcome to dicta2stream! Click to confirm your new account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time confirmation." + ) + response_message = "🎉 Account created! Check your email for a magic login link!" + try: with smtplib.SMTP("localhost") as smtp: smtp.send_message(msg) except Exception as e: raise HTTPException(status_code=500, detail=f"Email failed: {e}") - return { "message": "Confirmation sent" } + + return {"message": response_message, "action": action} diff --git a/reload.txt b/reload.txt deleted file mode 100644 index e69de29..0000000 diff --git a/run_migrations.py b/run_migrations.py deleted file mode 100644 index d004dde..0000000 --- a/run_migrations.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -"""Run database migrations""" -import os -import sys -from alembic.config import Config -from alembic import command -from dotenv import load_dotenv - -# Load environment variables -load_dotenv() - -def run_migrations(): - # Get database URL from environment or use default - database_url = os.getenv( - "DATABASE_URL", - "postgresql://postgres:postgres@localhost/dicta2stream" - ) - - # Set up Alembic config - alembic_cfg = Config() - alembic_cfg.set_main_option("script_location", "dev/migrations") - alembic_cfg.set_main_option("sqlalchemy.url", database_url) - - # Run migrations - command.upgrade(alembic_cfg, "head") - print("Database migrations completed successfully.") - -if __name__ == "__main__": - run_migrations() diff --git a/static/app.js b/static/app.js index 9a0ceb2..bb2fef5 100644 --- a/static/app.js +++ b/static/app.js @@ -1,1257 +1,65 @@ -// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect +// app.js - Main application entry point -import { playBeep } from "./sound.js"; -import { showToast } from "./toast.js"; -import { injectNavigation } from "./inject-nav.js"; -import { globalAudioManager } from './global-audio-manager.js'; - -// Global audio state -let globalAudio = null; -let currentStreamUid = null; -let audioPlaying = false; -let lastPosition = 0; - -// Utility functions -function getCookie(name) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop().split(';').shift(); - return null; -} - -// Log debug messages to server -export function logToServer(msg) { - const xhr = new XMLHttpRequest(); - xhr.open("POST", "/log", true); - xhr.setRequestHeader("Content-Type", "application/json"); - xhr.send(JSON.stringify({ msg })); -} - -// Handle magic link login redirect -function handleMagicLoginRedirect() { - const params = new URLSearchParams(window.location.search); - if (params.get('login') === 'success' && params.get('confirmed_uid')) { - const username = params.get('confirmed_uid'); - console.log('Magic link login detected for user:', username); - - // Update authentication state - localStorage.setItem('uid', username); - localStorage.setItem('confirmed_uid', username); - localStorage.setItem('uid_time', Date.now().toString()); - document.cookie = `uid=${encodeURIComponent(username)}; path=/; SameSite=Lax`; - - // Update UI state - document.body.classList.add('authenticated'); - document.body.classList.remove('guest'); - - // Update local storage and cookies - localStorage.setItem('isAuthenticated', 'true'); - document.cookie = `isAuthenticated=true; path=/; SameSite=Lax`; - - // Update URL and history without reloading - window.history.replaceState({}, document.title, window.location.pathname); - - // Update navigation - if (typeof injectNavigation === 'function') { - console.log('Updating navigation after magic link login'); - injectNavigation(true); - } else { - console.warn('injectNavigation function not available after magic link login'); - } - - // Navigate to user's profile page - if (window.showOnly) { - console.log('Navigating to me-page'); - window.showOnly('me-page'); - } else if (window.location.hash !== '#me') { - window.location.hash = '#me'; - } - - // Auth state will be updated by the polling mechanism - } -} - -// Audio player functions -function getOrCreateAudioElement() { - if (!globalAudio) { - globalAudio = document.getElementById('me-audio'); - if (!globalAudio) { - console.error('Audio element not found'); - return null; - } - - globalAudio.preload = 'metadata'; - globalAudio.crossOrigin = 'use-credentials'; - globalAudio.setAttribute('crossorigin', 'use-credentials'); - - // Set up event listeners - globalAudio.addEventListener('play', () => { - audioPlaying = true; - updatePlayPauseButton(); - }); - - globalAudio.addEventListener('pause', () => { - audioPlaying = false; - updatePlayPauseButton(); - }); - - globalAudio.addEventListener('timeupdate', () => { - lastPosition = globalAudio.currentTime; - }); - - globalAudio.addEventListener('error', handleAudioError); - } - return globalAudio; -} - -function handleAudioError(e) { - const error = this.error; - let errorMessage = 'Audio playback error'; - let shouldShowToast = true; - - if (error) { - switch(error.code) { - case MediaError.MEDIA_ERR_ABORTED: - errorMessage = 'Audio playback was aborted'; - shouldShowToast = false; // Don't show toast for aborted operations - break; - case MediaError.MEDIA_ERR_NETWORK: - errorMessage = 'Network error while loading audio'; - break; - case MediaError.MEDIA_ERR_DECODE: - errorMessage = 'Error decoding audio. The file may be corrupted.'; - break; - case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: - // Don't show error for missing audio files on new accounts - if (this.currentSrc && this.currentSrc.includes('stream.opus')) { - console.log('Audio format not supported or file not found:', this.currentSrc); - return; - } - errorMessage = 'Audio format not supported. Please upload a supported format (Opus/OGG).'; - break; - } - - console.error('Audio error:', errorMessage, error); - - // Only show error toast if we have a valid error and it's not a missing file - if (shouldShowToast && !(error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && !this.src)) { - showToast(errorMessage, 'error'); - } - } - - console.error('Audio error:', { - error: error, - src: this.currentSrc, - networkState: this.networkState, - readyState: this.readyState - }); - - if (errorMessage !== 'Audio format not supported') { - showToast(`❌ ${errorMessage}`, 'error'); - } -} - -function updatePlayPauseButton(audio, button) { - if (button && audio) { - button.textContent = audio.paused ? '▶️' : '⏸️'; - } -} - -// Stream loading and playback -async function loadProfileStream(uid) { - const audio = getOrCreateAudioElement(); - if (!audio) { - console.error('Failed to initialize audio element'); - return null; - } - - // Hide playlist controls - const mePrevBtn = document.getElementById("me-prev"); - const meNextBtn = document.getElementById("me-next"); - if (mePrevBtn) mePrevBtn.style.display = "none"; - if (meNextBtn) meNextBtn.style.display = "none"; - - // Reset current stream and update audio source - currentStreamUid = uid; - audio.pause(); - audio.removeAttribute('src'); - audio.load(); - - // Wait a moment to ensure the previous source is cleared - await new Promise(resolve => setTimeout(resolve, 50)); - - const username = localStorage.getItem('username') || uid; - const audioUrl = `/audio/${encodeURIComponent(username)}/stream.opus?t=${Date.now()}`; - - try { - console.log('Checking audio file at:', audioUrl); - - // First check if the audio file exists and get its content type - const response = await fetch(audioUrl, { - method: 'HEAD', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - - if (!response.ok) { - console.log('No audio file found for user:', username); - updatePlayPauseButton(audio, document.querySelector('.play-pause-btn')); - return null; - } - - const contentType = response.headers.get('content-type'); - console.log('Audio content type:', contentType); - - if (!contentType || !contentType.includes('audio/')) { - throw new Error(`Invalid content type: ${contentType || 'unknown'}`); - } - - // Set the audio source with proper type hint - const source = document.createElement('source'); - source.src = audioUrl; - source.type = 'audio/ogg; codecs=opus'; - - // Clear any existing sources - while (audio.firstChild) { - audio.removeChild(audio.firstChild); - } - audio.appendChild(source); - - // Load the new source - await new Promise((resolve, reject) => { - audio.load(); - audio.oncanplaythrough = resolve; - audio.onerror = () => { - reject(new Error('Failed to load audio source')); - }; - // Set a timeout in case the audio never loads - setTimeout(() => reject(new Error('Audio load timeout')), 10000); - }); - - console.log('Audio loaded, attempting to play...'); - - // Try to play immediately - try { - await audio.play(); - audioPlaying = true; - console.log('Audio playback started successfully'); - } catch (e) { - console.log('Auto-play failed, waiting for user interaction:', e); - audioPlaying = false; - // Don't show error for autoplay restrictions - if (!e.message.includes('play() failed because the user')) { - showToast('Click the play button to start playback', 'info'); - } - } - - // Show stream info if available - const streamInfo = document.getElementById("stream-info"); - if (streamInfo) streamInfo.hidden = false; - - } catch (error) { - console.error('Error checking/loading audio:', error); - // Don't show error toasts for missing audio files or aborted requests - if (error.name !== 'AbortError' && - !error.message.includes('404') && - !error.message.includes('Failed to load')) { - showToast('Error loading audio: ' + (error.message || 'Unknown error'), 'error'); - } - return null; - } - - // Update button state - updatePlayPauseButton(audio, document.querySelector('.play-pause-btn')); - return audio; -} - -// Navigation and UI functions -function showProfilePlayerFromUrl() { - const params = new URLSearchParams(window.location.search); - const profileUid = params.get("profile"); - - if (profileUid) { - const mePage = document.getElementById("me-page"); - if (!mePage) return; - - document.querySelectorAll("main > section").forEach(sec => - sec.hidden = sec.id !== "me-page" - ); - - // Hide upload/delete/copy-url controls for guest view - const uploadArea = document.getElementById("upload-area"); - if (uploadArea) uploadArea.hidden = true; - - const copyUrlBtn = document.getElementById("copy-url"); - if (copyUrlBtn) copyUrlBtn.style.display = "none"; - - const deleteBtn = document.getElementById("delete-account"); - if (deleteBtn) deleteBtn.style.display = "none"; - - // Update UI for guest view - const meHeading = document.querySelector("#me-page h2"); - if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`; - - const meDesc = document.querySelector("#me-page p"); - if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`; - - // Show a Play Stream button for explicit user action - const streamInfo = document.getElementById("stream-info"); - if (streamInfo) { - streamInfo.innerHTML = ''; - const playBtn = document.createElement('button'); - playBtn.textContent = "▶ Play Stream"; - playBtn.onclick = () => { - loadProfileStream(profileUid); - playBtn.disabled = true; - }; - streamInfo.appendChild(playBtn); - streamInfo.hidden = false; - } - } -} +import { initPersonalPlayer } from './personal-player.js'; +/** + * Initializes the primary navigation and routing system. + * This function sets up event listeners for navigation links and handles hash-based routing. + */ function initNavigation() { - // Get all navigation links - const navLinks = document.querySelectorAll('nav a, .dashboard-nav a'); - - // Handle navigation link clicks + const navLinks = document.querySelectorAll('nav a, .dashboard-nav a, .footer-links a'); + const handleNavClick = (e) => { const link = e.target.closest('a'); if (!link) return; - - // Check both href and data-target attributes - const target = link.getAttribute('data-target'); + const href = link.getAttribute('href'); - - // Let the browser handle external links + const target = link.getAttribute('data-target'); + if (href && (href.startsWith('http') || href.startsWith('mailto:'))) { - return; + return; // External link } - - // If no data-target and no hash in href, let browser handle it - if (!target && (!href || !href.startsWith('#'))) { - return; - } - - // Prefer data-target over href - let sectionId = target || (href ? href.substring(1) : ''); - - // Special case for the 'me' route which maps to 'me-page' - if (sectionId === 'me') { - sectionId = 'me-page'; - } - - // Skip if no valid section ID - if (!sectionId) { - console.warn('No valid section ID in navigation link:', link); - return; - } - - // Let the router handle the navigation + e.preventDefault(); e.stopPropagation(); - - // Update body class to reflect the current section - document.body.className = ''; // Clear existing classes - document.body.classList.add(`page-${sectionId.replace('-page', '')}`); - - // Update URL hash - the router will handle the rest + + let sectionId = target || (href ? href.substring(1) : 'welcome-page'); + if (sectionId === 'me' || sectionId === 'account') { + sectionId = sectionId + '-page'; + } + window.location.hash = sectionId; - - // Close mobile menu if open - const burger = document.getElementById('burger-toggle'); - if (burger && burger.checked) burger.checked = false; }; - - // Add click event listeners to all navigation links - navLinks.forEach(link => { - link.addEventListener('click', handleNavClick); - }); - - // Handle initial page load and hash changes + const handleHashChange = () => { let hash = window.location.hash.substring(1); - - // Map URL hashes to section IDs if they don't match exactly - const sectionMap = { - 'welcome': 'welcome-page', - 'streams': 'stream-page', - 'account': 'register-page', - 'login': 'login-page', - 'me': 'me-page' - }; - - // Use mapped section ID or the hash as is - const sectionId = sectionMap[hash] || hash || 'welcome-page'; - const targetSection = document.getElementById(sectionId); - - if (targetSection) { - // Hide all sections - document.querySelectorAll('main > section').forEach(section => { - section.hidden = section.id !== sectionId; - }); - - // Show target section - targetSection.hidden = false; - targetSection.scrollIntoView({ behavior: 'smooth' }); - - // Update active state of navigation links - navLinks.forEach(link => { - const linkHref = link.getAttribute('href'); - // Match both the exact hash and the mapped section ID - link.classList.toggle('active', - linkHref === `#${hash}` || - linkHref === `#${sectionId}` || - link.getAttribute('data-target') === sectionId || - link.getAttribute('data-target') === hash - ); - }); - - // Special handling for streams page - if (sectionId === 'stream-page' && typeof window.maybeLoadStreamsOnShow === 'function') { - window.maybeLoadStreamsOnShow(); - } - } else { - console.warn(`Section with ID '${sectionId}' not found`); + if (!hash || !document.getElementById(hash)) { + hash = 'welcome-page'; } + + document.querySelectorAll('main > section').forEach(section => { + section.classList.remove('active'); + }); + + const activeSection = document.getElementById(hash); + if (activeSection) { + activeSection.classList.add('active'); + } + + navLinks.forEach(link => { + const linkTarget = link.getAttribute('data-target') || (link.getAttribute('href') ? link.getAttribute('href').substring(1) : ''); + const isActive = (linkTarget === hash) || (linkTarget === 'me' && hash === 'me-page'); + link.classList.toggle('active', isActive); + }); }; - - // Listen for hash changes + + document.body.addEventListener('click', handleNavClick); window.addEventListener('hashchange', handleHashChange); - - // Handle initial page load - handleHashChange(); -} - -function initProfilePlayer() { - if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return; - showProfilePlayerFromUrl(); -} - -// Track previous authentication state -let wasAuthenticated = null; -// Debug flag - set to false to disable auth state change logs -const DEBUG_AUTH_STATE = false; - -// Track all intervals and timeouts -const activeIntervals = new Map(); -const activeTimeouts = new Map(); - -// Store original timer functions -const originalSetInterval = window.setInterval; -const originalClearInterval = window.clearInterval; -const originalSetTimeout = window.setTimeout; -const originalClearTimeout = window.clearTimeout; - -// Override setInterval to track all intervals -window.setInterval = (callback, delay, ...args) => { - const id = originalSetInterval((...args) => { - trackFunctionCall('setInterval callback', { id, delay, callback: callback.toString() }); - return callback(...args); - }, delay, ...args); - - activeIntervals.set(id, { - id, - delay, - callback: callback.toString(), - createdAt: Date.now(), - stack: new Error().stack - }); - - if (DEBUG_AUTH_STATE) { - console.log(`[Interval ${id}] Created with delay ${delay}ms`, { - id, - delay, - callback: callback.toString(), - stack: new Error().stack - }); - } - - return id; -}; - -// Override clearInterval to track interval cleanup -window.clearInterval = (id) => { - if (activeIntervals.has(id)) { - activeIntervals.delete(id); - if (DEBUG_AUTH_STATE) { - console.log(`[Interval ${id}] Cleared`); - } - } else if (DEBUG_AUTH_STATE) { - console.log(`[Interval ${id}] Cleared (not tracked)`); - } - originalClearInterval(id); -}; - -// Override setTimeout to track timeouts (debug logging disabled) -window.setTimeout = (callback, delay, ...args) => { - const id = originalSetTimeout(callback, delay, ...args); - // Store minimal info without logging - activeTimeouts.set(id, { - id, - delay, - callback: callback.toString(), - createdAt: Date.now() - }); - return id; -}; - -// Override clearTimeout to track timeout cleanup (debug logging disabled) -window.clearTimeout = (id) => { - if (activeTimeouts.has(id)) { - activeTimeouts.delete(id); - } - originalClearTimeout(id); -}; - -// Track auth check calls and cache state -let lastAuthCheckTime = 0; -let authCheckCounter = 0; -const AUTH_CHECK_DEBOUNCE = 1000; // 1 second -let authStateCache = { - timestamp: 0, - value: null, - ttl: 5000 // Cache TTL in milliseconds -}; - -// Override console.log to capture all logs -const originalConsoleLog = console.log; -const originalConsoleGroup = console.group; -const originalConsoleGroupEnd = console.groupEnd; - -// Track all console logs -const consoleLogs = []; -const MAX_LOGS = 100; - -console.log = function(...args) { - // Store the log - consoleLogs.push({ - type: 'log', - timestamp: new Date().toISOString(), - args: [...args], - stack: new Error().stack - }); - - // Keep only the most recent logs - while (consoleLogs.length > MAX_LOGS) { - consoleLogs.shift(); - } - - // Filter out the auth state check messages - if (args[0] && typeof args[0] === 'string' && args[0].includes('Auth State Check')) { - return; - } - - originalConsoleLog.apply(console, args); -}; - -// Track console groups -console.group = function(...args) { - consoleLogs.push({ type: 'group', timestamp: new Date().toISOString(), args }); - originalConsoleGroup.apply(console, args); -}; - -console.groupEnd = function() { - consoleLogs.push({ type: 'groupEnd', timestamp: new Date().toISOString() }); - originalConsoleGroupEnd.apply(console); -}; - -// Track all function calls that might trigger auth checks -const trackedFunctions = ['checkAuthState', 'setInterval', 'setTimeout', 'addEventListener', 'removeEventListener']; -const functionCalls = []; -const MAX_FUNCTION_CALLS = 100; - - - -// Function to format stack trace for better readability -function formatStack(stack) { - if (!stack) return 'No stack trace'; - // Remove the first line (the error message) and limit to 5 frames - const frames = stack.split('\n').slice(1).slice(0, 5); - return frames.join('\n'); -} - -// Function to dump debug info -window.dumpDebugInfo = () => { - console.group('Debug Info'); - - // Active Intervals - console.group('Active Intervals'); - if (activeIntervals.size === 0) { - console.log('No active intervals'); - } else { - activeIntervals.forEach((info, id) => { - console.group(`Interval ${id} (${info.delay}ms)`); - console.log('Created at:', new Date(info.createdAt).toISOString()); - console.log('Callback:', info.callback.split('\n')[0]); // First line of callback - console.log('Stack trace:'); - console.log(formatStack(info.stack)); - console.groupEnd(); - }); - } - console.groupEnd(); - - // Active Timeouts - console.group('Active Timeouts'); - if (activeTimeouts.size === 0) { - console.log('No active timeouts'); - } else { - activeTimeouts.forEach((info, id) => { - console.group(`Timeout ${id} (${info.delay}ms)`); - console.log('Created at:', new Date(info.createdAt).toISOString()); - console.log('Callback:', info.callback.split('\n')[0]); // First line of callback - console.log('Stack trace:'); - console.log(formatStack(info.stack)); - console.groupEnd(); - }); - } - console.groupEnd(); - - // Document state - console.group('Document State'); - console.log('Visibility:', document.visibilityState); - console.log('Has Focus:', document.hasFocus()); - console.log('URL:', window.location.href); - console.groupEnd(); - - // Recent logs - console.group('Recent Logs (10 most recent)'); - if (consoleLogs.length === 0) { - console.log('No logs recorded'); - } else { - consoleLogs.slice(-10).forEach((log, i) => { - console.group(`Log ${i + 1} (${log.timestamp})`); - console.log(...log.args); - if (log.stack) { - console.log('Stack trace:'); - console.log(formatStack(log.stack)); - } - console.groupEnd(); - }); - } - console.groupEnd(); - - // Auth state - console.group('Auth State'); - console.log('Has auth cookie:', document.cookie.includes('sessionid=')); - console.log('Has UID cookie:', document.cookie.includes('uid=')); - console.log('Has localStorage auth:', localStorage.getItem('isAuthenticated') === 'true'); - console.log('Has auth token:', !!localStorage.getItem('auth_token')); - console.groupEnd(); - - console.groupEnd(); // End main group -}; - -function trackFunctionCall(name, ...args) { - const callInfo = { - name, - time: Date.now(), - timestamp: new Date().toISOString(), - args: args.map(arg => { - try { - return JSON.stringify(arg); - } catch (e) { - return String(arg); - } - }), - stack: new Error().stack - }; - - functionCalls.push(callInfo); - if (functionCalls.length > MAX_FUNCTION_CALLS) { - functionCalls.shift(); - } - - if (name === 'checkAuthState') { - console.group(`[${functionCalls.length}] Auth check at ${callInfo.timestamp}`); - console.log('Call stack:', callInfo.stack); - console.log('Recent function calls:', functionCalls.slice(-5)); - console.groupEnd(); - } -} - -// Override tracked functions -trackedFunctions.forEach(fnName => { - if (window[fnName]) { - const originalFn = window[fnName]; - window[fnName] = function(...args) { - trackFunctionCall(fnName, ...args); - return originalFn.apply(this, args); - }; - } -}); - -// Update the visibility of the account deletion section based on authentication state -function updateAccountDeletionVisibility(isAuthenticated) { - console.log('[ACCOUNT-DELETION] updateAccountDeletionVisibility called with isAuthenticated:', isAuthenticated); - - // Find the account deletion section and its auth-only wrapper - const authOnlyWrapper = document.querySelector('#privacy-page .auth-only'); - const accountDeletionSection = document.getElementById('account-deletion'); - - console.log('[ACCOUNT-DELETION] Elements found:', { - authOnlyWrapper: !!authOnlyWrapper, - accountDeletionSection: !!accountDeletionSection - }); - - // Function to show an element with all necessary styles - const showElement = (element) => { - if (!element) return; - - console.log('[ACCOUNT-DELETION] Showing element:', element); - - // Remove any hiding classes - element.classList.remove('hidden', 'auth-only-hidden'); - - // Set all possible visibility properties - element.style.display = 'block'; - element.style.visibility = 'visible'; - element.style.opacity = '1'; - element.style.height = 'auto'; - element.style.position = 'relative'; - element.style.clip = 'auto'; - element.style.overflow = 'visible'; - - // Add a class to mark as visible - element.classList.add('account-visible'); - }; - - // Function to hide an element - const hideElement = (element) => { - if (!element) return; - - console.log('[ACCOUNT-DELETION] Hiding element:', element); - - // Set display to none to completely remove from layout - element.style.display = 'none'; - - // Remove any visibility-related classes - element.classList.remove('account-visible'); - }; - - if (isAuthenticated) { - console.log('[ACCOUNT-DELETION] User is authenticated, checking if on privacy page'); - - // Get the current page state - only show on #privacy-page - const currentHash = window.location.hash; - const isPrivacyPage = currentHash === '#privacy-page'; - - console.log('[ACCOUNT-DELETION] Debug - Page State:', { - isAuthenticated, - currentHash, - isPrivacyPage, - documentTitle: document.title - }); - - if (isAuthenticated && isPrivacyPage) { - console.log('[ACCOUNT-DELETION] On privacy page, showing account deletion section'); - - // Show the auth wrapper and account deletion section - if (authOnlyWrapper) { - authOnlyWrapper.style.display = 'block'; - authOnlyWrapper.style.visibility = 'visible'; - } - - if (accountDeletionSection) { - accountDeletionSection.style.display = 'block'; - accountDeletionSection.style.visibility = 'visible'; - } - } else { - console.log('[ACCOUNT-DELETION] Not on privacy page, hiding account deletion section'); - - // Hide the account deletion section - if (accountDeletionSection) { - accountDeletionSection.style.display = 'none'; - accountDeletionSection.style.visibility = 'hidden'; - } - - // Only hide the auth wrapper if we're not on the privacy page - if (authOnlyWrapper && !isPrivacyPage) { - authOnlyWrapper.style.display = 'none'; - authOnlyWrapper.style.visibility = 'hidden'; - } - } - - // Debug: Log the current state after updates - if (accountDeletionSection) { - console.log('[ACCOUNT-DELETION] Account deletion section state after show:', { - display: window.getComputedStyle(accountDeletionSection).display, - visibility: window.getComputedStyle(accountDeletionSection).visibility, - classes: accountDeletionSection.className, - parent: accountDeletionSection.parentElement ? { - tag: accountDeletionSection.parentElement.tagName, - classes: accountDeletionSection.parentElement.className, - display: window.getComputedStyle(accountDeletionSection.parentElement).display - } : 'no parent' - }); - } - - } else { - console.log('[ACCOUNT-DELETION] User is not authenticated, hiding account deletion section'); - - // Hide the account deletion section but keep the auth-only wrapper for other potential content - if (accountDeletionSection) { - hideElement(accountDeletionSection); - } - - // Only hide the auth-only wrapper if it doesn't contain other important content - if (authOnlyWrapper) { - const hasOtherContent = Array.from(authOnlyWrapper.children).some( - child => child.id !== 'account-deletion' && child.offsetParent !== null - ); - - if (!hasOtherContent) { - hideElement(authOnlyWrapper); - } - } - } - - // Log final state for debugging - console.log('[ACCOUNT-DELETION] Final state:', { - authOnlyWrapper: authOnlyWrapper ? { - display: window.getComputedStyle(authOnlyWrapper).display, - visibility: window.getComputedStyle(authOnlyWrapper).visibility, - classes: authOnlyWrapper.className - } : 'not found', - accountDeletionSection: accountDeletionSection ? { - display: window.getComputedStyle(accountDeletionSection).display, - visibility: window.getComputedStyle(accountDeletionSection).visibility, - classes: accountDeletionSection.className, - parent: accountDeletionSection.parentElement ? { - tag: accountDeletionSection.parentElement.tagName, - classes: accountDeletionSection.parentElement.className, - display: window.getComputedStyle(accountDeletionSection.parentElement).display - } : 'no parent' - } : 'not found' - }); -} - -// Check authentication state and update UI with caching and debouncing -function checkAuthState(force = false) { - const now = Date.now(); - - // Return cached value if still valid and not forcing a refresh - if (!force && now - authStateCache.timestamp < authStateCache.ttl && authStateCache.value !== null) { - return authStateCache.value; - } - - // Debounce rapid calls - if (now - lastAuthCheckTime < AUTH_CHECK_DEBOUNCE && !force) { - return wasAuthenticated === true; - } - - lastAuthCheckTime = now; - authCheckCounter++; - - // Use a single check for authentication state - let isAuthenticated = false; - - // Check the most likely indicators first for better performance - isAuthenticated = - document.cookie.includes('isAuthenticated=') || - document.cookie.includes('uid=') || - localStorage.getItem('isAuthenticated') === 'true' || - !!localStorage.getItem('authToken'); - - // Update cache - authStateCache = { - timestamp: now, - value: isAuthenticated, - ttl: isAuthenticated ? 30000 : 5000 // Longer TTL for authenticated users - }; - - if (DEBUG_AUTH_STATE && isAuthenticated !== wasAuthenticated) { - console.log('Auth State Check:', { - isAuthenticated, - wasAuthenticated, - cacheHit: !force && now - authStateCache.timestamp < authStateCache.ttl, - cacheAge: now - authStateCache.timestamp - }); - } - - // Only update if authentication state has changed - if (isAuthenticated !== wasAuthenticated) { - if (DEBUG_AUTH_STATE) { - console.log('Auth state changed, updating navigation...'); - } - - // Update UI state - if (isAuthenticated) { - document.body.classList.add('authenticated'); - document.body.classList.remove('guest'); - } else { - document.body.classList.remove('authenticated'); - document.body.classList.add('guest'); - } - - // Update navigation - if (typeof injectNavigation === 'function') { - injectNavigation(isAuthenticated); - } else if (DEBUG_AUTH_STATE) { - console.warn('injectNavigation function not found'); - } - - // Update account deletion section visibility - updateAccountDeletionVisibility(isAuthenticated); - - // Update the tracked state - wasAuthenticated = isAuthenticated; - - // Force reflow to ensure CSS updates - void document.body.offsetHeight; - } - - return isAuthenticated; -} - -// Periodically check authentication state with optimized polling -function setupAuthStatePolling() { - // Initial check with force to ensure we get the latest state - checkAuthState(true); - - // Use a single interval for all checks - const checkAndUpdate = () => { - // Only force check if the page is visible - checkAuthState(!document.hidden); - }; - - // Check every 30 seconds (reduced from previous implementation) - const AUTH_CHECK_INTERVAL = 30000; - setInterval(checkAndUpdate, AUTH_CHECK_INTERVAL); - - // Listen for storage events (like login/logout from other tabs) - const handleStorageEvent = (e) => { - if (['isAuthenticated', 'authToken', 'uid'].includes(e.key)) { - checkAuthState(true); // Force check on relevant storage changes - } - }; - - window.addEventListener('storage', handleStorageEvent); - - // Check auth state when page becomes visible - const handleVisibilityChange = () => { - if (!document.hidden) { - checkAuthState(true); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - - // Cleanup function - return () => { - window.removeEventListener('storage', handleStorageEvent); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; -} - - -// Function to handle page navigation -function handlePageNavigation() { - const isAuthenticated = checkAuthState(); - updateAccountDeletionVisibility(isAuthenticated); + handleHashChange(); // Initial call } // Initialize the application when DOM is loaded document.addEventListener("DOMContentLoaded", () => { - // Set up authentication state monitoring - setupAuthStatePolling(); - - // Handle magic link redirect if needed - handleMagicLoginRedirect(); - - // Initialize components initNavigation(); - - // Register with global audio manager to handle stop requests from other players - globalAudioManager.addListener('personal', () => { - console.log('[app.js] Received stop request from global audio manager'); - const audio = getOrCreateAudioElement(); - if (audio && !audio.paused) { - audio.pause(); - const playButton = document.querySelector('.play-pause-btn'); - if (playButton) { - updatePlayPauseButton(audio, playButton); - } - } - }); - - // Initialize account deletion section visibility - handlePageNavigation(); - - // Listen for hash changes to update visibility when navigating - window.addEventListener('hashchange', handlePageNavigation); - - // Initialize profile player after a short delay - setTimeout(() => { - initProfilePlayer(); - - // Set up play/pause button click handler - document.addEventListener('click', (e) => { - const playPauseBtn = e.target.closest('.play-pause-btn'); - if (!playPauseBtn || playPauseBtn.id === 'logout-button') return; - - const audio = getOrCreateAudioElement(); - if (!audio) return; - - try { - if (audio.paused) { - // Stop any currently playing audio first - if (window.currentlyPlayingAudio && window.currentlyPlayingAudio !== audio) { - window.currentlyPlayingAudio.pause(); - if (window.currentlyPlayingButton) { - updatePlayPauseButton(window.currentlyPlayingAudio, window.currentlyPlayingButton); - } - } - - // Stop any playing public streams - const publicPlayers = document.querySelectorAll('.stream-player audio'); - publicPlayers.forEach(player => { - if (!player.paused) { - player.pause(); - const btn = player.closest('.stream-player').querySelector('.play-pause-btn'); - if (btn) updatePlayPauseButton(player, btn); - } - }); - - // Check if audio has a valid source before attempting to play - // Only show this message for the main player, not public streams - if (!audio.src && !playPauseBtn.closest('.stream-player')) { - console.log('No audio source available for main player'); - showToast('No audio file available. Please upload an audio file first.', 'info'); - audioPlaying = false; - updatePlayPauseButton(audio, playPauseBtn); - return; - } - - // Notify global audio manager that personal player is starting - const uid = localStorage.getItem('uid') || 'personal-stream'; - globalAudioManager.startPlayback('personal', uid); - - // Store the current play promise to handle aborts - const playPromise = audio.play(); - - // Handle successful play - playPromise.then(() => { - // Only update state if this is still the current play action - if (audio === getMainAudio()) { - window.currentlyPlayingAudio = audio; - window.currentlyPlayingButton = playPauseBtn; - updatePlayPauseButton(audio, playPauseBtn); - } - }).catch(e => { - // Don't log aborted errors as they're normal during rapid play/pause - if (e.name !== 'AbortError') { - console.error('Play failed:', e); - } else { - console.log('Playback was aborted as expected'); - return; // Skip UI updates for aborted play - } - - // Only update state if this is still the current audio element - if (audio === getMainAudio()) { - audioPlaying = false; - updatePlayPauseButton(audio, playPauseBtn); - - // Provide more specific error messages - if (e.name === 'NotSupportedError' || e.name === 'NotAllowedError') { - showToast('Could not play audio. The format may not be supported.', 'error'); - } else if (e.name !== 'AbortError') { // Skip toast for aborted errors - showToast('Failed to play audio. Please try again.', 'error'); - } - } - }); - } else { - audio.pause(); - - // Notify global audio manager that personal player has stopped - globalAudioManager.stopPlayback('personal'); - - if (window.currentlyPlayingAudio === audio) { - window.currentlyPlayingAudio = null; - window.currentlyPlayingButton = null; - } - updatePlayPauseButton(audio, playPauseBtn); - } - } catch (e) { - console.error('Audio error:', e); - updatePlayPauseButton(audio, playPauseBtn); - } - }); - - // Set up delete account button if it exists - const deleteAccountBtn = document.getElementById('delete-account'); - const deleteAccountFromPrivacyBtn = document.getElementById('delete-account-from-privacy'); - - const deleteAccount = async (e) => { - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - - if (!confirm('Are you sure you want to delete your account?\n\nThis action cannot be undone.')) { - return; - } - - // Show loading state - const deleteBtn = e?.target.closest('button'); - const originalText = deleteBtn?.textContent || 'Delete My Account'; - if (deleteBtn) { - deleteBtn.disabled = true; - deleteBtn.textContent = 'Deleting...'; - } - - try { - // Get UID from localStorage - const uid = localStorage.getItem('uid'); - if (!uid) { - throw new Error('User not authenticated. Please log in again.'); - } - - console.log('Sending delete account request for UID:', uid); - const response = await fetch('/api/delete-account', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ - uid: uid // Include UID in the request body - }) - }); - - console.log('Received response status:', response.status, response.statusText); - - // Try to parse response as JSON, but handle non-JSON responses - let data; - const text = await response.text(); - try { - data = text ? JSON.parse(text) : {}; - } catch (parseError) { - console.error('Failed to parse response as JSON:', parseError); - console.log('Raw response text:', text); - data = {}; - } - - if (response.ok) { - console.log('Account deletion successful'); - showToast('✅ Account deleted successfully', 'success'); - // Clear local storage and redirect to home page after a short delay - setTimeout(() => { - localStorage.clear(); - window.location.href = '/'; - }, 1000); - } else { - console.error('Delete account failed:', { status: response.status, data }); - const errorMessage = data.detail || data.message || - data.error || - `Server returned ${response.status} ${response.statusText}`; - throw new Error(errorMessage); - } - } catch (error) { - console.error('Error in deleteAccount:', { - name: error.name, - message: error.message, - stack: error.stack, - error: error - }); - - // Try to extract a meaningful error message - let errorMessage = 'Failed to delete account'; - if (error instanceof Error) { - errorMessage = error.message || error.toString(); - } else if (typeof error === 'string') { - errorMessage = error; - } else if (error && typeof error === 'object') { - errorMessage = error.message || JSON.stringify(error); - } - - showToast(`❌ ${errorMessage}`, 'error'); - } finally { - // Restore button state - if (deleteBtn) { - deleteBtn.disabled = false; - deleteBtn.textContent = originalText; - } - } - }; - - // Add event listeners to both delete account buttons - if (deleteAccountBtn) { - deleteAccountBtn.addEventListener('click', deleteAccount); - } - - if (deleteAccountFromPrivacyBtn) { - deleteAccountFromPrivacyBtn.addEventListener('click', deleteAccount); - } - - }, 200); // End of setTimeout + initPersonalPlayer(); }); - -// Logout function -async function logout(event) { - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - - // If handleLogout is available in dashboard.js, use it for comprehensive logout - if (typeof handleLogout === 'function') { - try { - await handleLogout(event); - } catch (error) { - console.error('Error during logout:', error); - // Fall back to basic logout if handleLogout fails - basicLogout(); - } - } else { - // Fallback to basic logout if handleLogout is not available - basicLogout(); - } -} - -// Basic client-side logout as fallback -function basicLogout() { - // Clear authentication state - document.body.classList.remove('authenticated'); - localStorage.removeItem('isAuthenticated'); - localStorage.removeItem('uid'); - localStorage.removeItem('confirmed_uid'); - localStorage.removeItem('uid_time'); - localStorage.removeItem('authToken'); - - // Clear all cookies with proper SameSite attribute - document.cookie.split(';').forEach(cookie => { - const [name] = cookie.trim().split('='); - if (name) { - document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=${window.location.hostname}; SameSite=Lax`; - } - }); - - // Stop any playing audio - stopMainAudio(); - - // Force a hard redirect to ensure all state is cleared - window.location.href = '/'; -} - -// Add click handler for logout button -document.addEventListener('click', (e) => { - if (e.target.id === 'logout-button' || e.target.closest('#logout-button')) { - e.preventDefault(); - logout(); - } -}); - -// Expose functions for global access -window.logToServer = logToServer; -window.getMainAudio = () => globalAudio; -window.stopMainAudio = () => { - if (globalAudio) { - globalAudio.pause(); - audioPlaying = false; - updatePlayPauseButton(); - } -}; -window.loadProfileStream = loadProfileStream; diff --git a/static/audio-player.js b/static/audio-player.js new file mode 100644 index 0000000..779e88d --- /dev/null +++ b/static/audio-player.js @@ -0,0 +1,442 @@ +/** + * Audio Player Module + * A shared audio player implementation based on the working "Your Stream" player + */ + +import { globalAudioManager } from './global-audio-manager.js'; + +export class AudioPlayer { + constructor() { + // Audio state + this.audioElement = null; + this.currentUid = null; + this.isPlaying = false; + this.currentButton = null; + this.audioUrl = ''; + this.lastPlayTime = 0; + this.isLoading = false; + this.loadTimeout = null; // For tracking loading timeouts + + // Create a single audio element that we'll reuse + this.audioElement = new Audio(); + this.audioElement.preload = 'none'; + this.audioElement.crossOrigin = 'anonymous'; + + // Bind methods + this.loadAndPlay = this.loadAndPlay.bind(this); + this.stop = this.stop.bind(this); + this.cleanup = this.cleanup.bind(this); + + // Register with global audio manager to handle stop requests from other players + globalAudioManager.addListener('personal', () => { + console.log('[audio-player] Received stop request from global audio manager'); + this.stop(); + }); + } + + /** + * Load and play audio for a specific UID + * @param {string} uid - The user ID for the audio stream + * @param {HTMLElement} button - The play/pause button element + */ + /** + * Validates that a UID is in the correct UUID format + * @param {string} uid - The UID to validate + * @returns {boolean} True if valid, false otherwise + */ + isValidUuid(uid) { + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(uid); + } + + /** + * Logs an error and updates the button state + * @param {HTMLElement} button - The button to update + * @param {string} message - Error message to log + */ + handleError(button, message) { + console.error(message); + if (button) { + this.updateButtonState(button, 'error'); + } + } + + async loadAndPlay(uid, button) { + // Validate UID exists and is in correct format + if (!uid) { + this.handleError(button, 'No UID provided for audio playback'); + return; + } + + if (!this.isValidUuid(uid)) { + this.handleError(button, `Invalid UID format: ${uid}. Expected UUID v4 format.`); + return; + } + + // If we're in the middle of loading, check if it's for the same UID + if (this.isLoading) { + // If same UID, ignore duplicate request + if (this.currentUid === uid) { + console.log('Already loading this UID, ignoring duplicate request:', uid); + return; + } + // If different UID, queue the new request + console.log('Already loading, queuing request for UID:', uid); + setTimeout(() => this.loadAndPlay(uid, button), 500); + return; + } + + // If already playing this stream, just toggle pause/play + if (this.currentUid === uid && this.audioElement) { + try { + if (this.isPlaying) { + console.log('Pausing current playback'); + try { + this.audioElement.pause(); + this.lastPlayTime = this.audioElement.currentTime; + this.isPlaying = false; + this.updateButtonState(button, 'paused'); + } catch (pauseError) { + console.warn('Error pausing audio, continuing with state update:', pauseError); + this.isPlaying = false; + this.updateButtonState(button, 'paused'); + } + } else { + console.log('Resuming playback from time:', this.lastPlayTime); + try { + // If we have a last play time, seek to it + if (this.lastPlayTime > 0) { + this.audioElement.currentTime = this.lastPlayTime; + } + await this.audioElement.play(); + this.isPlaying = true; + this.updateButtonState(button, 'playing'); + } catch (playError) { + console.error('Error resuming playback, reloading source:', playError); + // If resume fails, try reloading the source + this.currentUid = null; // Force reload of the source + return this.loadAndPlay(uid, button); + } + } + return; // Exit after handling pause/resume + } catch (error) { + console.error('Error toggling playback:', error); + this.updateButtonState(button, 'error'); + return; + } + } + + // If we get here, we're loading a new stream + this.isLoading = true; + this.currentUid = uid; + this.currentButton = button; + this.isPlaying = true; + this.updateButtonState(button, 'loading'); + + // Notify global audio manager that personal player is starting + globalAudioManager.startPlayback('personal', uid); + + try { + // Only clean up if switching streams + if (this.currentUid !== uid) { + this.cleanup(); + } + + // Store the current button reference + this.currentButton = button; + this.currentUid = uid; + + // Create a new audio element if we don't have one + if (!this.audioElement) { + this.audioElement = new Audio(); + } else if (this.audioElement.readyState > 0) { + // If we already have a loaded source, just play it + try { + await this.audioElement.play(); + this.isPlaying = true; + this.updateButtonState(button, 'playing'); + return; + } catch (playError) { + console.warn('Error playing existing source, will reload:', playError); + // Continue to load a new source + } + } + + // Clear any existing sources + while (this.audioElement.firstChild) { + this.audioElement.removeChild(this.audioElement.firstChild); + } + + // Set the source URL with proper encoding and cache-busting timestamp + // Using the format: /audio/{uid}/stream.opus?t={timestamp} + const timestamp = new Date().getTime(); + this.audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${timestamp}`; + console.log('Loading audio from URL:', this.audioUrl); + this.audioElement.src = this.audioUrl; + + // Load the new source (don't await, let canplay handle it) + try { + this.audioElement.load(); + // If load() doesn't throw, we'll wait for canplay event + } catch (e) { + // Ignore abort errors as they're expected during rapid toggling + if (e.name !== 'AbortError') { + console.error('Error loading audio source:', e); + this.isLoading = false; + this.updateButtonState(button, 'error'); + } + } + + // Reset the current time when loading a new source + this.audioElement.currentTime = 0; + this.lastPlayTime = 0; + + // Set up error handling + this.audioElement.onerror = (e) => { + console.error('Audio element error:', e, this.audioElement.error); + this.isLoading = false; + this.updateButtonState(button, 'error'); + }; + + // Handle when audio is ready to play + const onCanPlay = () => { + this.audioElement.removeEventListener('canplay', onCanPlay); + this.isLoading = false; + if (this.lastPlayTime > 0) { + this.audioElement.currentTime = this.lastPlayTime; + } + this.audioElement.play().then(() => { + this.isPlaying = true; + this.updateButtonState(button, 'playing'); + }).catch(e => { + console.error('Error playing after load:', e); + this.updateButtonState(button, 'error'); + }); + }; + + // Define the error handler + const errorHandler = (e) => { + console.error('Audio element error:', e, this.audioElement.error); + this.isLoading = false; + this.updateButtonState(button, 'error'); + }; + + // Define the play handler + const playHandler = () => { + // Clear any pending timeouts + if (this.loadTimeout) { + clearTimeout(this.loadTimeout); + this.loadTimeout = null; + } + + this.audioElement.removeEventListener('canplay', playHandler); + this.isLoading = false; + + if (this.lastPlayTime > 0) { + this.audioElement.currentTime = this.lastPlayTime; + } + + this.audioElement.play().then(() => { + this.isPlaying = true; + this.updateButtonState(button, 'playing'); + }).catch(e => { + console.error('Error playing after load:', e); + this.isPlaying = false; + this.updateButtonState(button, 'error'); + }); + }; + + // Add event listeners + this.audioElement.addEventListener('error', errorHandler, { once: true }); + this.audioElement.addEventListener('canplay', playHandler, { once: true }); + + // Load and play the new source + try { + await this.audioElement.load(); + // Don't await play() here, let the canplay handler handle it + + // Set a timeout to handle cases where canplay doesn't fire + this.loadTimeout = setTimeout(() => { + if (this.isLoading) { + console.warn('Audio loading timed out for UID:', uid); + this.isLoading = false; + this.updateButtonState(button, 'error'); + } + }, 10000); // 10 second timeout + + } catch (e) { + console.error('Error loading audio:', e); + this.isLoading = false; + this.updateButtonState(button, 'error'); + + // Clear any pending timeouts + if (this.loadTimeout) { + clearTimeout(this.loadTimeout); + this.loadTimeout = null; + } + } + + } catch (error) { + console.error('Error in loadAndPlay:', error); + + // Only cleanup and show error if we're still on the same track + if (this.currentUid === uid) { + this.cleanup(); + this.updateButtonState(button, 'error'); + } + } + } + + /** + * Stop playback and clean up resources + */ + stop() { + try { + if (this.audioElement) { + console.log('Stopping audio playback'); + this.audioElement.pause(); + this.lastPlayTime = this.audioElement.currentTime; + this.isPlaying = false; + + // Notify global audio manager that personal player has stopped + globalAudioManager.stopPlayback('personal'); + + if (this.currentButton) { + this.updateButtonState(this.currentButton, 'paused'); + } + } + } catch (error) { + console.error('Error stopping audio:', error); + // Don't throw, just log the error + } + } + + /** + * Clean up resources + */ + cleanup() { + // Update button state if we have a reference to the current button + if (this.currentButton) { + this.updateButtonState(this.currentButton, 'paused'); + } + + // Pause the audio and store the current time + if (this.audioElement) { + try { + try { + this.audioElement.pause(); + this.lastPlayTime = this.audioElement.currentTime; + } catch (e) { + console.warn('Error pausing audio during cleanup:', e); + } + + try { + // Clear any existing sources + while (this.audioElement.firstChild) { + this.audioElement.removeChild(this.audioElement.firstChild); + } + + // Clear the source and reset the audio element + this.audioElement.removeAttribute('src'); + try { + this.audioElement.load(); + } catch (e) { + console.warn('Error in audio load during cleanup:', e); + } + } catch (e) { + console.warn('Error cleaning up audio sources:', e); + } + } catch (e) { + console.warn('Error during audio cleanup:', e); + } + } + + // Reset state + this.currentUid = null; + this.currentButton = null; + this.audioUrl = ''; + this.isPlaying = false; + + // Notify global audio manager that personal player has stopped + globalAudioManager.stopPlayback('personal'); + } + + /** + * Update the state of a play/pause button + * @param {HTMLElement} button - The button to update + * @param {string} state - The state to set ('playing', 'paused', 'loading', 'error') + */ + updateButtonState(button, state) { + if (!button) return; + + // Only update the current button's state + if (state === 'playing') { + // If this button is now playing, update all buttons + document.querySelectorAll('.play-pause-btn').forEach(btn => { + btn.classList.remove('playing', 'paused', 'loading', 'error'); + if (btn === button) { + btn.classList.add('playing'); + } else { + btn.classList.add('paused'); + } + }); + } else { + // For other states, just update the target button + button.classList.remove('playing', 'paused', 'loading', 'error'); + if (state) { + button.classList.add(state); + } + } + + // Update button icon and aria-label for the target button + const icon = button.querySelector('i'); + if (icon) { + if (state === 'playing') { + icon.className = 'fas fa-pause'; + button.setAttribute('aria-label', 'Pause'); + } else { + icon.className = 'fas fa-play'; + button.setAttribute('aria-label', 'Play'); + } + } + } +} + +// Create a singleton instance +export const audioPlayer = new AudioPlayer(); + +// Export utility functions for direct use +export function initAudioPlayer(container = document) { + // Set up event delegation for play/pause buttons + container.addEventListener('click', (e) => { + const playButton = e.target.closest('.play-pause-btn'); + if (!playButton) return; + + e.preventDefault(); + e.stopPropagation(); + + const uid = playButton.dataset.uid; + if (!uid) return; + + audioPlayer.loadAndPlay(uid, playButton); + }); + + // Set up event delegation for stop buttons if they exist + container.addEventListener('click', (e) => { + const stopButton = e.target.closest('.stop-btn'); + if (!stopButton) return; + + e.preventDefault(); + e.stopPropagation(); + + audioPlayer.stop(); + }); +} + +// Auto-initialize if this is the main module +if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => { + initAudioPlayer(); + }); +} diff --git a/static/auth-ui.js b/static/auth-ui.js index fd25139..20e718b 100644 --- a/static/auth-ui.js +++ b/static/auth-ui.js @@ -1,5 +1,5 @@ // static/auth-ui.js — navigation link and back-button handlers -import { showOnly } from './router.js'; +import { showSection } from './nav.js'; // Data-target navigation (e.g., at #links) export function initNavLinks() { @@ -10,7 +10,7 @@ export function initNavLinks() { if (!a || !linksContainer.contains(a)) return; e.preventDefault(); const target = a.dataset.target; - if (target) showOnly(target); + if (target) showSection(target); const burger = document.getElementById('burger-toggle'); if (burger && burger.checked) burger.checked = false; }); @@ -22,7 +22,7 @@ export function initBackButtons() { btn.addEventListener('click', e => { e.preventDefault(); const target = btn.dataset.back; - if (target) showOnly(target); + if (target) showSection(target); }); }); } diff --git a/static/auth.js b/static/auth.js new file mode 100644 index 0000000..8ceee05 --- /dev/null +++ b/static/auth.js @@ -0,0 +1,252 @@ +import { showToast } from './toast.js'; + +import { loadProfileStream } from './personal-player.js'; + +document.addEventListener('DOMContentLoaded', () => { + // Track previous authentication state + let wasAuthenticated = null; + // Debug flag - set to false to disable auth state change logs + const DEBUG_AUTH_STATE = false; + + // Track auth check calls and cache state + let lastAuthCheckTime = 0; + let authCheckCounter = 0; + const AUTH_CHECK_DEBOUNCE = 1000; // 1 second + let authStateCache = { + timestamp: 0, + value: null, + ttl: 5000 // Cache TTL in milliseconds + }; + + // Handle magic link login redirect + function handleMagicLoginRedirect() { + const params = new URLSearchParams(window.location.search); + if (params.get('login') === 'success' && params.get('confirmed_uid')) { + const username = params.get('confirmed_uid'); + console.log('Magic link login detected for user:', username); + + // Update authentication state + localStorage.setItem('uid', username); + localStorage.setItem('confirmed_uid', username); + localStorage.setItem('uid_time', Date.now().toString()); + document.cookie = `uid=${encodeURIComponent(username)}; path=/; SameSite=Lax`; + + // Update UI state + document.body.classList.add('authenticated'); + document.body.classList.remove('guest'); + + // Update local storage and cookies + localStorage.setItem('isAuthenticated', 'true'); + document.cookie = `isAuthenticated=true; path=/; SameSite=Lax`; + + // Update URL and history without reloading + window.history.replaceState({}, document.title, window.location.pathname); + + // Update navigation + if (typeof injectNavigation === 'function') { + console.log('Updating navigation after magic link login'); + injectNavigation(true); + } else { + console.warn('injectNavigation function not available after magic link login'); + } + + // Navigate to user's profile page + if (window.showOnly) { + console.log('Navigating to me-page'); + window.showOnly('me-page'); + } else if (window.location.hash !== '#me') { + window.location.hash = '#me'; + } + + // Auth state will be updated by the polling mechanism + } + } + + // Update the visibility of the account deletion section based on authentication state + function updateAccountDeletionVisibility(isAuthenticated) { + const authOnlyWrapper = document.querySelector('#privacy-page .auth-only'); + const accountDeletionSection = document.getElementById('account-deletion'); + + const showElement = (element) => { + if (!element) return; + element.classList.remove('hidden', 'auth-only-hidden'); + element.style.display = 'block'; + }; + + const hideElement = (element) => { + if (!element) return; + element.style.display = 'none'; + }; + + if (isAuthenticated) { + const isPrivacyPage = window.location.hash === '#privacy-page'; + if (isPrivacyPage) { + if (authOnlyWrapper) showElement(authOnlyWrapper); + if (accountDeletionSection) showElement(accountDeletionSection); + } else { + if (accountDeletionSection) hideElement(accountDeletionSection); + if (authOnlyWrapper) hideElement(authOnlyWrapper); + } + } else { + if (accountDeletionSection) hideElement(accountDeletionSection); + if (authOnlyWrapper) { + const hasOtherContent = Array.from(authOnlyWrapper.children).some( + child => child.id !== 'account-deletion' && child.offsetParent !== null + ); + if (!hasOtherContent) { + hideElement(authOnlyWrapper); + } + } + } + } + + // Check authentication state and update UI with caching and debouncing + function checkAuthState(force = false) { + const now = Date.now(); + if (!force && authStateCache.value !== null && now - authStateCache.timestamp < authStateCache.ttl) { + return authStateCache.value; + } + + if (now - lastAuthCheckTime < AUTH_CHECK_DEBOUNCE && !force) { + return wasAuthenticated; + } + lastAuthCheckTime = now; + authCheckCounter++; + + const isAuthenticated = + (document.cookie.includes('isAuthenticated=true') || localStorage.getItem('isAuthenticated') === 'true') && + (document.cookie.includes('uid=') || localStorage.getItem('uid')) && + !!localStorage.getItem('authToken'); + + authStateCache = { + timestamp: now, + value: isAuthenticated, + ttl: isAuthenticated ? 30000 : 5000 + }; + + if (isAuthenticated !== wasAuthenticated) { + if (DEBUG_AUTH_STATE) { + console.log('Auth state changed, updating UI...'); + } + + if (!isAuthenticated && wasAuthenticated) { + console.log('User was authenticated, but is no longer. Triggering logout.'); + basicLogout(); + return; // Stop further processing after logout + } + + if (isAuthenticated) { + document.body.classList.add('authenticated'); + document.body.classList.remove('guest'); + const uid = localStorage.getItem('uid'); + if (uid && (window.location.hash === '#me-page' || window.location.hash === '#me' || window.location.pathname.startsWith('/~'))) { + loadProfileStream(uid); + } + } else { + document.body.classList.remove('authenticated'); + document.body.classList.add('guest'); + } + + updateAccountDeletionVisibility(isAuthenticated); + wasAuthenticated = isAuthenticated; + void document.body.offsetHeight; // Force reflow + } + + return isAuthenticated; + } + + // Periodically check authentication state with optimized polling + function setupAuthStatePolling() { + checkAuthState(true); + + const checkAndUpdate = () => { + checkAuthState(!document.hidden); + }; + + const AUTH_CHECK_INTERVAL = 30000; + setInterval(checkAndUpdate, AUTH_CHECK_INTERVAL); + + const handleStorageEvent = (e) => { + if (['isAuthenticated', 'authToken', 'uid'].includes(e.key)) { + checkAuthState(true); + } + }; + + window.addEventListener('storage', handleStorageEvent); + + const handleVisibilityChange = () => { + if (!document.hidden) { + checkAuthState(true); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('storage', handleStorageEvent); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + } + + // --- ACCOUNT DELETION --- + const deleteAccount = async (e) => { + if (e) e.preventDefault(); + if (deleteAccount.inProgress) return; + if (!confirm('Are you sure you want to delete your account?\nThis action is permanent.')) return; + + deleteAccount.inProgress = true; + const deleteBtn = e?.target.closest('button'); + const originalText = deleteBtn?.textContent; + if (deleteBtn) { + deleteBtn.disabled = true; + deleteBtn.textContent = 'Deleting...'; + } + + try { + const response = await fetch('/api/delete-account', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ uid: localStorage.getItem('uid') }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Failed to delete account.' })); + throw new Error(errorData.detail); + } + + showToast('Account deleted successfully.', 'success'); + // Perform a full client-side logout and redirect + basicLogout(); + } catch (error) { + showToast(error.message, 'error'); + } finally { + deleteAccount.inProgress = false; + if (deleteBtn) { + deleteBtn.disabled = false; + deleteBtn.textContent = originalText; + } + } + }; + + // --- LOGOUT --- + function basicLogout() { + ['isAuthenticated', 'uid', 'confirmed_uid', 'uid_time', 'authToken'].forEach(k => localStorage.removeItem(k)); + document.cookie.split(';').forEach(c => document.cookie = c.replace(/^ +/, '').replace(/=.*/, `=;expires=${new Date().toUTCString()};path=/`)); + window.location.href = '/'; + } + + // --- DELEGATED EVENT LISTENERS --- + document.addEventListener('click', (e) => { + + // Delete Account Buttons + if (e.target.closest('#delete-account') || e.target.closest('#delete-account-from-privacy')) { + deleteAccount(e); + return; + } + }); + + // --- INITIALIZATION --- + handleMagicLoginRedirect(); + setupAuthStatePolling(); +}); diff --git a/static/dashboard.js b/static/dashboard.js index 1c931fb..fa0f9f6 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -1,4 +1,5 @@ import { showToast } from "./toast.js"; +import { showSection } from './nav.js'; // Utility function to get cookie value by name function getCookie(name) { @@ -14,6 +15,7 @@ let isLoggingOut = false; async function handleLogout(event) { console.log('[LOGOUT] Logout initiated'); + // Prevent multiple simultaneous logout attempts if (isLoggingOut) { console.log('[LOGOUT] Logout already in progress'); @@ -27,18 +29,66 @@ async function handleLogout(event) { event.stopPropagation(); } - // Get auth token before we clear it - const authToken = localStorage.getItem('authToken'); - - // 1. First try to invalidate the server session (but don't block on it) - if (authToken) { - try { - // We'll use a timeout to prevent hanging on the server request - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 2000); - + try { + // Get auth token before we clear it + const authToken = localStorage.getItem('authToken'); + + // 1. Clear all client-side state first (most important) + console.log('[LOGOUT] Clearing all client-side state'); + + // Clear localStorage and sessionStorage + const storageKeys = [ + 'uid', 'uid_time', 'confirmed_uid', 'last_page', + 'isAuthenticated', 'authToken', 'user', 'token', 'sessionid', 'sessionId' + ]; + + storageKeys.forEach(key => { + localStorage.removeItem(key); + sessionStorage.removeItem(key); + }); + + // Get all current cookies for debugging + const allCookies = document.cookie.split(';'); + console.log('[LOGOUT] Current cookies before clearing:', allCookies); + + // Clear ALL cookies (aggressive approach) + allCookies.forEach(cookie => { + const [name] = cookie.trim().split('='); + if (name) { + const cookieName = name.trim(); + console.log(`[LOGOUT] Clearing cookie: ${cookieName}`); + + // Try multiple clearing strategies to ensure cookies are removed + const clearStrategies = [ + `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`, + `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`, + `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${window.location.hostname};`, + `${cookieName}=; max-age=0; path=/;`, + `${cookieName}=; max-age=0; path=/; domain=${window.location.hostname};` + ]; + + clearStrategies.forEach(strategy => { + document.cookie = strategy; + }); + } + }); + + // Verify cookies are cleared + const remainingCookies = document.cookie.split(';').filter(c => c.trim()); + console.log('[LOGOUT] Remaining cookies after clearing:', remainingCookies); + + // Update UI state + document.body.classList.remove('authenticated', 'logged-in'); + document.body.classList.add('guest'); + + // 2. Try to invalidate server session (non-blocking) + if (authToken) { try { - await fetch('/api/logout', { + console.log('[LOGOUT] Attempting to invalidate server session'); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + + const response = await fetch('/api/logout', { method: 'POST', credentials: 'include', signal: controller.signal, @@ -47,141 +97,27 @@ async function handleLogout(event) { 'Authorization': `Bearer ${authToken}` }, }); - clearTimeout(timeoutId); - } catch (error) { - clearTimeout(timeoutId); - // Silently handle any errors during server logout - } - } catch (error) { - // Silently handle any unexpected errors - } - } - - // 2. Clear all client-side state - function clearClientState() { - console.log('[LOGOUT] Clearing client state'); - - // Clear all authentication-related data from localStorage - const keysToRemove = [ - 'uid', 'uid_time', 'confirmed_uid', 'last_page', - 'isAuthenticated', 'authToken', 'user', 'token', 'sessionid' - ]; - - keysToRemove.forEach(key => { - localStorage.removeItem(key); - sessionStorage.removeItem(key); - }); - - // Get current cookies for debugging - const cookies = document.cookie.split(';'); - console.log('[LOGOUT] Current cookies before clearing:', cookies); - - // Function to clear a cookie by name - const clearCookie = (name) => { - console.log(`[LOGOUT] Attempting to clear cookie: ${name}`); - const baseOptions = 'Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=Lax'; - // Try with current domain - document.cookie = `${name}=; ${baseOptions}`; - // Try with domain - document.cookie = `${name}=; ${baseOptions}; domain=${window.location.hostname}`; - // Try with leading dot for subdomains - document.cookie = `${name}=; ${baseOptions}; domain=.${window.location.hostname}`; - }; - - // Clear all authentication-related cookies - const authCookies = [ - 'uid', 'authToken', 'isAuthenticated', 'sessionid', 'session_id', - 'token', 'remember_token', 'auth', 'authentication' - ]; - - // Clear specific auth cookies - authCookies.forEach(clearCookie); - - // Also clear any existing cookies that match our patterns - cookies.forEach(cookie => { - const [name] = cookie.trim().split('='); - if (name && authCookies.some(authName => name.trim() === authName)) { - clearCookie(name.trim()); - } - }); - - // Clear all cookies by setting them to expire in the past - document.cookie.split(';').forEach(cookie => { - const [name] = cookie.trim().split('='); - if (name) { - clearCookie(name.trim()); - } - }); - - console.log('[LOGOUT] Cookies after clearing:', document.cookie); - - // Update UI state - document.body.classList.remove('authenticated'); - document.body.classList.add('guest'); - - // Force a hard reload to ensure all state is reset - setTimeout(() => { - // Clear all storage again before redirecting - keysToRemove.forEach(key => { - localStorage.removeItem(key); - sessionStorage.removeItem(key); - }); - - // Redirect to home with a cache-busting parameter - window.location.href = '/?logout=' + Date.now(); - }, 100); - } - - try { - // Clear client state immediately to prevent any race conditions - clearClientState(); - - // 2. Try to invalidate the server session (but don't block on it) - console.log('[LOGOUT] Auth token exists:', !!authToken); - if (authToken) { - try { - console.log('[LOGOUT] Attempting to invalidate server session'); - const response = await fetch('/api/logout', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}` - }, - }); - - if (!response.ok && response.status !== 401) { - console.warn(`[LOGOUT] Server returned ${response.status} during logout`); - // Don't throw - we've already cleared client state - } else { - console.log('[LOGOUT] Server session invalidated successfully'); - } + clearTimeout(timeoutId); + console.log('[LOGOUT] Server session invalidation completed'); } catch (error) { - console.warn('[LOGOUT] Error during server session invalidation (non-critical):', error); - // Continue with logout process + console.warn('[LOGOUT] Server session invalidation failed (non-critical):', error); } } - // 3. Update navigation if the function exists - if (typeof injectNavigation === 'function') { - injectNavigation(false); - } - - console.log('[LOGOUT] Logout completed'); - + // 3. Final redirect + console.log('[LOGOUT] Redirecting to home page'); + window.location.href = '/?logout=' + Date.now(); + } catch (error) { - console.error('[LOGOUT] Logout failed:', error); + console.error('[LOGOUT] Unexpected error during logout:', error); if (window.showToast) { showToast('Logout failed. Please try again.'); } + // Even if there's an error, force redirect to clear state + window.location.href = '/?logout=error-' + Date.now(); } finally { isLoggingOut = false; - - // 4. Redirect to home page after a short delay to ensure state is cleared - setTimeout(() => { - window.location.href = '/'; - }, 100); } } @@ -221,11 +157,60 @@ async function handleDeleteAccount() { if (response.ok) { showToast('Account deleted successfully'); - // Clear user data - localStorage.removeItem('uid'); - localStorage.removeItem('uid_time'); - localStorage.removeItem('confirmed_uid'); - document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + // Use comprehensive logout logic to clear all cookies and storage + console.log('🧹 Account deleted - clearing all authentication data...'); + + // Clear all authentication-related data from localStorage + const keysToRemove = [ + 'uid', 'uid_time', 'confirmed_uid', 'last_page', + 'isAuthenticated', 'authToken', 'user', 'token', 'sessionid' + ]; + + keysToRemove.forEach(key => { + if (localStorage.getItem(key)) { + console.log(`Removing localStorage key: ${key}`); + localStorage.removeItem(key); + } + }); + + // Clear sessionStorage completely + sessionStorage.clear(); + console.log('Cleared sessionStorage'); + + // Clear all cookies using multiple strategies + const clearCookie = (cookieName) => { + const clearStrategies = [ + `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`, + `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`, + `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${window.location.hostname};`, + `${cookieName}=; max-age=0; path=/;`, + `${cookieName}=; max-age=0; path=/; domain=${window.location.hostname};` + ]; + + clearStrategies.forEach(strategy => { + document.cookie = strategy; + }); + console.log(`Cleared cookie: ${cookieName}`); + }; + + // Clear all cookies by setting them to expire in the past + document.cookie.split(';').forEach(cookie => { + const [name] = cookie.trim().split('='); + if (name) { + clearCookie(name.trim()); + } + }); + + // Also specifically clear known authentication cookies + const authCookies = ['authToken', 'isAuthenticated', 'sessionId', 'uid', 'token']; + authCookies.forEach(clearCookie); + + // Log remaining cookies for verification + console.log('Remaining cookies after deletion cleanup:', document.cookie); + + // Update UI state + document.body.classList.remove('authenticated'); + document.body.classList.add('guest'); // Redirect to home page setTimeout(() => { @@ -274,333 +259,54 @@ function debugElementVisibility(elementId) { */ async function initDashboard() { console.log('[DASHBOARD] Initializing dashboard...'); - - // Get all dashboard elements - const guestDashboard = document.getElementById('guest-dashboard'); - const userDashboard = document.getElementById('user-dashboard'); - const userUpload = document.getElementById('user-upload-area'); - const logoutButton = document.getElementById('logout-button'); - const deleteAccountButton = document.getElementById('delete-account-button'); - const fileList = document.getElementById('file-list'); - - // Add click event listeners for logout and delete account buttons - if (logoutButton) { - console.log('[DASHBOARD] Adding logout button handler'); - logoutButton.addEventListener('click', handleLogout); - } - - if (deleteAccountButton) { - console.log('[DASHBOARD] Adding delete account button handler'); - deleteAccountButton.addEventListener('click', (e) => { - e.preventDefault(); - handleDeleteAccount(); - }); - } - - // Check authentication state - consolidated to avoid duplicate declarations - const hasAuthCookie = document.cookie.includes('isAuthenticated=true'); - const hasUidCookie = document.cookie.includes('uid='); - const hasLocalStorageAuth = localStorage.getItem('isAuthenticated') === 'true'; - const hasAuthToken = localStorage.getItem('authToken') !== null; - const isAuthenticated = hasAuthCookie || hasUidCookie || hasLocalStorageAuth || hasAuthToken; - - // Ensure body class reflects authentication state - if (isAuthenticated) { - document.body.classList.add('authenticated'); - document.body.classList.remove('guest-mode'); - } else { - document.body.classList.remove('authenticated'); - document.body.classList.add('guest-mode'); - } - - // Debug authentication state - console.log('[AUTH] Authentication state:', { - hasAuthCookie, - hasUidCookie, - hasLocalStorageAuth, - hasAuthToken, - isAuthenticated, - cookies: document.cookie, - localStorage: { - isAuthenticated: localStorage.getItem('isAuthenticated'), - uid: localStorage.getItem('uid'), - authToken: localStorage.getItem('authToken') ? 'present' : 'not present' - }, - bodyClasses: document.body.className - }); - - // Handle authenticated user - if (isAuthenticated) { - console.log('[DASHBOARD] User is authenticated, showing user dashboard'); - if (userDashboard) userDashboard.style.display = 'block'; - if (userUpload) userUpload.style.display = 'block'; - if (guestDashboard) guestDashboard.style.display = 'none'; - - // Add authenticated class to body if not present - document.body.classList.add('authenticated'); - - // Get UID from cookies or localStorage - let uid = getCookie('uid') || localStorage.getItem('uid'); - - if (!uid) { - console.warn('[DASHBOARD] No UID found in cookies or localStorage'); - // Try to get UID from the URL or hash fragment - const urlParams = new URLSearchParams(window.location.search); - uid = urlParams.get('uid') || window.location.hash.substring(1); - - if (uid) { - console.log(`[DASHBOARD] Using UID from URL/hash: ${uid}`); - localStorage.setItem('uid', uid); - } else { - console.error('[DASHBOARD] No UID available for file listing'); - if (fileList) { - fileList.innerHTML = ` -
  • - Error: Could not determine user account. Please again. -
  • `; - } - return; - } - } - - // Initialize file listing if we have a UID - if (window.fetchAndDisplayFiles) { - console.log(`[DASHBOARD] Initializing file listing for UID: ${uid}`); - try { - await window.fetchAndDisplayFiles(uid); - } catch (error) { - console.error('[DASHBOARD] Error initializing file listing:', error); - if (fileList) { - fileList.innerHTML = ` -
  • - Error loading files: ${error.message || 'Unknown error'}. - Please again. -
  • `; - } - } - } - } else { - // Guest view - console.log('[DASHBOARD] User not authenticated, showing guest dashboard'); - if (guestDashboard) guestDashboard.style.display = 'block'; - if (userDashboard) userDashboard.style.display = 'none'; - if (userUpload) userUpload.style.display = 'none'; - - // Remove authenticated class if present - document.body.classList.remove('authenticated'); - - // Show login prompt - if (fileList) { - fileList.innerHTML = ` -
  • - Please to view your files. -
  • `; - } - } - - // Log authentication details for debugging - console.log('[DASHBOARD] Authentication details:', { - uid: getCookie('uid') || localStorage.getItem('uid'), - cookies: document.cookie, - localStorage: { - uid: localStorage.getItem('uid'), - isAuthenticated: localStorage.getItem('isAuthenticated'), - authToken: localStorage.getItem('authToken') ? 'present' : 'not present' - } - }); - - // If not authenticated, show guest view and return early - if (!isAuthenticated) { - console.log('[DASHBOARD] User not authenticated, showing guest dashboard'); - if (guestDashboard) guestDashboard.style.display = 'block'; - if (userDashboard) userDashboard.style.display = 'none'; - if (userUpload) userUpload.style.display = 'none'; - - // Remove authenticated class if present - document.body.classList.remove('authenticated'); - - // Show login prompt - if (fileList) { - fileList.innerHTML = ` -
  • - Please to view your files. -
  • `; - } - return; - } - - // Logged-in view - show user dashboard - console.log('[DASHBOARD] User is logged in, showing user dashboard'); - - // Get all page elements - const mePage = document.getElementById('me-page'); - - // Log current display states for debugging - console.log('[DASHBOARD] Updated display states:', { - guestDashboard: guestDashboard ? window.getComputedStyle(guestDashboard).display : 'not found', - userDashboard: userDashboard ? window.getComputedStyle(userDashboard).display : 'not found', - userUpload: userUpload ? window.getComputedStyle(userUpload).display : 'not found', - logoutButton: logoutButton ? window.getComputedStyle(logoutButton).display : 'not found', - deleteAccountButton: deleteAccountButton ? window.getComputedStyle(deleteAccountButton).display : 'not found', - mePage: mePage ? window.getComputedStyle(mePage).display : 'not found' - }); - - // Hide guest dashboard - if (guestDashboard) { - console.log('[DASHBOARD] Hiding guest dashboard'); - guestDashboard.style.display = 'none'; - } - - // Show user dashboard - if (userDashboard) { - console.log('[DASHBOARD] Showing user dashboard'); - userDashboard.style.display = 'block'; - userDashboard.style.visibility = 'visible'; - userDashboard.hidden = false; - - // Log final visibility state after changes - console.log('[DEBUG] Final visibility state after showing user dashboard:', { - userDashboard: debugElementVisibility('user-dashboard'), - guestDashboard: debugElementVisibility('guest-dashboard'), - computedDisplay: window.getComputedStyle(userDashboard).display, - computedVisibility: window.getComputedStyle(userDashboard).visibility - }); - - // Debug: Check if the element is actually in the DOM - console.log('[DASHBOARD] User dashboard parent:', userDashboard.parentElement); - console.log('[DASHBOARD] User dashboard computed display:', window.getComputedStyle(userDashboard).display); - } else { - console.error('[DASHBOARD] userDashboard element not found!'); - } - - // Show essential elements for logged-in users - const linksSection = document.getElementById('links'); - if (linksSection) { - console.log('[DASHBOARD] Showing links section'); - linksSection.style.display = 'block'; - } - - const showMeLink = document.getElementById('show-me'); - if (showMeLink && showMeLink.parentElement) { - console.log('[DASHBOARD] Showing show-me link'); - showMeLink.parentElement.style.display = 'block'; - } - - // Show me-page for logged-in users - if (mePage) { - console.log('[DASHBOARD] Showing me-page'); - mePage.style.display = 'block'; - } - try { - // Try to get UID from various sources - let uid = getCookie('uid') || localStorage.getItem('uid'); - - // If we have a valid UID, try to fetch user data - if (uid && uid !== 'welcome-page' && uid !== 'undefined' && uid !== 'null') { - console.log('[DASHBOARD] Found valid UID:', uid); - console.log(`[DEBUG] Fetching user data for UID: ${uid}`); - const response = await fetch(`/me/${uid}`); - if (!response.ok) { - const errorText = await response.text(); - console.error(`[ERROR] Failed to fetch user data: ${response.status} ${response.statusText}`, errorText); - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - // Parse and handle the response data - const data = await response.json(); - console.log('[DEBUG] User data loaded:', data); - - // Ensure upload area is visible if last_page was me-page - if (userUpload && localStorage.getItem('last_page') === 'me-page') { - // userUpload visibility is now only controlled by nav.js SPA logic - } + const guestDashboard = document.getElementById('guest-dashboard'); + const userDashboard = document.getElementById('user-dashboard'); + const userUpload = document.getElementById('user-upload-area'); + const logoutButton = document.getElementById('logout-button'); + const deleteAccountButton = document.getElementById('delete-account-button'); + const fileList = document.getElementById('file-list'); - // Remove guest warning if present - const guestMsg = document.getElementById('guest-warning-msg'); - if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg); - - // Show user dashboard and logout button - if (userDashboard) userDashboard.style.display = ''; - if (logoutButton) { - logoutButton.style.display = 'block'; - logoutButton.onclick = handleLogout; - } + if (logoutButton) { + logoutButton.addEventListener('click', handleLogout); + } + if (deleteAccountButton) { + deleteAccountButton.addEventListener('click', (e) => { + e.preventDefault(); + handleDeleteAccount(); + }); + } - // Set audio source - const meAudio = document.getElementById('me-audio'); - const username = data?.username || ''; - - if (meAudio) { - if (username) { - // Use username for the audio file path if available - meAudio.src = `/audio/${encodeURIComponent(username)}/stream.opus?t=${Date.now()}`; - console.log('Setting audio source to:', meAudio.src); - } else if (uid) { - // Fallback to UID if username is not available - meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; - console.warn('Using UID fallback for audio source:', meAudio.src); - } - } + const isAuthenticated = (document.cookie.includes('isAuthenticated=true') || localStorage.getItem('isAuthenticated') === 'true'); - // Update quota and ensure quota meter is visible if data is available - const quotaMeter = document.getElementById('quota-meter'); - const quotaBar = document.getElementById('quota-bar'); - const quotaText = document.getElementById('quota-text'); - - if (quotaBar && data.quota !== undefined) { - quotaBar.value = data.quota; - } - - if (quotaText && data.quota !== undefined) { - quotaText.textContent = `${data.quota} MB`; - } - - if (quotaMeter) { - quotaMeter.hidden = false; - quotaMeter.style.display = 'block'; // Ensure it's not hidden by display:none - } - - // Fetch and display the list of uploaded files if the function is available - if (window.fetchAndDisplayFiles) { - console.log('[DASHBOARD] Calling fetchAndDisplayFiles with UID:', uid); - // Ensure we have the most up-to-date UID from the response data if available - const effectiveUid = data?.uid || uid; - console.log('[DASHBOARD] Using effective UID:', effectiveUid); - window.fetchAndDisplayFiles(effectiveUid); - } else { - console.error('[DASHBOARD] fetchAndDisplayFiles function not found!'); + if (isAuthenticated) { + document.body.classList.add('authenticated'); + document.body.classList.remove('guest-mode'); + if (userDashboard) userDashboard.style.display = 'block'; + if (userUpload) userUpload.style.display = 'block'; + if (guestDashboard) guestDashboard.style.display = 'none'; + + const uid = getCookie('uid') || localStorage.getItem('uid'); + if (uid && window.fetchAndDisplayFiles) { + await window.fetchAndDisplayFiles(uid); } } else { - // No valid UID found, ensure we're in guest mode - console.log('[DASHBOARD] No valid UID found, showing guest dashboard'); - userDashboard.style.display = 'none'; - guestDashboard.style.display = 'block'; - userUpload.style.display = 'none'; document.body.classList.remove('authenticated'); - return; // Exit early for guest users + document.body.classList.add('guest-mode'); + if (guestDashboard) guestDashboard.style.display = 'block'; + if (userDashboard) userDashboard.style.display = 'none'; + if (userUpload) userUpload.style.display = 'none'; + if (fileList) { + fileList.innerHTML = `
  • Please to view your files.
  • `; + } } - - // Ensure Streams link remains in nav, not moved - // (No action needed if static) } catch (e) { - console.warn('Dashboard init error, falling back to guest mode:', e); - - // Ensure guest UI is shown - userUpload.style.display = 'none'; - userDashboard.style.display = 'none'; + console.error('Dashboard initialization failed:', e); + const guestDashboard = document.getElementById('guest-dashboard'); + const userDashboard = document.getElementById('user-dashboard'); + if (userDashboard) userDashboard.style.display = 'none'; if (guestDashboard) guestDashboard.style.display = 'block'; - - // Update body classes document.body.classList.remove('authenticated'); - document.body.classList.add('guest-mode'); - - // Ensure navigation is in correct state - const registerLink = document.getElementById('guest-login'); - const streamsLink = document.getElementById('guest-streams'); - if (registerLink && streamsLink) { - registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement); - } } } @@ -977,30 +683,36 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize dashboard components initDashboard(); // initFileUpload is called from within initDashboard - // Add event delegation for delete buttons + // Delegated event listener for clicks on the document document.addEventListener('click', (e) => { - const deleteButton = e.target.closest('.delete-file'); - if (!deleteButton) return; - - e.preventDefault(); - e.stopPropagation(); - - const listItem = deleteButton.closest('.file-item'); - if (!listItem) return; - - // Get UID from localStorage - const uid = localStorage.getItem('uid') || localStorage.getItem('confirmed_uid'); - if (!uid) { - showToast('You need to be logged in to delete files', 'error'); - console.error('[DELETE] No UID found in localStorage'); + // Logout Button + if (e.target.closest('#logout-button')) { + e.preventDefault(); + handleLogout(e); return; } - - const fileName = deleteButton.getAttribute('data-filename'); - const displayName = deleteButton.getAttribute('data-original-name') || fileName; - - // Pass the UID to deleteFile - deleteFile(uid, fileName, listItem, displayName); + + // Delete File Button + const deleteButton = e.target.closest('.delete-file'); + if (deleteButton) { + e.preventDefault(); + e.stopPropagation(); + + const listItem = deleteButton.closest('.file-item'); + if (!listItem) return; + + const uid = localStorage.getItem('uid') || localStorage.getItem('confirmed_uid'); + if (!uid) { + showToast('You need to be logged in to delete files', 'error'); + console.error('[DELETE] No UID found in localStorage'); + return; + } + + const fileName = deleteButton.getAttribute('data-filename'); + const displayName = deleteButton.getAttribute('data-original-name') || fileName; + + deleteFile(uid, fileName, listItem, displayName); + } }); // Make fetchAndDisplayFiles available globally @@ -1062,184 +774,10 @@ document.addEventListener('DOMContentLoaded', () => { }); } // Connect Login or Register link to register form - // Login/Register (guest) - const loginLink = document.getElementById('guest-login'); - if (loginLink) { - loginLink.addEventListener('click', (e) => { - e.preventDefault(); - document.querySelectorAll('main > section').forEach(sec => { - sec.hidden = sec.id !== 'register-page'; - }); - const reg = document.getElementById('register-page'); - if (reg) reg.hidden = false; - reg.scrollIntoView({behavior:'smooth'}); - }); - } - // Terms of Service (all dashboards) - const termsLinks = [ - document.getElementById('guest-terms'), - document.getElementById('user-terms') - ]; - termsLinks.forEach(link => { - if (link) { - link.addEventListener('click', (e) => { - e.preventDefault(); - document.querySelectorAll('main > section').forEach(sec => { - sec.hidden = sec.id !== 'terms-page'; - }); - const terms = document.getElementById('terms-page'); - if (terms) terms.hidden = false; - terms.scrollIntoView({behavior:'smooth'}); - }); - } + // All navigation is now handled by the global click and hashchange listeners in nav.js. + // The legacy setupPageNavigation function and manual nav link handlers have been removed. }); - // Imprint (all dashboards) - const imprintLinks = [ - document.getElementById('guest-imprint'), - document.getElementById('user-imprint') - ]; - imprintLinks.forEach(link => { - if (link) { - link.addEventListener('click', (e) => { - e.preventDefault(); - document.querySelectorAll('main > section').forEach(sec => { - sec.hidden = sec.id !== 'imprint-page'; - }); - const imprint = document.getElementById('imprint-page'); - if (imprint) imprint.hidden = false; - imprint.scrollIntoView({behavior:'smooth'}); - }); - } - }); - - // Privacy Policy (all dashboards) - const privacyLinks = [ - document.getElementById('guest-privacy'), - document.getElementById('user-privacy') - ]; - privacyLinks.forEach(link => { - if (link) { - link.addEventListener('click', (e) => { - e.preventDefault(); - document.querySelectorAll('main > section').forEach(sec => { - sec.hidden = sec.id !== 'privacy-page'; - }); - const privacy = document.getElementById('privacy-page'); - if (privacy) privacy.hidden = false; - privacy.scrollIntoView({behavior:'smooth'}); - }); - } - }); - - // Back to top button functionality - const backToTop = document.getElementById('back-to-top'); - if (backToTop) { - backToTop.addEventListener('click', (e) => { - e.preventDefault(); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }); - } - - // Mobile menu functionality - const menuToggle = document.getElementById('mobile-menu-toggle'); - const mainNav = document.getElementById('main-navigation'); - - if (menuToggle && mainNav) { - // Toggle mobile menu - menuToggle.addEventListener('click', () => { - const isExpanded = menuToggle.getAttribute('aria-expanded') === 'true' || false; - menuToggle.setAttribute('aria-expanded', !isExpanded); - mainNav.setAttribute('aria-hidden', isExpanded); - - // Toggle mobile menu visibility - if (isExpanded) { - mainNav.classList.remove('mobile-visible'); - document.body.style.overflow = ''; - } else { - mainNav.classList.add('mobile-visible'); - document.body.style.overflow = 'hidden'; - } - }); - - // Close mobile menu when clicking outside - document.addEventListener('click', (e) => { - const isClickInsideNav = mainNav.contains(e.target); - const isClickOnToggle = menuToggle === e.target || menuToggle.contains(e.target); - - if (mainNav.classList.contains('mobile-visible') && !isClickInsideNav && !isClickOnToggle) { - mainNav.classList.remove('mobile-visible'); - menuToggle.setAttribute('aria-expanded', 'false'); - document.body.style.overflow = ''; - } - }); - } - - // Handle navigation link clicks - const navLinks = document.querySelectorAll('nav a[href^="#"]'); - navLinks.forEach(link => { - link.addEventListener('click', (e) => { - const targetId = link.getAttribute('href'); - if (targetId === '#') return; - - const targetElement = document.querySelector(targetId); - if (targetElement) { - e.preventDefault(); - - // Close mobile menu if open - if (mainNav && mainNav.classList.contains('mobile-visible')) { - mainNav.classList.remove('mobile-visible'); - if (menuToggle) { - menuToggle.setAttribute('aria-expanded', 'false'); - } - document.body.style.overflow = ''; - } - - // Smooth scroll to target - targetElement.scrollIntoView({ behavior: 'smooth' }); - - // Update URL without page reload - if (history.pushState) { - history.pushState(null, '', targetId); - } else { - location.hash = targetId; - } - } - }); - }); - - // Helper function to handle page section navigation - const setupPageNavigation = (linkIds, pageId) => { - const links = linkIds - .map(id => document.getElementById(id)) - .filter(Boolean); - - links.forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - document.querySelectorAll('main > section').forEach(sec => { - sec.hidden = sec.id !== pageId; - }); - const targetPage = document.getElementById(pageId); - if (targetPage) { - targetPage.hidden = false; - targetPage.scrollIntoView({ behavior: 'smooth' }); - } - }); - }); - }; - - // Setup navigation for different sections - setupPageNavigation(['guest-terms', 'user-terms'], 'terms-page'); - setupPageNavigation(['guest-imprint', 'user-imprint'], 'imprint-page'); - setupPageNavigation(['guest-privacy', 'user-privacy'], 'privacy-page'); - - // Registration form handler for guests - removed duplicate declaration - // The form submission is already handled earlier in the file - - // Login link handler - removed duplicate declaration - // The login link is already handled by the setupPageNavigation function - // Handle drag and drop const uploadArea = document.getElementById('upload-area'); if (uploadArea) { @@ -1281,4 +819,3 @@ document.addEventListener('DOMContentLoaded', () => { } }); } -}); // End of DOMContentLoaded diff --git a/static/fix-nav.js b/static/fix-nav.js deleted file mode 100644 index 8c34805..0000000 --- a/static/fix-nav.js +++ /dev/null @@ -1,140 +0,0 @@ -// Debounce helper function -function debounce(func, wait) { - let timeout; - return function() { - const context = this; - const args = arguments; - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(context, args), wait); - }; -} - -// Throttle helper function -function throttle(func, limit) { - let inThrottle; - return function() { - const args = arguments; - const context = this; - if (!inThrottle) { - func.apply(context, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - } - }; -} - -// Check authentication state once and cache it -function getAuthState() { - return ( - document.cookie.includes('isAuthenticated=') || - document.cookie.includes('uid=') || - localStorage.getItem('isAuthenticated') === 'true' || - !!localStorage.getItem('authToken') - ); -} - -// Update navigation based on authentication state -function updateNavigation() { - const isAuthenticated = getAuthState(); - - // Only proceed if the authentication state has changed - if (isAuthenticated === updateNavigation.lastState) { - return; - } - updateNavigation.lastState = isAuthenticated; - - if (isAuthenticated) { - // Hide guest navigation for authenticated users - const guestNav = document.getElementById('guest-dashboard'); - if (guestNav) { - guestNav.style.cssText = ` - display: none !important; - visibility: hidden !important; - opacity: 0 !important; - height: 0 !important; - overflow: hidden !important; - position: absolute !important; - clip: rect(0, 0, 0, 0) !important; - pointer-events: none !important; - `; - } - - // Show user navigation if it exists - const userNav = document.getElementById('user-dashboard'); - if (userNav) { - userNav.style.cssText = ` - display: block !important; - visibility: visible !important; - opacity: 1 !important; - height: auto !important; - position: relative !important; - clip: auto !important; - pointer-events: auto !important; - `; - userNav.classList.add('force-visible'); - } - - // Update body classes - document.body.classList.add('authenticated'); - document.body.classList.remove('guest-mode'); - } else { - // User is not authenticated - ensure guest nav is visible - const guestNav = document.getElementById('guest-dashboard'); - if (guestNav) { - guestNav.style.cssText = ''; // Reset any inline styles - } - document.body.classList.remove('authenticated'); - document.body.classList.add('guest-mode'); - } -} - -// Initialize the navigation state -updateNavigation.lastState = null; - -// Handle navigation link clicks -function handleNavLinkClick(e) { - const link = e.target.closest('a[href^="#"]'); - if (!link) return; - - e.preventDefault(); - const targetId = link.getAttribute('href'); - if (targetId && targetId !== '#') { - // Update URL without triggering full page reload - history.pushState(null, '', targetId); - // Dispatch a custom event for other scripts - window.dispatchEvent(new CustomEvent('hashchange')); - } -} - -// Initialize the navigation system -function initNavigation() { - // Set up event delegation for navigation links - document.body.addEventListener('click', handleNavLinkClick); - - // Listen for hash changes (throttled) - window.addEventListener('hashchange', throttle(updateNavigation, 100)); - - // Listen for storage changes (like login/logout from other tabs) - window.addEventListener('storage', debounce(updateNavigation, 100)); - - // Check for authentication changes periodically (every 30 seconds) - setInterval(updateNavigation, 30000); - - // Initial update - updateNavigation(); -} - -// Run initialization when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initNavigation); -} else { - // DOMContentLoaded has already fired - initNavigation(); -} - -// Export for testing if needed -window.navigationUtils = { - updateNavigation, - getAuthState, - initNavigation -}; diff --git a/static/generate-test-audio.sh b/static/generate-test-audio.sh deleted file mode 100755 index 9f9e0b1..0000000 --- a/static/generate-test-audio.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Create a 1-second silent audio file in Opus format -ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a libopus -b:a 60k /home/oib/games/dicta2stream/static/test-audio.opus - -# Verify the file was created -if [ -f "/home/oib/games/dicta2stream/static/test-audio.opus" ]; then - echo "Test audio file created successfully at /home/oib/games/dicta2stream/static/test-audio.opus" - echo "File size: $(du -h /home/oib/games/dicta2stream/static/test-audio.opus | cut -f1)" -else - echo "Failed to create test audio file" - exit 1 -fi diff --git a/static/global-audio-manager.js b/static/global-audio-manager.js index 1a2cc98..619b18d 100644 --- a/static/global-audio-manager.js +++ b/static/global-audio-manager.js @@ -23,6 +23,7 @@ class GlobalAudioManager { * @param {Object} playerInstance - Reference to the player instance */ startPlayback(playerType, uid, playerInstance = null) { + console.log(`[GlobalAudioManager] startPlayback called by: ${playerType} for UID: ${uid}`); // If the same player is already playing the same UID, allow it if (this.currentPlayer === playerType && this.currentUid === uid) { return true; diff --git a/static/index.html b/static/index.html index aa28c1e..b74bf7b 100644 --- a/static/index.html +++ b/static/index.html @@ -22,7 +22,8 @@ - + +
    @@ -36,7 +37,7 @@ @@ -47,7 +48,7 @@
    -

    Your Stream

    +

    Your Stream

    This is your personal stream. Only you can upload to it.

    @@ -187,27 +188,18 @@ - - + - + - - + + diff --git a/static/inject-nav.js b/static/inject-nav.js deleted file mode 100644 index 3f00e91..0000000 --- a/static/inject-nav.js +++ /dev/null @@ -1,184 +0,0 @@ -// inject-nav.js - Handles dynamic injection and management of navigation elements -import { showOnly } from './router.js'; - -// Function to set up guest navigation links -function setupGuestNav() { - const guestDashboard = document.getElementById('guest-dashboard'); - if (!guestDashboard) return; - - const links = guestDashboard.querySelectorAll('a'); - links.forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - const target = link.getAttribute('href')?.substring(1); // Remove '#' - if (target) { - window.location.hash = target; - if (window.router && typeof window.router.showOnly === 'function') { - window.router.showOnly(target); - } - } - }); - }); -} - -// Function to set up user navigation links -function setupUserNav() { - const userDashboard = document.getElementById('user-dashboard'); - if (!userDashboard) return; - - const links = userDashboard.querySelectorAll('a'); - links.forEach(link => { - // Handle logout specially - if (link.getAttribute('href') === '#logout') { - link.addEventListener('click', (e) => { - e.preventDefault(); - if (window.handleLogout) { - window.handleLogout(); - } - }); - } else { - // Handle regular navigation - link.addEventListener('click', (e) => { - e.preventDefault(); - const target = link.getAttribute('href')?.substring(1); // Remove '#' - if (target) { - window.location.hash = target; - if (window.router && typeof window.router.showOnly === 'function') { - window.router.showOnly(target); - } - } - }); - } - }); -} - -function createUserNav() { - const nav = document.createElement('div'); - nav.className = 'dashboard-nav'; - nav.setAttribute('role', 'navigation'); - nav.setAttribute('aria-label', 'User navigation'); - - const navList = document.createElement('ul'); - navList.className = 'nav-list'; - - const links = [ - { id: 'user-stream', target: 'your-stream', text: 'Your Stream' }, - { id: 'nav-streams', target: 'streams', text: 'Streams' }, - { id: 'nav-welcome', target: 'welcome', text: 'Welcome' }, - { id: 'user-logout', target: 'logout', text: 'Logout' } - ]; - - // Create and append links - links.forEach((link) => { - const li = document.createElement('li'); - li.className = 'nav-item'; - - const a = document.createElement('a'); - a.id = link.id; - a.href = '#'; - a.className = 'nav-link'; - a.setAttribute('data-target', link.target); - a.textContent = link.text; - - a.addEventListener('click', (e) => { - e.preventDefault(); - const target = e.currentTarget.getAttribute('data-target'); - if (target === 'logout') { - if (window.handleLogout) { - window.handleLogout(); - } - } else if (target) { - window.location.hash = target; - if (window.router && typeof window.router.showOnly === 'function') { - window.router.showOnly(target); - } - } - }); - - li.appendChild(a); - navList.appendChild(li); - }); - - nav.appendChild(navList); - return nav; -} - -// Navigation injection function -export function injectNavigation(isAuthenticated = false) { - // Get the appropriate dashboard element based on auth state - const guestDashboard = document.getElementById('guest-dashboard'); - const userDashboard = document.getElementById('user-dashboard'); - - if (isAuthenticated) { - // Show user dashboard, hide guest dashboard - if (guestDashboard) guestDashboard.style.display = 'none'; - if (userDashboard) userDashboard.style.display = 'block'; - document.body.classList.add('authenticated'); - document.body.classList.remove('guest-mode'); - } else { - // Show guest dashboard, hide user dashboard - if (guestDashboard) guestDashboard.style.display = 'block'; - if (userDashboard) userDashboard.style.display = 'none'; - document.body.classList.add('guest-mode'); - document.body.classList.remove('authenticated'); - } - - // Set up menu links and active state - setupMenuLinks(); - updateActiveNav(); - - return isAuthenticated ? userDashboard : guestDashboard; -} - -// Set up menu links with click handlers -function setupMenuLinks() { - // Set up guest and user navigation links - setupGuestNav(); - setupUserNav(); - - // Handle hash changes for SPA navigation - window.addEventListener('hashchange', updateActiveNav); -} - -// Update active navigation link -function updateActiveNav() { - const currentHash = window.location.hash.substring(1) || 'welcome'; - - // Remove active class from all links in both dashboards - document.querySelectorAll('#guest-dashboard a, #user-dashboard a').forEach(link => { - link.classList.remove('active'); - // Check if this link's href matches the current hash - const linkTarget = link.getAttribute('href')?.substring(1); // Remove '#' - if (linkTarget === currentHash) { - link.classList.add('active'); - } - }); -} - -// Initialize when DOM is loaded -document.addEventListener('DOMContentLoaded', () => { - // Check authentication state and initialize navigation - const isAuthenticated = document.cookie.includes('sessionid=') || - localStorage.getItem('isAuthenticated') === 'true'; - - // Initialize navigation based on authentication state - injectNavigation(isAuthenticated); - - // Set up menu links and active navigation - setupMenuLinks(); - updateActiveNav(); - - // Update body classes based on authentication state - if (isAuthenticated) { - document.body.classList.add('authenticated'); - document.body.classList.remove('guest-mode'); - } else { - document.body.classList.add('guest-mode'); - document.body.classList.remove('authenticated'); - } - - console.log('[NAV] Navigation initialized', { isAuthenticated }); -}); - -// Make the function available globally for debugging -window.injectNavigation = injectNavigation; diff --git a/static/logger.js b/static/logger.js new file mode 100644 index 0000000..ad03dde --- /dev/null +++ b/static/logger.js @@ -0,0 +1,6 @@ +export function logToServer(msg) { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/log", true); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(JSON.stringify({ msg })); +} diff --git a/static/magic-login.js b/static/magic-login.js index 39fb856..8c465fd 100644 --- a/static/magic-login.js +++ b/static/magic-login.js @@ -1,5 +1,5 @@ // static/magic-login.js — handles magic‑link token UI -import { showOnly } from './router.js'; +import { showSection } from './nav.js'; let magicLoginSubmitted = false; @@ -73,8 +73,8 @@ export async function initMagicLogin() { if (registerPage) registerPage.style.display = 'none'; // Show the user's stream page - if (window.showOnly) { - window.showOnly('me-page'); + if (typeof showSection === 'function') { + showSection('me-page'); } }); return; diff --git a/static/nav.js b/static/nav.js index cf6635a..419e01f 100644 --- a/static/nav.js +++ b/static/nav.js @@ -7,468 +7,97 @@ function getCookie(name) { return null; } -document.addEventListener("DOMContentLoaded", () => { - // Check authentication status - const isLoggedIn = !!getCookie('uid'); - - // Update body class for CSS-based visibility - document.body.classList.toggle('logged-in', isLoggedIn); - - // Get all main content sections - const mainSections = Array.from(document.querySelectorAll('main > section')); - - // Show/hide sections with smooth transitions - const showSection = (sectionId) => { - // Update body class to indicate current page - document.body.className = ''; - if (sectionId) { - document.body.classList.add(`page-${sectionId}`); +// Determines the correct section to show based on auth status and requested section +function getValidSection(sectionId) { + const isLoggedIn = !!getCookie('uid'); + const protectedSections = ['me-page', 'account-page']; + const guestOnlySections = ['login-page', 'register-page', 'magic-login-page']; + + if (isLoggedIn) { + // If logged in, guest-only sections are invalid, redirect to 'me-page' + if (guestOnlySections.includes(sectionId)) { + return 'me-page'; + } } else { - document.body.classList.add('page-welcome'); + // If not logged in, protected sections are invalid, redirect to 'welcome-page' + if (protectedSections.includes(sectionId)) { + return 'welcome-page'; + } } + // If the section doesn't exist in the DOM, default to welcome page + if (!document.getElementById(sectionId)) { + return 'welcome-page'; + } + + return sectionId; +} + +// Main function to show/hide sections +export function showSection(sectionId) { + const mainSections = Array.from(document.querySelectorAll('main > section')); + + // Update body class for page-specific CSS + document.body.className = document.body.className.replace(/page-\S+/g, ''); + document.body.classList.add(`page-${sectionId || 'welcome-page'}`); + // Update active state of navigation links document.querySelectorAll('.dashboard-nav a').forEach(link => { - link.classList.remove('active'); - if ((!sectionId && link.getAttribute('href') === '#welcome-page') || - (sectionId && link.getAttribute('href') === `#${sectionId}`)) { - link.classList.add('active'); - } + link.classList.remove('active'); + if (link.getAttribute('href') === `#${sectionId}`) { + link.classList.add('active'); + } }); - + mainSections.forEach(section => { - // Skip navigation sections - if (section.id === 'guest-dashboard' || section.id === 'user-dashboard') { - return; - } - - const isTarget = section.id === sectionId; - const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId); - const isWelcomePage = !sectionId || sectionId === 'welcome-page'; - - if (isTarget || (isLegalPage && section.id === sectionId)) { - // Show the target section or legal page - section.classList.add('active'); - section.hidden = false; - - // Focus the section for accessibility with a small delay - // Only focus if the section is focusable and in the viewport - const focusSection = () => { - try { - if (section && typeof section.focus === 'function' && - section.offsetParent !== null && // Check if element is visible - section.getBoundingClientRect().top < window.innerHeight && - section.getBoundingClientRect().bottom > 0) { - section.focus({ preventScroll: true }); + section.hidden = section.id !== sectionId; + }); + + // Update URL hash without causing a page scroll, this is for direct calls to showSection + // Normal navigation is handled by the hashchange listener + const currentHash = `#${sectionId}`; + if (window.location.hash !== currentHash) { + if (history.pushState) { + if (sectionId && sectionId !== 'welcome-page') { + history.pushState(null, null, currentHash); + } else { + history.pushState(null, null, window.location.pathname + window.location.search); } - } catch (e) { - // Silently fail if focusing isn't possible - if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) { - console.debug('Could not focus section:', e); - } - } - }; - - // Use requestAnimationFrame for better performance - requestAnimationFrame(() => { - // Only set the timeout in debug mode or local development - if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) { - setTimeout(focusSection, 50); - } else { - focusSection(); - } - }); - } else if (isWelcomePage && section.id === 'welcome-page') { - // Special handling for welcome page - section.classList.add('active'); - section.hidden = false; - } else { - // Hide other sections - section.classList.remove('active'); - section.hidden = true; - } - }); - - // Update URL hash without page scroll - if (sectionId && !['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId)) { - if (sectionId === 'welcome-page') { - history.replaceState(null, '', window.location.pathname); - } else { - history.replaceState(null, '', `#${sectionId}`); - } - } - }; - - // Handle initial page load - const getValidSection = (sectionId) => { - const protectedSections = ['me-page', 'register-page']; - - // If not logged in and trying to access protected section - if (!isLoggedIn && protectedSections.includes(sectionId)) { - return 'welcome-page'; - } - - // If section doesn't exist, default to welcome page - if (!document.getElementById(sectionId)) { - return 'welcome-page'; - } - - return sectionId; - }; - - // Process initial page load - const initialPage = window.location.hash.substring(1) || 'welcome-page'; - const validSection = getValidSection(initialPage); - - // Update URL if needed - if (validSection !== initialPage) { - window.location.hash = validSection; - } - - // Show the appropriate section - showSection(validSection); - - const Router = { - sections: Array.from(document.querySelectorAll("main > section")), - - showOnly(id) { - // Validate the section ID - const validId = getValidSection(id); - - // Update URL if needed - if (validId !== id) { - window.location.hash = validId; - return; - } - - // Show the requested section - showSection(validId); - - // Handle the quota meter visibility - only show with 'me-page' - const quotaMeter = document.getElementById('quota-meter'); - if (quotaMeter) { - quotaMeter.hidden = validId !== 'me-page'; - quotaMeter.tabIndex = validId === 'me-page' ? 0 : -1; - } - - // Update navigation active states - this.updateActiveNav(validId); - }, - - updateActiveNav(activeId) { - // Update active states for navigation links - document.querySelectorAll('.dashboard-nav a').forEach(link => { - const target = link.getAttribute('href').substring(1); - if (target === activeId) { - link.setAttribute('aria-current', 'page'); - link.classList.add('active'); - } else { - link.removeAttribute('aria-current'); - link.classList.remove('active'); } - }); } - }; - - // Initialize the router - const router = Router; - - // Handle section visibility based on authentication - const updateSectionVisibility = (sectionId) => { - const section = document.getElementById(sectionId); - if (!section) return; - - // Skip navigation sections and quota meter - if (['guest-dashboard', 'user-dashboard', 'quota-meter'].includes(sectionId)) { - return; - } - - const currentHash = window.location.hash.substring(1); - const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId); - - // Special handling for legal pages - always show when in hash - if (isLegalPage) { - const isActive = sectionId === currentHash; - section.hidden = !isActive; - section.tabIndex = isActive ? 0 : -1; - if (isActive) section.focus(); - return; - } - - // Special handling for me-page - only show to authenticated users - if (sectionId === 'me-page') { - section.hidden = !isLoggedIn || currentHash !== 'me-page'; - section.tabIndex = (isLoggedIn && currentHash === 'me-page') ? 0 : -1; - return; - } - - // Special handling for register page - only show to guests - if (sectionId === 'register-page') { - section.hidden = isLoggedIn || currentHash !== 'register-page'; - section.tabIndex = (!isLoggedIn && currentHash === 'register-page') ? 0 : -1; - return; - } - - // For other sections, show if they match the current section ID - const isActive = sectionId === currentHash; - section.hidden = !isActive; - section.tabIndex = isActive ? 0 : -1; - - if (isActive) { - section.focus(); - } - }; - - // Initialize the router - router.init = function() { - // Update visibility for all sections - this.sections.forEach(section => { - updateSectionVisibility(section.id); - }); - - // Show user-upload-area only when me-page is shown and user is logged in - const userUpload = document.getElementById("user-upload-area"); - if (userUpload) { - const uid = getCookie("uid"); - userUpload.style.display = (window.location.hash === '#me-page' && uid) ? '' : 'none'; - } - - // Store the current page - localStorage.setItem("last_page", window.location.hash.substring(1)); - - // Initialize navigation - initNavLinks(); - initBackButtons(); - initStreamLinks(); - - // Ensure proper focus management for accessibility - const currentSection = document.querySelector('main > section:not([hidden])'); - if (currentSection) { - currentSection.setAttribute('tabindex', '0'); - currentSection.focus(); - } - }; - - // Initialize the router - router.init(); - - // Handle footer links - document.querySelectorAll('.footer-links a').forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - const target = link.dataset.target; - if (target) { - // Update URL hash to maintain proper history state - window.location.hash = target; - // Use the router to handle the navigation - if (router && typeof router.showOnly === 'function') { - router.showOnly(target); - } else { - // Fallback to showSection if router is not available - showSection(target); - } - } - }); - }); - - // Export the showOnly function for global access - window.showOnly = router.showOnly.bind(router); - - // Make router available globally for debugging - window.appRouter = router; +} - // Highlight active profile link on browser back/forward navigation - function highlightActiveProfileLink() { - const params = new URLSearchParams(window.location.search); - const profileUid = params.get('profile'); - const ul = document.getElementById('stream-list'); - if (!ul) return; - ul.querySelectorAll('a.profile-link').forEach(link => { - const url = new URL(link.href, window.location.origin); - const uidParam = url.searchParams.get('profile'); - link.classList.toggle('active', uidParam === profileUid); - }); - } - window.addEventListener('popstate', () => { - const params = new URLSearchParams(window.location.search); - const profileUid = params.get('profile'); - const currentPage = window.location.hash.substring(1) || 'welcome-page'; - - // Prevent unauthorized access to me-page - if ((currentPage === 'me-page' || profileUid) && !getCookie('uid')) { - history.replaceState(null, '', '#welcome-page'); - showOnly('welcome-page'); - return; - } - - if (profileUid) { - showOnly('me-page'); - if (typeof window.showProfilePlayerFromUrl === 'function') { - window.showProfilePlayerFromUrl(); - } - } else { - highlightActiveProfileLink(); - } - }); +document.addEventListener("DOMContentLoaded", () => { + const isLoggedIn = !!getCookie('uid'); + document.body.classList.toggle('authenticated', isLoggedIn); - /* restore last page (unless magic‑link token present) */ - const params = new URLSearchParams(location.search); - const token = params.get("token"); - if (!token) { - const last = localStorage.getItem("last_page"); - if (last && document.getElementById(last)) { - showOnly(last); - } else if (document.getElementById("welcome-page")) { - // Show Welcome page by default for all new/guest users - showOnly("welcome-page"); - } - // Highlight active link on initial load - highlightActiveProfileLink(); - } + // Unified click handler for SPA navigation + document.body.addEventListener('click', (e) => { + const link = e.target.closest('a[href^="#"]'); + // Ensure the link is not inside a component that handles its own navigation + if (!link || link.closest('.no-global-nav')) return; - /* token → show magic‑login page */ - if (token) { - document.getElementById("magic-token").value = token; - showOnly("magic-login-page"); - const err = params.get("error"); - if (err) { - const box = document.getElementById("magic-error"); - box.textContent = decodeURIComponent(err); - box.style.display = "block"; - } - } - - - function renderStreamList(streams) { - const ul = document.getElementById("stream-list"); - if (!ul) return; - if (streams.length) { - // Handle both array of UIDs (legacy) and array of stream objects (new) - const streamItems = streams.map(item => { - if (typeof item === 'string') { - // Legacy: array of UIDs - return { uid: item, username: item }; - } else { - // New: array of stream objects - return { - uid: item.uid || '', - username: item.username || 'Unknown User' - }; - } - }); - - streamItems.sort((a, b) => (a.username || '').localeCompare(b.username || '')); - ul.innerHTML = streamItems.map(stream => ` -
  • ▶ ${stream.username}
  • - `).join(""); - } else { - ul.innerHTML = "
  • No active streams.
  • "; - } - // Ensure correct link is active after rendering - highlightActiveProfileLink(); - } - - // Initialize navigation listeners - function initNavLinks() { - const navIds = ["links", "user-dashboard", "guest-dashboard"]; - navIds.forEach(id => { - const nav = document.getElementById(id); - if (!nav) return; - nav.addEventListener("click", e => { - const a = e.target.closest("a[data-target]"); - if (!a || !nav.contains(a)) return; e.preventDefault(); - - // Save audio state before navigation - const audio = document.getElementById('me-audio'); - const wasPlaying = audio && !audio.paused; - const currentTime = audio ? audio.currentTime : 0; - - const target = a.dataset.target; - if (target) showOnly(target); - - // Handle stream page specifically - if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") { - window.maybeLoadStreamsOnShow(); + const newHash = link.getAttribute('href'); + if (window.location.hash !== newHash) { + window.location.hash = newHash; } - // Handle me-page specifically - else if (target === "me-page" && audio) { - // Restore audio state if it was playing - if (wasPlaying) { - audio.currentTime = currentTime; - audio.play().catch(e => console.error('Play failed:', e)); - } - } - }); }); - // Add click handlers for footer links with audio state saving - document.querySelectorAll(".footer-links a").forEach(link => { - link.addEventListener("click", (e) => { - e.preventDefault(); - const target = link.dataset.target; - if (!target) return; + // Main routing logic on hash change + const handleNavigation = () => { + const sectionId = window.location.hash.substring(1) || 'welcome-page'; + const validSectionId = getValidSection(sectionId); - // Save audio state before navigation - const audio = document.getElementById('me-audio'); - const wasPlaying = audio && !audio.paused; - const currentTime = audio ? audio.currentTime : 0; - - showOnly(target); - - // Handle me-page specifically - if (target === "me-page" && audio) { - // Restore audio state if it was playing - if (wasPlaying) { - audio.currentTime = currentTime; - audio.play().catch(e => console.error('Play failed:', e)); - } + if (sectionId !== validSectionId) { + window.location.hash = validSectionId; // This will re-trigger handleNavigation + } else { + showSection(validSectionId); } - }); - }); - } - - function initBackButtons() { - document.querySelectorAll('a[data-back]').forEach(btn => { - btn.addEventListener("click", e => { - e.preventDefault(); - const target = btn.dataset.back; - if (target) showOnly(target); - // Ensure streams load instantly when stream-page is shown - if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") { - window.maybeLoadStreamsOnShow(); - } - }); - }); - } + }; + window.addEventListener('hashchange', handleNavigation); - - function initStreamLinks() { - const ul = document.getElementById("stream-list"); - if (!ul) return; - ul.addEventListener("click", e => { - const a = e.target.closest("a.profile-link"); - if (!a || !ul.contains(a)) return; - e.preventDefault(); - const url = new URL(a.href, window.location.origin); - const profileUid = url.searchParams.get("profile"); - if (profileUid && window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) { - window.profileNavigationTriggered = true; - window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}`); - window.dispatchEvent(new Event("popstate")); - } - }); - } - - // Initialize Router - document.addEventListener('visibilitychange', () => { - // Re-check authentication when tab becomes visible again - if (!document.hidden && window.location.hash === '#me-page' && !getCookie('uid')) { - window.location.hash = 'welcome-page'; - showOnly('welcome-page'); - } - }); - - Router.init(); + // Initial page load + handleNavigation(); }); diff --git a/static/personal-player.js b/static/personal-player.js new file mode 100644 index 0000000..9df5b89 --- /dev/null +++ b/static/personal-player.js @@ -0,0 +1,140 @@ +import { showToast } from "./toast.js"; +import { globalAudioManager } from './global-audio-manager.js'; + +// Module-level state for the personal player +let audio = null; + +/** + * Finds or creates the audio element for the personal stream. + * @returns {HTMLAudioElement | null} + */ +function getOrCreateAudioElement() { + if (audio) { + return audio; + } + + audio = document.createElement('audio'); + audio.id = 'me-audio'; + audio.preload = 'metadata'; + audio.crossOrigin = 'use-credentials'; + document.body.appendChild(audio); + + // --- Setup Event Listeners (only once) --- + audio.addEventListener('error', (e) => { + console.error('Personal Player: Audio Element Error', e); + const error = audio.error; + let errorMessage = 'An unknown audio error occurred.'; + if (error) { + switch (error.code) { + case error.MEDIA_ERR_ABORTED: + errorMessage = 'Audio playback was aborted.'; + break; + case error.MEDIA_ERR_NETWORK: + errorMessage = 'A network error caused the audio to fail.'; + break; + case error.MEDIA_ERR_DECODE: + errorMessage = 'The audio could not be decoded.'; + break; + case error.MEDIA_ERR_SRC_NOT_SUPPORTED: + errorMessage = 'The audio format is not supported by your browser.'; + break; + default: + errorMessage = `An unexpected error occurred (Code: ${error.code}).`; + break; + } + } + showToast(errorMessage, 'error'); + }); + + audio.addEventListener('play', () => updatePlayPauseButton(true)); + audio.addEventListener('pause', () => updatePlayPauseButton(false)); + audio.addEventListener('ended', () => updatePlayPauseButton(false)); + + // The canplaythrough listener is removed as it violates autoplay policies. + // The user will perform a second click to play the media after it's loaded. + + return audio; +} + +/** + * Updates the play/pause button icon based on audio state. + * @param {boolean} isPlaying - Whether the audio is currently playing. + */ +function updatePlayPauseButton(isPlaying) { + const playPauseBtn = document.querySelector('#me-page .play-pause-btn'); + if (playPauseBtn) { + playPauseBtn.textContent = isPlaying ? '⏸️' : '▶️'; + } +} + +/** + * Loads the user's personal audio stream into the player. + * @param {string} uid - The user's unique ID. + */ +export async function loadProfileStream(uid) { + const audioElement = getOrCreateAudioElement(); + const audioSrc = `/audio/${uid}/stream.opus?t=${Date.now()}`; + console.log(`[personal-player.js] Setting personal audio source to: ${audioSrc}`); + audioElement.src = audioSrc; +} + +/** + * Initializes the personal audio player, setting up event listeners. + */ +export function initPersonalPlayer() { + const mePageSection = document.getElementById('me-page'); + if (!mePageSection) return; + + // Use a delegated event listener for the play button + mePageSection.addEventListener('click', (e) => { + const playPauseBtn = e.target.closest('.play-pause-btn'); + if (!playPauseBtn) return; + + e.stopPropagation(); + const audio = getOrCreateAudioElement(); + if (!audio) return; + + try { + if (audio.paused) { + if (!audio.src || audio.src.endsWith('/#')) { + showToast('No audio file available. Please upload one first.', 'info'); + return; + } + + console.log('Attempting to play...'); + globalAudioManager.startPlayback('personal', localStorage.getItem('uid') || 'personal'); + + const playPromise = audio.play(); + if (playPromise !== undefined) { + playPromise.catch(error => { + console.error(`Initial play() failed: ${error.name}. This is expected on first load.`); + // If play fails, it's because the content isn't loaded. + // The recovery is to call load(). The user will need to click play again. + console.log('Calling load() to fetch media...'); + audio.load(); + showToast('Stream is loading. Please click play again in a moment.', 'info'); + }); + } + } else { + console.log('Attempting to pause...'); + audio.pause(); + } + } catch (err) { + console.error('A synchronous error occurred in handlePlayPause:', err); + showToast('An unexpected error occurred with the audio player.', 'error'); + } + }); + + // Listen for stop requests from the global manager + globalAudioManager.addListener('personal', () => { + console.log('[personal-player.js] Received stop request from global audio manager.'); + const audio = getOrCreateAudioElement(); + if (audio && !audio.paused) { + console.log('[personal-player.js] Pausing personal audio player.'); + audio.pause(); + } + }); + + // Initial setup + getOrCreateAudioElement(); +} diff --git a/static/reload.txt b/static/reload.txt deleted file mode 100644 index e69de29..0000000 diff --git a/static/router.js b/static/router.js deleted file mode 100644 index abd02f6..0000000 --- a/static/router.js +++ /dev/null @@ -1,168 +0,0 @@ -// static/router.js — core routing for SPA navigation -export const Router = { - sections: [], - // Map URL hashes to section IDs - sectionMap: { - 'welcome': 'welcome-page', - 'streams': 'stream-page', - 'account': 'register-page', - 'login': 'login-page', - 'me': 'me-page', - 'your-stream': 'me-page' // Map 'your-stream' to 'me-page' - }, - - init() { - this.sections = Array.from(document.querySelectorAll("main > section")); - // Set up hash change handler - window.addEventListener('hashchange', this.handleHashChange.bind(this)); - // Initial route - this.handleHashChange(); - }, - - handleHashChange() { - let hash = window.location.hash.substring(1) || 'welcome'; - - // First check if the hash matches any direct section ID - const directSection = this.sections.find(sec => sec.id === hash); - - if (directSection) { - // If it's a direct section ID match, show it directly - this.showOnly(hash); - } else { - // Otherwise, use the section map - const sectionId = this.sectionMap[hash] || hash; - this.showOnly(sectionId); - } - }, - - showOnly(id) { - if (!id) return; - - // Update URL hash without triggering hashchange - if (window.location.hash !== `#${id}`) { - window.history.pushState(null, '', `#${id}`); - } - - const isAuthenticated = document.body.classList.contains('authenticated'); - const isMePage = id === 'me-page' || id === 'your-stream'; - - // Helper function to update section visibility - const updateSection = (sec) => { - const isTarget = sec.id === id; - const isGuestOnly = sec.classList.contains('guest-only'); - const isAuthOnly = sec.classList.contains('auth-only'); - const isAlwaysVisible = sec.classList.contains('always-visible'); - const isQuotaMeter = sec.id === 'quota-meter'; - const isUserUploadArea = sec.id === 'user-upload-area'; - const isLogOut = sec.id === 'log-out'; - - // Determine if section should be visible - let shouldShow = isTarget; - - // Always show sections with always-visible class - if (isAlwaysVisible) { - shouldShow = true; - } - - // Handle guest-only sections - if (isGuestOnly && isAuthenticated) { - shouldShow = false; - } - - // Handle auth-only sections - if (isAuthOnly && !isAuthenticated) { - shouldShow = false; - } - - // Special case for me-page and its children - const isChildOfMePage = sec.closest('#me-page') !== null; - const shouldBeActive = isTarget || - (isQuotaMeter && isMePage) || - (isUserUploadArea && isMePage) || - (isLogOut && isMePage) || - (isChildOfMePage && isMePage); - - // Update visibility and tab index - sec.hidden = !shouldShow; - sec.tabIndex = shouldShow ? 0 : -1; - - // Update active state and ARIA attributes - if (shouldBeActive) { - sec.setAttribute('aria-current', 'page'); - sec.classList.add('active'); - - // Ensure target section is visible - if (sec.hidden) { - sec.style.display = 'block'; - sec.hidden = false; - } - - // Show all children of the active section - if (isTarget) { - sec.focus(); - // Make sure all auth-only children are visible - const authChildren = sec.querySelectorAll('.auth-only'); - authChildren.forEach(child => { - if (isAuthenticated) { - child.style.display = ''; - child.hidden = false; - } - }); - } - } else { - sec.removeAttribute('aria-current'); - sec.classList.remove('active'); - - // Reset display property for sections when not active - if (shouldShow && !isAlwaysVisible) { - sec.style.display = ''; // Reset to default from CSS - } - } - }; - - // Update all sections - this.sections.forEach(updateSection); - - // Update active nav links - document.querySelectorAll('[data-target], [href^="#"]').forEach(link => { - let target = link.getAttribute('data-target'); - const href = link.getAttribute('href'); - - // If no data-target, try to get from href - if (!target && href) { - // Remove any query parameters and # from the href - const hash = href.split('?')[0].substring(1); - // Use mapped section ID or the hash as is - target = this.sectionMap[hash] || hash; - } - - // Check if this link points to the current section or its mapped equivalent - const linkId = this.sectionMap[target] || target; - const currentId = this.sectionMap[id] || id; - - if (linkId === currentId) { - link.setAttribute('aria-current', 'page'); - link.classList.add('active'); - } else { - link.removeAttribute('aria-current'); - link.classList.remove('active'); - } - }); - - // Close mobile menu if open - const menuToggle = document.querySelector('.menu-toggle'); - if (menuToggle && menuToggle.getAttribute('aria-expanded') === 'true') { - menuToggle.setAttribute('aria-expanded', 'false'); - document.body.classList.remove('menu-open'); - } - - localStorage.setItem("last_page", id); - } -}; - -// Initialize router when DOM is loaded -document.addEventListener('DOMContentLoaded', () => { - Router.init(); -}); - -export const showOnly = Router.showOnly.bind(Router); diff --git a/static/streams-ui.js b/static/streams-ui.js index 2803338..2320f16 100644 --- a/static/streams-ui.js +++ b/static/streams-ui.js @@ -1,5 +1,5 @@ // static/streams-ui.js — public streams loader and profile-link handling -import { showOnly } from './router.js'; + import { globalAudioManager } from './global-audio-manager.js'; // Global variable to track if we should force refresh the stream list diff --git a/static/upload.js b/static/upload.js index 4147264..be33409 100644 --- a/static/upload.js +++ b/static/upload.js @@ -2,7 +2,7 @@ import { showToast } from "./toast.js"; import { playBeep } from "./sound.js"; -import { logToServer } from "./app.js"; +import { logToServer } from "./logger.js"; // Initialize upload system when DOM is loaded document.addEventListener('DOMContentLoaded', () => {