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:
3
.gitignore
vendored
3
.gitignore
vendored
@ -48,6 +48,9 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Local Database
|
||||||
|
dicta2stream.db
|
||||||
|
|
||||||
# Development directory
|
# Development directory
|
||||||
dev/
|
dev/
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel import Session, select
|
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
|
from database import get_db
|
||||||
import os
|
import os
|
||||||
from typing import Dict, Any
|
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}")
|
print(f"[DELETE_ACCOUNT] Processing delete request for UID: {uid} from IP: {ip}")
|
||||||
|
|
||||||
# Verify user exists and IP matches
|
# 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:
|
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")
|
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:
|
if user.ip != ip:
|
||||||
print(f"[DELETE_ACCOUNT] Error: IP mismatch. User IP: {user.ip}, Request 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")
|
raise HTTPException(status_code=403, detail="Unauthorized: IP address does not match")
|
||||||
|
|
||||||
# Start transaction
|
# Start transaction
|
||||||
try:
|
try:
|
||||||
# Delete user's upload logs
|
# Delete user's upload logs (use actual_uid which is always the email)
|
||||||
uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all()
|
uploads = db.exec(select(UploadLog).where(UploadLog.uid == actual_uid)).all()
|
||||||
for upload in uploads:
|
for upload in uploads:
|
||||||
db.delete(upload)
|
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
|
# Delete user's quota
|
||||||
quota = db.get(UserQuota, uid)
|
quota = db.get(UserQuota, actual_uid)
|
||||||
if quota:
|
if quota:
|
||||||
db.delete(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
|
# Delete user's active sessions (check both email and username as user_id)
|
||||||
sessions = db.exec(select(DBSession).where(DBSession.user_id == uid)).all()
|
sessions_by_email = db.exec(select(DBSession).where(DBSession.user_id == actual_uid)).all()
|
||||||
for session in sessions:
|
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)
|
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
|
# 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:
|
if user_obj:
|
||||||
db.delete(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()
|
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:
|
except Exception as e:
|
||||||
db.rollback()
|
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)}")
|
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
|
# 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"}
|
return {"status": "success", "message": "Account and all associated data have been deleted"}
|
||||||
|
|
||||||
except HTTPException as he:
|
except HTTPException as he:
|
||||||
|
@ -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
|
|
@ -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
|
|
@ -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}")
|
|
212
deletefile.py
212
deletefile.py
@ -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"
|
|
||||||
)
|
|
@ -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)
|
|
36
init_db.py
36
init_db.py
@ -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()
|
|
137
list_streams.py
137
list_streams.py
@ -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": []}
|
|
@ -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}
|
|
@ -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()
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||||||
{"uid":"devuser","size":90059327,"mtime":1752911461}
|
|
||||||
{"uid":"oibchello","size":16262818,"mtime":1752911899}
|
|
||||||
{"uid":"orangeicebear","size":1734396,"mtime":1748767975}
|
|
101
register.py
101
register.py
@ -51,30 +51,49 @@ def initialize_user_directory(username: str):
|
|||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
|
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
# Try to find user by email or username
|
from datetime import datetime
|
||||||
existing_user = db.get(User, email)
|
|
||||||
if not existing_user:
|
# Check if user exists by email
|
||||||
# Try by username (since username is not primary key, need to query)
|
existing_user_by_email = db.get(User, email)
|
||||||
stmt = select(User).where(User.username == user)
|
|
||||||
existing_user = db.exec(stmt).first()
|
# 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())
|
token = str(uuid.uuid4())
|
||||||
if existing_user:
|
|
||||||
# Update token, timestamp, and ip, set confirmed False
|
# Case 1: Email and username match in db - it's a login
|
||||||
from datetime import datetime
|
if existing_user_by_email and existing_user_by_username and existing_user_by_email.email == existing_user_by_username.email:
|
||||||
existing_user.token = token
|
# Update token for existing user (login)
|
||||||
existing_user.token_created = datetime.utcnow()
|
existing_user_by_email.token = token
|
||||||
existing_user.confirmed = False
|
existing_user_by_email.token_created = datetime.utcnow()
|
||||||
existing_user.ip = request.client.host
|
existing_user_by_email.confirmed = False
|
||||||
db.add(existing_user)
|
existing_user_by_email.ip = request.client.host
|
||||||
|
db.add(existing_user_by_email)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
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
|
# Register new user
|
||||||
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
|
new_user = User(email=email, username=user, token=token, confirmed=False, ip=request.client.host)
|
||||||
db.add(UserQuota(uid=user))
|
new_quota = UserQuota(uid=email) # Use email as UID for quota tracking
|
||||||
|
|
||||||
|
db.add(new_user)
|
||||||
|
db.add(new_quota)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First commit the user to the database
|
# First commit the user to the database
|
||||||
@ -86,30 +105,46 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
if isinstance(e, IntegrityError):
|
if isinstance(e, IntegrityError):
|
||||||
# Race condition: user created after our check
|
# Race condition: user created after our check
|
||||||
# Try again as login
|
# Check which constraint was violated to provide specific feedback
|
||||||
stmt = select(User).where((User.email == email) | (User.username == user))
|
error_str = str(e).lower()
|
||||||
existing_user = db.exec(stmt).first()
|
|
||||||
if existing_user:
|
if 'username' in error_str or 'user_username_key' in error_str:
|
||||||
existing_user.token = token
|
raise HTTPException(status_code=409, detail="👤 This username is already taken.\nPlease choose a different username.")
|
||||||
existing_user.confirmed = False
|
elif 'email' in error_str or 'user_pkey' in error_str:
|
||||||
existing_user.ip = request.client.host
|
raise HTTPException(status_code=409, detail="📧 This email is already registered with a different username.\nOnly one account per email is allowed.")
|
||||||
db.add(existing_user)
|
|
||||||
db.commit()
|
|
||||||
else:
|
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:
|
else:
|
||||||
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
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 = EmailMessage()
|
||||||
msg["From"] = MAGIC_FROM
|
msg["From"] = MAGIC_FROM
|
||||||
msg["To"] = email
|
msg["To"] = email
|
||||||
msg["Subject"] = "Your magic login link"
|
|
||||||
msg.set_content(
|
if action == "login":
|
||||||
f"Hello {user},\n\nClick to confirm your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time 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:
|
try:
|
||||||
with smtplib.SMTP("localhost") as smtp:
|
with smtplib.SMTP("localhost") as smtp:
|
||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Email failed: {e}")
|
raise HTTPException(status_code=500, detail=f"Email failed: {e}")
|
||||||
return { "message": "Confirmation sent" }
|
|
||||||
|
return {"message": response_message, "action": action}
|
||||||
|
@ -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()
|
|
1274
static/app.js
1274
static/app.js
File diff suppressed because it is too large
Load Diff
442
static/audio-player.js
Normal file
442
static/audio-player.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
// static/auth-ui.js — navigation link and back-button handlers
|
// 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)
|
// Data-target navigation (e.g., at #links)
|
||||||
export function initNavLinks() {
|
export function initNavLinks() {
|
||||||
@ -10,7 +10,7 @@ export function initNavLinks() {
|
|||||||
if (!a || !linksContainer.contains(a)) return;
|
if (!a || !linksContainer.contains(a)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const target = a.dataset.target;
|
const target = a.dataset.target;
|
||||||
if (target) showOnly(target);
|
if (target) showSection(target);
|
||||||
const burger = document.getElementById('burger-toggle');
|
const burger = document.getElementById('burger-toggle');
|
||||||
if (burger && burger.checked) burger.checked = false;
|
if (burger && burger.checked) burger.checked = false;
|
||||||
});
|
});
|
||||||
@ -22,7 +22,7 @@ export function initBackButtons() {
|
|||||||
btn.addEventListener('click', e => {
|
btn.addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const target = btn.dataset.back;
|
const target = btn.dataset.back;
|
||||||
if (target) showOnly(target);
|
if (target) showSection(target);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
252
static/auth.js
Normal file
252
static/auth.js
Normal 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();
|
||||||
|
});
|
@ -1,4 +1,5 @@
|
|||||||
import { showToast } from "./toast.js";
|
import { showToast } from "./toast.js";
|
||||||
|
import { showSection } from './nav.js';
|
||||||
|
|
||||||
// Utility function to get cookie value by name
|
// Utility function to get cookie value by name
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
@ -14,6 +15,7 @@ let isLoggingOut = false;
|
|||||||
|
|
||||||
async function handleLogout(event) {
|
async function handleLogout(event) {
|
||||||
console.log('[LOGOUT] Logout initiated');
|
console.log('[LOGOUT] Logout initiated');
|
||||||
|
|
||||||
// Prevent multiple simultaneous logout attempts
|
// Prevent multiple simultaneous logout attempts
|
||||||
if (isLoggingOut) {
|
if (isLoggingOut) {
|
||||||
console.log('[LOGOUT] Logout already in progress');
|
console.log('[LOGOUT] Logout already in progress');
|
||||||
@ -27,18 +29,66 @@ async function handleLogout(event) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get auth token before we clear it
|
try {
|
||||||
const authToken = localStorage.getItem('authToken');
|
// 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) {
|
// 1. Clear all client-side state first (most important)
|
||||||
try {
|
console.log('[LOGOUT] Clearing all client-side state');
|
||||||
// We'll use a timeout to prevent hanging on the server request
|
|
||||||
const controller = new AbortController();
|
// Clear localStorage and sessionStorage
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
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 {
|
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',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@ -47,141 +97,27 @@ async function handleLogout(event) {
|
|||||||
'Authorization': `Bearer ${authToken}`
|
'Authorization': `Bearer ${authToken}`
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
clearTimeout(timeoutId);
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
// Silently handle any errors during server logout
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently handle any unexpected errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Clear all client-side state
|
|
||||||
function clearClientState() {
|
|
||||||
console.log('[LOGOUT] Clearing client state');
|
|
||||||
|
|
||||||
// Clear all authentication-related data from localStorage
|
|
||||||
const keysToRemove = [
|
|
||||||
'uid', 'uid_time', 'confirmed_uid', 'last_page',
|
|
||||||
'isAuthenticated', 'authToken', 'user', 'token', 'sessionid'
|
|
||||||
];
|
|
||||||
|
|
||||||
keysToRemove.forEach(key => {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
sessionStorage.removeItem(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get current cookies for debugging
|
|
||||||
const cookies = document.cookie.split(';');
|
|
||||||
console.log('[LOGOUT] Current cookies before clearing:', cookies);
|
|
||||||
|
|
||||||
// Function to clear a cookie by name
|
|
||||||
const clearCookie = (name) => {
|
|
||||||
console.log(`[LOGOUT] Attempting to clear cookie: ${name}`);
|
|
||||||
const baseOptions = 'Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=Lax';
|
|
||||||
// Try with current domain
|
|
||||||
document.cookie = `${name}=; ${baseOptions}`;
|
|
||||||
// Try with domain
|
|
||||||
document.cookie = `${name}=; ${baseOptions}; domain=${window.location.hostname}`;
|
|
||||||
// Try with leading dot for subdomains
|
|
||||||
document.cookie = `${name}=; ${baseOptions}; domain=.${window.location.hostname}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear all authentication-related cookies
|
|
||||||
const authCookies = [
|
|
||||||
'uid', 'authToken', 'isAuthenticated', 'sessionid', 'session_id',
|
|
||||||
'token', 'remember_token', 'auth', 'authentication'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Clear specific auth cookies
|
|
||||||
authCookies.forEach(clearCookie);
|
|
||||||
|
|
||||||
// Also clear any existing cookies that match our patterns
|
|
||||||
cookies.forEach(cookie => {
|
|
||||||
const [name] = cookie.trim().split('=');
|
|
||||||
if (name && authCookies.some(authName => name.trim() === authName)) {
|
|
||||||
clearCookie(name.trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all cookies by setting them to expire in the past
|
|
||||||
document.cookie.split(';').forEach(cookie => {
|
|
||||||
const [name] = cookie.trim().split('=');
|
|
||||||
if (name) {
|
|
||||||
clearCookie(name.trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[LOGOUT] Cookies after clearing:', document.cookie);
|
|
||||||
|
|
||||||
// Update UI state
|
|
||||||
document.body.classList.remove('authenticated');
|
|
||||||
document.body.classList.add('guest');
|
|
||||||
|
|
||||||
// Force a hard reload to ensure all state is reset
|
|
||||||
setTimeout(() => {
|
|
||||||
// Clear all storage again before redirecting
|
|
||||||
keysToRemove.forEach(key => {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
sessionStorage.removeItem(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to home with a cache-busting parameter
|
|
||||||
window.location.href = '/?logout=' + Date.now();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Clear client state immediately to prevent any race conditions
|
|
||||||
clearClientState();
|
|
||||||
|
|
||||||
// 2. Try to invalidate the server session (but don't block on it)
|
|
||||||
console.log('[LOGOUT] Auth token exists:', !!authToken);
|
|
||||||
if (authToken) {
|
|
||||||
try {
|
|
||||||
console.log('[LOGOUT] Attempting to invalidate server session');
|
|
||||||
|
|
||||||
const response = await fetch('/api/logout', {
|
clearTimeout(timeoutId);
|
||||||
method: 'POST',
|
console.log('[LOGOUT] Server session invalidation completed');
|
||||||
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) {
|
} catch (error) {
|
||||||
console.warn('[LOGOUT] Error during server session invalidation (non-critical):', error);
|
console.warn('[LOGOUT] Server session invalidation failed (non-critical):', error);
|
||||||
// Continue with logout process
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update navigation if the function exists
|
// 3. Final redirect
|
||||||
if (typeof injectNavigation === 'function') {
|
console.log('[LOGOUT] Redirecting to home page');
|
||||||
injectNavigation(false);
|
window.location.href = '/?logout=' + Date.now();
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[LOGOUT] Logout completed');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[LOGOUT] Logout failed:', error);
|
console.error('[LOGOUT] Unexpected error during logout:', error);
|
||||||
if (window.showToast) {
|
if (window.showToast) {
|
||||||
showToast('Logout failed. Please try again.');
|
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 {
|
} finally {
|
||||||
isLoggingOut = false;
|
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) {
|
if (response.ok) {
|
||||||
showToast('Account deleted successfully');
|
showToast('Account deleted successfully');
|
||||||
|
|
||||||
// Clear user data
|
// Use comprehensive logout logic to clear all cookies and storage
|
||||||
localStorage.removeItem('uid');
|
console.log('🧹 Account deleted - clearing all authentication data...');
|
||||||
localStorage.removeItem('uid_time');
|
|
||||||
localStorage.removeItem('confirmed_uid');
|
// Clear all authentication-related data from localStorage
|
||||||
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
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
|
// Redirect to home page
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -274,333 +259,54 @@ function debugElementVisibility(elementId) {
|
|||||||
*/
|
*/
|
||||||
async function initDashboard() {
|
async function initDashboard() {
|
||||||
console.log('[DASHBOARD] Initializing dashboard...');
|
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 {
|
||||||
// Try to get UID from various sources
|
const guestDashboard = document.getElementById('guest-dashboard');
|
||||||
let uid = getCookie('uid') || localStorage.getItem('uid');
|
const userDashboard = document.getElementById('user-dashboard');
|
||||||
|
const userUpload = document.getElementById('user-upload-area');
|
||||||
// If we have a valid UID, try to fetch user data
|
const logoutButton = document.getElementById('logout-button');
|
||||||
if (uid && uid !== 'welcome-page' && uid !== 'undefined' && uid !== 'null') {
|
const deleteAccountButton = document.getElementById('delete-account-button');
|
||||||
console.log('[DASHBOARD] Found valid UID:', uid);
|
const fileList = document.getElementById('file-list');
|
||||||
console.log(`[DEBUG] Fetching user data for UID: ${uid}`);
|
|
||||||
const response = await fetch(`/me/${uid}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error(`[ERROR] Failed to fetch user data: ${response.status} ${response.statusText}`, errorText);
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and handle the response data
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('[DEBUG] User data loaded:', data);
|
|
||||||
|
|
||||||
// Ensure upload area is visible if last_page was me-page
|
|
||||||
if (userUpload && localStorage.getItem('last_page') === 'me-page') {
|
|
||||||
// userUpload visibility is now only controlled by nav.js SPA logic
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove guest warning if present
|
if (logoutButton) {
|
||||||
const guestMsg = document.getElementById('guest-warning-msg');
|
logoutButton.addEventListener('click', handleLogout);
|
||||||
if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg);
|
}
|
||||||
|
if (deleteAccountButton) {
|
||||||
// Show user dashboard and logout button
|
deleteAccountButton.addEventListener('click', (e) => {
|
||||||
if (userDashboard) userDashboard.style.display = '';
|
e.preventDefault();
|
||||||
if (logoutButton) {
|
handleDeleteAccount();
|
||||||
logoutButton.style.display = 'block';
|
});
|
||||||
logoutButton.onclick = handleLogout;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Set audio source
|
const isAuthenticated = (document.cookie.includes('isAuthenticated=true') || localStorage.getItem('isAuthenticated') === 'true');
|
||||||
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
|
if (isAuthenticated) {
|
||||||
const quotaMeter = document.getElementById('quota-meter');
|
document.body.classList.add('authenticated');
|
||||||
const quotaBar = document.getElementById('quota-bar');
|
document.body.classList.remove('guest-mode');
|
||||||
const quotaText = document.getElementById('quota-text');
|
if (userDashboard) userDashboard.style.display = 'block';
|
||||||
|
if (userUpload) userUpload.style.display = 'block';
|
||||||
if (quotaBar && data.quota !== undefined) {
|
if (guestDashboard) guestDashboard.style.display = 'none';
|
||||||
quotaBar.value = data.quota;
|
|
||||||
}
|
const uid = getCookie('uid') || localStorage.getItem('uid');
|
||||||
|
if (uid && window.fetchAndDisplayFiles) {
|
||||||
if (quotaText && data.quota !== undefined) {
|
await window.fetchAndDisplayFiles(uid);
|
||||||
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!');
|
|
||||||
}
|
}
|
||||||
} else {
|
} 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');
|
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) {
|
} catch (e) {
|
||||||
console.warn('Dashboard init error, falling back to guest mode:', e);
|
console.error('Dashboard initialization failed:', e);
|
||||||
|
const guestDashboard = document.getElementById('guest-dashboard');
|
||||||
// Ensure guest UI is shown
|
const userDashboard = document.getElementById('user-dashboard');
|
||||||
userUpload.style.display = 'none';
|
if (userDashboard) userDashboard.style.display = 'none';
|
||||||
userDashboard.style.display = 'none';
|
|
||||||
if (guestDashboard) guestDashboard.style.display = 'block';
|
if (guestDashboard) guestDashboard.style.display = 'block';
|
||||||
|
|
||||||
// Update body classes
|
|
||||||
document.body.classList.remove('authenticated');
|
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
|
// Initialize dashboard components
|
||||||
initDashboard(); // initFileUpload is called from within initDashboard
|
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) => {
|
document.addEventListener('click', (e) => {
|
||||||
const deleteButton = e.target.closest('.delete-file');
|
// Logout Button
|
||||||
if (!deleteButton) return;
|
if (e.target.closest('#logout-button')) {
|
||||||
|
e.preventDefault();
|
||||||
e.preventDefault();
|
handleLogout(e);
|
||||||
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');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = deleteButton.getAttribute('data-filename');
|
// Delete File Button
|
||||||
const displayName = deleteButton.getAttribute('data-original-name') || fileName;
|
const deleteButton = e.target.closest('.delete-file');
|
||||||
|
if (deleteButton) {
|
||||||
// Pass the UID to deleteFile
|
e.preventDefault();
|
||||||
deleteFile(uid, fileName, listItem, displayName);
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const listItem = deleteButton.closest('.file-item');
|
||||||
|
if (!listItem) return;
|
||||||
|
|
||||||
|
const uid = localStorage.getItem('uid') || localStorage.getItem('confirmed_uid');
|
||||||
|
if (!uid) {
|
||||||
|
showToast('You need to be logged in to delete files', 'error');
|
||||||
|
console.error('[DELETE] No UID found in localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = deleteButton.getAttribute('data-filename');
|
||||||
|
const displayName = deleteButton.getAttribute('data-original-name') || fileName;
|
||||||
|
|
||||||
|
deleteFile(uid, fileName, listItem, displayName);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make fetchAndDisplayFiles available globally
|
// Make fetchAndDisplayFiles available globally
|
||||||
@ -1062,184 +774,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Connect Login or Register link to register form
|
// Connect Login or Register link to register form
|
||||||
// Login/Register (guest)
|
// All navigation is now handled by the global click and hashchange listeners in nav.js.
|
||||||
const loginLink = document.getElementById('guest-login');
|
// The legacy setupPageNavigation function and manual nav link handlers have been removed.
|
||||||
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'});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// Handle drag and drop
|
||||||
const uploadArea = document.getElementById('upload-area');
|
const uploadArea = document.getElementById('upload-area');
|
||||||
if (uploadArea) {
|
if (uploadArea) {
|
||||||
@ -1281,4 +819,3 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}); // End of DOMContentLoaded
|
|
||||||
|
@ -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
|
|
||||||
};
|
|
@ -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
|
|
@ -23,6 +23,7 @@ class GlobalAudioManager {
|
|||||||
* @param {Object} playerInstance - Reference to the player instance
|
* @param {Object} playerInstance - Reference to the player instance
|
||||||
*/
|
*/
|
||||||
startPlayback(playerType, uid, playerInstance = null) {
|
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 the same player is already playing the same UID, allow it
|
||||||
if (this.currentPlayer === playerType && this.currentUid === uid) {
|
if (this.currentPlayer === playerType && this.currentUid === uid) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -22,7 +22,8 @@
|
|||||||
</style>
|
</style>
|
||||||
<link rel="modulepreload" href="/static/sound.js" />
|
<link rel="modulepreload" href="/static/sound.js" />
|
||||||
<script src="/static/streams-ui.js?v=3" type="module"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@ -36,7 +37,7 @@
|
|||||||
<nav id="guest-dashboard" class="dashboard-nav guest-only">
|
<nav id="guest-dashboard" class="dashboard-nav guest-only">
|
||||||
<a href="#welcome-page" id="guest-welcome">Welcome</a>
|
<a href="#welcome-page" id="guest-welcome">Welcome</a>
|
||||||
<a href="#stream-page" id="guest-streams">Streams</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>
|
</nav>
|
||||||
|
|
||||||
<!-- User Dashboard -->
|
<!-- User Dashboard -->
|
||||||
@ -47,7 +48,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<section id="me-page" class="auth-only">
|
<section id="me-page" class="auth-only">
|
||||||
<div>
|
<div>
|
||||||
<h2>Your Stream</h2>
|
<h2 id="your-stream-heading">Your Stream</h2>
|
||||||
</div>
|
</div>
|
||||||
<article>
|
<article>
|
||||||
<p>This is your personal stream. Only you can upload to it.</p>
|
<p>This is your personal stream. Only you can upload to it.</p>
|
||||||
@ -187,27 +188,18 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p class="footer-links">
|
<p class="footer-links">
|
||||||
<a href="#" id="footer-terms" data-target="terms-page">Terms</a> |
|
<a href="#terms-page" id="footer-terms">Terms</a> |
|
||||||
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
|
<a href="#privacy-page" id="footer-privacy">Privacy</a> |
|
||||||
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
|
<a href="#imprint-page" id="footer-imprint">Imprint</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script type="module" src="/static/dashboard.js"></script>
|
<script type="module" src="/static/dashboard.js?v=5"></script>
|
||||||
<script type="module" src="/static/app.js"></script>
|
|
||||||
<!-- Load public streams UI logic -->
|
<!-- Load public streams UI logic -->
|
||||||
<script type="module" src="/static/streams-ui.js?v=3"></script>
|
<script type="module" src="/static/streams-ui.js?v=3"></script>
|
||||||
<!-- Load upload functionality -->
|
<!-- Load upload functionality -->
|
||||||
<script type="module" src="/static/upload.js"></script>
|
<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">
|
<script type="module">
|
||||||
import { initMagicLogin } from '/static/magic-login.js';
|
import { initMagicLogin } from '/static/magic-login.js';
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@ -220,7 +212,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/static/init-personal-stream.js"></script>
|
<script type="module" src="/static/init-personal-stream.js"></script>
|
||||||
<!-- Temporary fix for mobile navigation -->
|
<script type="module" src="/static/personal-player.js"></script>
|
||||||
<script src="/static/fix-nav.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -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
6
static/logger.js
Normal 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 }));
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
// static/magic-login.js — handles magic‑link token UI
|
// static/magic-login.js — handles magic‑link token UI
|
||||||
import { showOnly } from './router.js';
|
import { showSection } from './nav.js';
|
||||||
|
|
||||||
let magicLoginSubmitted = false;
|
let magicLoginSubmitted = false;
|
||||||
|
|
||||||
@ -73,8 +73,8 @@ export async function initMagicLogin() {
|
|||||||
if (registerPage) registerPage.style.display = 'none';
|
if (registerPage) registerPage.style.display = 'none';
|
||||||
|
|
||||||
// Show the user's stream page
|
// Show the user's stream page
|
||||||
if (window.showOnly) {
|
if (typeof showSection === 'function') {
|
||||||
window.showOnly('me-page');
|
showSection('me-page');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
515
static/nav.js
515
static/nav.js
@ -7,468 +7,97 @@ function getCookie(name) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
// Determines the correct section to show based on auth status and requested section
|
||||||
// Check authentication status
|
function getValidSection(sectionId) {
|
||||||
const isLoggedIn = !!getCookie('uid');
|
const isLoggedIn = !!getCookie('uid');
|
||||||
|
const protectedSections = ['me-page', 'account-page'];
|
||||||
// Update body class for CSS-based visibility
|
const guestOnlySections = ['login-page', 'register-page', 'magic-login-page'];
|
||||||
document.body.classList.toggle('logged-in', isLoggedIn);
|
|
||||||
|
if (isLoggedIn) {
|
||||||
// Get all main content sections
|
// If logged in, guest-only sections are invalid, redirect to 'me-page'
|
||||||
const mainSections = Array.from(document.querySelectorAll('main > section'));
|
if (guestOnlySections.includes(sectionId)) {
|
||||||
|
return 'me-page';
|
||||||
// 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}`);
|
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.add('page-welcome');
|
// If not logged in, protected sections are invalid, redirect to 'welcome-page'
|
||||||
|
if (protectedSections.includes(sectionId)) {
|
||||||
|
return 'welcome-page';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the section doesn't exist in the DOM, default to welcome page
|
||||||
|
if (!document.getElementById(sectionId)) {
|
||||||
|
return 'welcome-page';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to show/hide sections
|
||||||
|
export function showSection(sectionId) {
|
||||||
|
const mainSections = Array.from(document.querySelectorAll('main > section'));
|
||||||
|
|
||||||
|
// Update body class for page-specific CSS
|
||||||
|
document.body.className = document.body.className.replace(/page-\S+/g, '');
|
||||||
|
document.body.classList.add(`page-${sectionId || 'welcome-page'}`);
|
||||||
|
|
||||||
// Update active state of navigation links
|
// Update active state of navigation links
|
||||||
document.querySelectorAll('.dashboard-nav a').forEach(link => {
|
document.querySelectorAll('.dashboard-nav a').forEach(link => {
|
||||||
link.classList.remove('active');
|
link.classList.remove('active');
|
||||||
if ((!sectionId && link.getAttribute('href') === '#welcome-page') ||
|
if (link.getAttribute('href') === `#${sectionId}`) {
|
||||||
(sectionId && link.getAttribute('href') === `#${sectionId}`)) {
|
link.classList.add('active');
|
||||||
link.classList.add('active');
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mainSections.forEach(section => {
|
mainSections.forEach(section => {
|
||||||
// Skip navigation sections
|
section.hidden = section.id !== sectionId;
|
||||||
if (section.id === 'guest-dashboard' || section.id === 'user-dashboard') {
|
});
|
||||||
return;
|
|
||||||
}
|
// Update URL hash without causing a page scroll, this is for direct calls to showSection
|
||||||
|
// Normal navigation is handled by the hashchange listener
|
||||||
const isTarget = section.id === sectionId;
|
const currentHash = `#${sectionId}`;
|
||||||
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
|
if (window.location.hash !== currentHash) {
|
||||||
const isWelcomePage = !sectionId || sectionId === 'welcome-page';
|
if (history.pushState) {
|
||||||
|
if (sectionId && sectionId !== 'welcome-page') {
|
||||||
if (isTarget || (isLegalPage && section.id === sectionId)) {
|
history.pushState(null, null, currentHash);
|
||||||
// Show the target section or legal page
|
} else {
|
||||||
section.classList.add('active');
|
history.pushState(null, null, window.location.pathname + window.location.search);
|
||||||
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 (!document.getElementById(sectionId)) {
|
|
||||||
return 'welcome-page';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sectionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Process initial page load
|
|
||||||
const initialPage = window.location.hash.substring(1) || 'welcome-page';
|
|
||||||
const validSection = getValidSection(initialPage);
|
|
||||||
|
|
||||||
// Update URL if needed
|
|
||||||
if (validSection !== initialPage) {
|
|
||||||
window.location.hash = validSection;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the appropriate section
|
|
||||||
showSection(validSection);
|
|
||||||
|
|
||||||
const Router = {
|
|
||||||
sections: Array.from(document.querySelectorAll("main > section")),
|
|
||||||
|
|
||||||
showOnly(id) {
|
|
||||||
// Validate the section ID
|
|
||||||
const validId = getValidSection(id);
|
|
||||||
|
|
||||||
// Update URL if needed
|
|
||||||
if (validId !== id) {
|
|
||||||
window.location.hash = validId;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the requested section
|
|
||||||
showSection(validId);
|
|
||||||
|
|
||||||
// Handle the quota meter visibility - only show with 'me-page'
|
|
||||||
const quotaMeter = document.getElementById('quota-meter');
|
|
||||||
if (quotaMeter) {
|
|
||||||
quotaMeter.hidden = validId !== 'me-page';
|
|
||||||
quotaMeter.tabIndex = validId === 'me-page' ? 0 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update navigation active states
|
|
||||||
this.updateActiveNav(validId);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateActiveNav(activeId) {
|
|
||||||
// Update active states for navigation links
|
|
||||||
document.querySelectorAll('.dashboard-nav a').forEach(link => {
|
|
||||||
const target = link.getAttribute('href').substring(1);
|
|
||||||
if (target === activeId) {
|
|
||||||
link.setAttribute('aria-current', 'page');
|
|
||||||
link.classList.add('active');
|
|
||||||
} else {
|
|
||||||
link.removeAttribute('aria-current');
|
|
||||||
link.classList.remove('active');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Initialize the router
|
|
||||||
const router = Router;
|
|
||||||
|
|
||||||
// Handle section visibility based on authentication
|
|
||||||
const updateSectionVisibility = (sectionId) => {
|
|
||||||
const section = document.getElementById(sectionId);
|
|
||||||
if (!section) return;
|
|
||||||
|
|
||||||
// Skip navigation sections and quota meter
|
|
||||||
if (['guest-dashboard', 'user-dashboard', 'quota-meter'].includes(sectionId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentHash = window.location.hash.substring(1);
|
|
||||||
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
|
|
||||||
|
|
||||||
// Special handling for legal pages - always show when in hash
|
|
||||||
if (isLegalPage) {
|
|
||||||
const isActive = sectionId === currentHash;
|
|
||||||
section.hidden = !isActive;
|
|
||||||
section.tabIndex = isActive ? 0 : -1;
|
|
||||||
if (isActive) section.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for me-page - only show to authenticated users
|
|
||||||
if (sectionId === 'me-page') {
|
|
||||||
section.hidden = !isLoggedIn || currentHash !== 'me-page';
|
|
||||||
section.tabIndex = (isLoggedIn && currentHash === 'me-page') ? 0 : -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for register page - only show to guests
|
|
||||||
if (sectionId === 'register-page') {
|
|
||||||
section.hidden = isLoggedIn || currentHash !== 'register-page';
|
|
||||||
section.tabIndex = (!isLoggedIn && currentHash === 'register-page') ? 0 : -1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other sections, show if they match the current section ID
|
|
||||||
const isActive = sectionId === currentHash;
|
|
||||||
section.hidden = !isActive;
|
|
||||||
section.tabIndex = isActive ? 0 : -1;
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
section.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize the router
|
|
||||||
router.init = function() {
|
|
||||||
// Update visibility for all sections
|
|
||||||
this.sections.forEach(section => {
|
|
||||||
updateSectionVisibility(section.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show user-upload-area only when me-page is shown and user is logged in
|
|
||||||
const userUpload = document.getElementById("user-upload-area");
|
|
||||||
if (userUpload) {
|
|
||||||
const uid = getCookie("uid");
|
|
||||||
userUpload.style.display = (window.location.hash === '#me-page' && uid) ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the current page
|
|
||||||
localStorage.setItem("last_page", window.location.hash.substring(1));
|
|
||||||
|
|
||||||
// Initialize navigation
|
|
||||||
initNavLinks();
|
|
||||||
initBackButtons();
|
|
||||||
initStreamLinks();
|
|
||||||
|
|
||||||
// Ensure proper focus management for accessibility
|
|
||||||
const currentSection = document.querySelector('main > section:not([hidden])');
|
|
||||||
if (currentSection) {
|
|
||||||
currentSection.setAttribute('tabindex', '0');
|
|
||||||
currentSection.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize the router
|
|
||||||
router.init();
|
|
||||||
|
|
||||||
// Handle footer links
|
|
||||||
document.querySelectorAll('.footer-links a').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const target = link.dataset.target;
|
|
||||||
if (target) {
|
|
||||||
// Update URL hash to maintain proper history state
|
|
||||||
window.location.hash = target;
|
|
||||||
// Use the router to handle the navigation
|
|
||||||
if (router && typeof router.showOnly === 'function') {
|
|
||||||
router.showOnly(target);
|
|
||||||
} else {
|
|
||||||
// Fallback to showSection if router is not available
|
|
||||||
showSection(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export the showOnly function for global access
|
|
||||||
window.showOnly = router.showOnly.bind(router);
|
|
||||||
|
|
||||||
// Make router available globally for debugging
|
|
||||||
window.appRouter = router;
|
|
||||||
|
|
||||||
// Highlight active profile link on browser back/forward navigation
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
function highlightActiveProfileLink() {
|
const isLoggedIn = !!getCookie('uid');
|
||||||
const params = new URLSearchParams(window.location.search);
|
document.body.classList.toggle('authenticated', isLoggedIn);
|
||||||
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 magic‑link token present) */
|
// Unified click handler for SPA navigation
|
||||||
const params = new URLSearchParams(location.search);
|
document.body.addEventListener('click', (e) => {
|
||||||
const token = params.get("token");
|
const link = e.target.closest('a[href^="#"]');
|
||||||
if (!token) {
|
// Ensure the link is not inside a component that handles its own navigation
|
||||||
const last = localStorage.getItem("last_page");
|
if (!link || link.closest('.no-global-nav')) return;
|
||||||
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 magic‑login page */
|
|
||||||
if (token) {
|
|
||||||
document.getElementById("magic-token").value = token;
|
|
||||||
showOnly("magic-login-page");
|
|
||||||
const err = params.get("error");
|
|
||||||
if (err) {
|
|
||||||
const box = document.getElementById("magic-error");
|
|
||||||
box.textContent = decodeURIComponent(err);
|
|
||||||
box.style.display = "block";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function renderStreamList(streams) {
|
|
||||||
const ul = document.getElementById("stream-list");
|
|
||||||
if (!ul) return;
|
|
||||||
if (streams.length) {
|
|
||||||
// Handle both array of UIDs (legacy) and array of stream objects (new)
|
|
||||||
const streamItems = streams.map(item => {
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
// Legacy: array of UIDs
|
|
||||||
return { uid: item, username: item };
|
|
||||||
} else {
|
|
||||||
// New: array of stream objects
|
|
||||||
return {
|
|
||||||
uid: item.uid || '',
|
|
||||||
username: item.username || 'Unknown User'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
streamItems.sort((a, b) => (a.username || '').localeCompare(b.username || ''));
|
|
||||||
ul.innerHTML = streamItems.map(stream => `
|
|
||||||
<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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
e.preventDefault();
|
||||||
|
const newHash = link.getAttribute('href');
|
||||||
// Save audio state before navigation
|
if (window.location.hash !== newHash) {
|
||||||
const audio = document.getElementById('me-audio');
|
window.location.hash = newHash;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
// 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
|
// Main routing logic on hash change
|
||||||
document.querySelectorAll(".footer-links a").forEach(link => {
|
const handleNavigation = () => {
|
||||||
link.addEventListener("click", (e) => {
|
const sectionId = window.location.hash.substring(1) || 'welcome-page';
|
||||||
e.preventDefault();
|
const validSectionId = getValidSection(sectionId);
|
||||||
const target = link.dataset.target;
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
// Save audio state before navigation
|
if (sectionId !== validSectionId) {
|
||||||
const audio = document.getElementById('me-audio');
|
window.location.hash = validSectionId; // This will re-trigger handleNavigation
|
||||||
const wasPlaying = audio && !audio.paused;
|
} else {
|
||||||
const currentTime = audio ? audio.currentTime : 0;
|
showSection(validSectionId);
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
// Initial page load
|
||||||
function initStreamLinks() {
|
handleNavigation();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
140
static/personal-player.js
Normal file
140
static/personal-player.js
Normal 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();
|
||||||
|
}
|
168
static/router.js
168
static/router.js
@ -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);
|
|
@ -1,5 +1,5 @@
|
|||||||
// static/streams-ui.js — public streams loader and profile-link handling
|
// static/streams-ui.js — public streams loader and profile-link handling
|
||||||
import { showOnly } from './router.js';
|
|
||||||
import { globalAudioManager } from './global-audio-manager.js';
|
import { globalAudioManager } from './global-audio-manager.js';
|
||||||
|
|
||||||
// Global variable to track if we should force refresh the stream list
|
// Global variable to track if we should force refresh the stream list
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { showToast } from "./toast.js";
|
import { showToast } from "./toast.js";
|
||||||
import { playBeep } from "./sound.js";
|
import { playBeep } from "./sound.js";
|
||||||
import { logToServer } from "./app.js";
|
import { logToServer } from "./logger.js";
|
||||||
|
|
||||||
// Initialize upload system when DOM is loaded
|
// Initialize upload system when DOM is loaded
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
Reference in New Issue
Block a user