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.
This commit is contained in:
oib
2025-07-28 16:42:46 +02:00
parent 88e468b716
commit d497492186
34 changed files with 1279 additions and 3810 deletions

3
.gitignore vendored
View File

@ -48,6 +48,9 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local Database
dicta2stream.db
# Development directory
dev/

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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('<H', 80)) # Preskip (80 samples)
f.write(struct.pack('<I', SAMPLE_RATE)) # Input sample rate
f.write(b'\x00\x00') # Output gain
f.write(b'\x00') # Channel mapping family (0 = mono/stereo)
# Write comment header
f.write(b'OpusTags') # Magic signature
f.write(struct.pack('<I', 0)) # Vendor string length (0 for none)
f.write(struct.pack('<I', 0)) # Number of comments (0)
# Encode and write silent frames
for _ in range(num_frames):
# Encode the silent frame
encoded = enc.encode(silent_frame, FRAME_SIZE)
# Write Ogg page
f.write(b'OggS') # Magic number
f.write(b'\x00') # Version
f.write(b'\x00') # Header type (0 = normal)
f.write(struct.pack('<Q', (FRAME_SIZE * _) % (1 << 64))) # Granule position
f.write(b'\x00\x00\x00\x00') # Bitstream serial number
f.write(struct.pack('<I', _ + 2)) # Page sequence number
f.write(b'\x00\x00\x00\x00') # Checksum (0 for now)
f.write(b'\x01') # Number of segments
f.write(chr(len(encoded)).encode('latin1')) # Segment length
f.write(encoded) # The encoded data
print(f"Created silent OPUS file: {OUTPUT_FILE}")

View File

@ -1,212 +0,0 @@
# deletefile.py — FastAPI route for file deletion
import os
import shutil
from typing import Optional, Dict, Any
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, Depends, status, Header
from sqlalchemy import select, delete, and_
from sqlalchemy.orm import Session
from database import get_db
from models import UploadLog, UserQuota, User, DBSession
router = APIRouter()
# Use absolute path for security
DATA_ROOT = Path(os.path.abspath("./data"))
def get_current_user(
authorization: str = Header(None, description="Bearer token for authentication"),
db: Session = Depends(get_db)
) -> 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"
)

View File

@ -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)

View File

@ -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()

View File

@ -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": []}

View File

@ -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}

View File

@ -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()

View File

@ -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

View File

@ -1,3 +0,0 @@
{"uid":"devuser","size":90059327,"mtime":1752911461}
{"uid":"oibchello","size":16262818,"mtime":1752911899}
{"uid":"orangeicebear","size":1734396,"mtime":1748767975}

View File

@ -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}

View File

View File

@ -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()

File diff suppressed because it is too large Load Diff

442
static/audio-player.js Normal file
View File

@ -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();
});
}

View File

@ -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);
});
});
}

252
static/auth.js Normal file
View File

@ -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();
});

View File

@ -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');
try {
// 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);
// 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);
console.log('[LOGOUT] Server session invalidation completed');
} 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');
}
} 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 = `
<li class="error-message">
Error: Could not determine user account. Please <a href="/#login" class="login-link">log in</a> again.
</li>`;
}
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 = `
<li class="error-message">
Error loading files: ${error.message || 'Unknown error'}.
Please <a href="/#login" class="login-link">log in</a> again.
</li>`;
}
}
}
} 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 = `
<li class="error-message">
Please <a href="/#login" class="login-link">log in</a> to view your files.
</li>`;
}
}
// 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 = `
<li class="error-message">
Please <a href="/#login" class="login-link">log in</a> to view your files.
</li>`;
}
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');
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');
// 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}`);
}
if (logoutButton) {
logoutButton.addEventListener('click', handleLogout);
}
if (deleteAccountButton) {
deleteAccountButton.addEventListener('click', (e) => {
e.preventDefault();
handleDeleteAccount();
});
}
// Parse and handle the response data
const data = await response.json();
console.log('[DEBUG] User data loaded:', data);
const isAuthenticated = (document.cookie.includes('isAuthenticated=true') || localStorage.getItem('isAuthenticated') === 'true');
// 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
}
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';
// 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;
}
// 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);
}
}
// 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!');
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 = `<li class="error-message">Please <a href="/#login" class="login-link">log in</a> to view your files.</li>`;
}
}
// 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;
// Delete File Button
const deleteButton = e.target.closest('.delete-file');
if (deleteButton) {
e.preventDefault();
e.stopPropagation();
// Pass the UID to deleteFile
deleteFile(uid, fileName, listItem, displayName);
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

View File

@ -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
};

View File

@ -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

View File

@ -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;

View File

@ -22,7 +22,8 @@
</style>
<link rel="modulepreload" href="/static/sound.js" />
<script src="/static/streams-ui.js?v=3" type="module"></script>
<script src="/static/app.js" type="module"></script>
<script src="/static/auth.js?v=2" type="module"></script>
<script src="/static/app.js?v=5" type="module"></script>
</head>
<body>
<header>
@ -36,7 +37,7 @@
<nav id="guest-dashboard" class="dashboard-nav guest-only">
<a href="#welcome-page" id="guest-welcome">Welcome</a>
<a href="#stream-page" id="guest-streams">Streams</a>
<a href="#account" id="guest-login">Account</a>
<a href="#register-page" id="guest-login">Account</a>
</nav>
<!-- User Dashboard -->
@ -47,7 +48,7 @@
</nav>
<section id="me-page" class="auth-only">
<div>
<h2>Your Stream</h2>
<h2 id="your-stream-heading">Your Stream</h2>
</div>
<article>
<p>This is your personal stream. Only you can upload to it.</p>
@ -187,27 +188,18 @@
<footer>
<p class="footer-links">
<a href="#" id="footer-terms" data-target="terms-page">Terms</a> |
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
<a href="#terms-page" id="footer-terms">Terms</a> |
<a href="#privacy-page" id="footer-privacy">Privacy</a> |
<a href="#imprint-page" id="footer-imprint">Imprint</a>
</p>
</footer>
<script type="module" src="/static/dashboard.js"></script>
<script type="module" src="/static/app.js"></script>
<script type="module" src="/static/dashboard.js?v=5"></script>
<!-- Load public streams UI logic -->
<script type="module" src="/static/streams-ui.js?v=3"></script>
<!-- Load upload functionality -->
<script type="module" src="/static/upload.js"></script>
<script type="module">
import "/static/nav.js?v=2";
window.addEventListener("pageshow", () => {
const dz = document.querySelector("#user-upload-area");
if (dz) dz.classList.remove("uploading");
const spinner = document.querySelector("#spinner");
if (spinner) spinner.style.display = "none";
});
</script>
<script type="module">
import { initMagicLogin } from '/static/magic-login.js';
const params = new URLSearchParams(window.location.search);
@ -220,7 +212,7 @@
}
</script>
<script type="module" src="/static/init-personal-stream.js"></script>
<!-- Temporary fix for mobile navigation -->
<script src="/static/fix-nav.js"></script>
<script type="module" src="/static/personal-player.js"></script>
</body>
</html>

View File

@ -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;

6
static/logger.js Normal file
View File

@ -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 }));
}

View File

@ -1,5 +1,5 @@
// static/magic-login.js — handles magiclink 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;

View File

@ -7,468 +7,97 @@ function getCookie(name) {
return null;
}
document.addEventListener("DOMContentLoaded", () => {
// Check authentication status
const isLoggedIn = !!getCookie('uid');
// 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'];
// 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}`);
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';
}
}
// 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');
}
});
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 });
}
} 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 the section doesn't exist in the DOM, default to welcome page
if (!document.getElementById(sectionId)) {
return 'welcome-page';
return 'welcome-page';
}
return sectionId;
};
}
// Process initial page load
const initialPage = window.location.hash.substring(1) || 'welcome-page';
const validSection = getValidSection(initialPage);
// Main function to show/hide sections
export function showSection(sectionId) {
const mainSections = Array.from(document.querySelectorAll('main > section'));
// Update URL if needed
if (validSection !== initialPage) {
window.location.hash = validSection;
}
// 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'}`);
// 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');
// Update active state of navigation links
document.querySelectorAll('.dashboard-nav a').forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === `#${sectionId}`) {
link.classList.add('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);
}
}
mainSections.forEach(section => {
section.hidden = section.id !== sectionId;
});
});
// 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();
}
});
/* restore last page (unless magiclink 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();
}
/* token → show magiclogin 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'
};
// 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);
}
}
});
streamItems.sort((a, b) => (a.username || '').localeCompare(b.username || ''));
ul.innerHTML = streamItems.map(stream => `
<li><a href="/?profile=${encodeURIComponent(stream.uid)}" class="profile-link">▶ ${stream.username}</a></li>
`).join("");
} else {
ul.innerHTML = "<li>No active streams.</li>";
}
// Ensure correct link is active after rendering
highlightActiveProfileLink();
}
}
document.addEventListener("DOMContentLoaded", () => {
const isLoggedIn = !!getCookie('uid');
document.body.classList.toggle('authenticated', isLoggedIn);
// 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;
// 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();
});

140
static/personal-player.js Normal file
View File

@ -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();
}

View File

View File

@ -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);

View File

@ -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

View File

@ -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', () => {