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-error.log*
|
||||
|
||||
# Local Database
|
||||
dicta2stream.db
|
||||
|
||||
# Development directory
|
||||
dev/
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel import Session, select
|
||||
from models import User, UserQuota, UploadLog, DBSession
|
||||
from models import User, UserQuota, UploadLog, DBSession, PublicStream
|
||||
from database import get_db
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
@ -23,43 +23,71 @@ async def delete_account(data: Dict[str, Any], request: Request, db: Session = D
|
||||
print(f"[DELETE_ACCOUNT] Processing delete request for UID: {uid} from IP: {ip}")
|
||||
|
||||
# Verify user exists and IP matches
|
||||
user = db.exec(select(User).where(User.username == uid)).first()
|
||||
# Handle both email-based and username-based UIDs for backward compatibility
|
||||
user = None
|
||||
|
||||
# First try to find by email (new UID format)
|
||||
if '@' in uid:
|
||||
user = db.exec(select(User).where(User.email == uid)).first()
|
||||
print(f"[DELETE_ACCOUNT] Looking up user by email: {uid}")
|
||||
|
||||
# If not found by email, try by username (legacy UID format)
|
||||
if not user:
|
||||
print(f"[DELETE_ACCOUNT] Error: User {uid} not found")
|
||||
user = db.exec(select(User).where(User.username == uid)).first()
|
||||
print(f"[DELETE_ACCOUNT] Looking up user by username: {uid}")
|
||||
|
||||
if not user:
|
||||
print(f"[DELETE_ACCOUNT] Error: User {uid} not found (tried both email and username lookup)")
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Use the actual email as the UID for database operations
|
||||
actual_uid = user.email
|
||||
print(f"[DELETE_ACCOUNT] Found user: {user.username} ({user.email}), using email as UID: {actual_uid}")
|
||||
|
||||
if user.ip != ip:
|
||||
print(f"[DELETE_ACCOUNT] Error: IP mismatch. User IP: {user.ip}, Request IP: {ip}")
|
||||
raise HTTPException(status_code=403, detail="Unauthorized: IP address does not match")
|
||||
|
||||
# Start transaction
|
||||
try:
|
||||
# Delete user's upload logs
|
||||
uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all()
|
||||
# Delete user's upload logs (use actual_uid which is always the email)
|
||||
uploads = db.exec(select(UploadLog).where(UploadLog.uid == actual_uid)).all()
|
||||
for upload in uploads:
|
||||
db.delete(upload)
|
||||
print(f"[DELETE_ACCOUNT] Deleted {len(uploads)} upload logs for user {uid}")
|
||||
print(f"[DELETE_ACCOUNT] Deleted {len(uploads)} upload logs for user {actual_uid}")
|
||||
|
||||
# Delete user's public streams
|
||||
streams = db.exec(select(PublicStream).where(PublicStream.uid == actual_uid)).all()
|
||||
for stream in streams:
|
||||
db.delete(stream)
|
||||
print(f"[DELETE_ACCOUNT] Deleted {len(streams)} public streams for user {actual_uid}")
|
||||
|
||||
# Delete user's quota
|
||||
quota = db.get(UserQuota, uid)
|
||||
quota = db.get(UserQuota, actual_uid)
|
||||
if quota:
|
||||
db.delete(quota)
|
||||
print(f"[DELETE_ACCOUNT] Deleted quota for user {uid}")
|
||||
print(f"[DELETE_ACCOUNT] Deleted quota for user {actual_uid}")
|
||||
|
||||
# Delete user's active sessions
|
||||
sessions = db.exec(select(DBSession).where(DBSession.user_id == uid)).all()
|
||||
for session in sessions:
|
||||
# Delete user's active sessions (check both email and username as user_id)
|
||||
sessions_by_email = db.exec(select(DBSession).where(DBSession.user_id == actual_uid)).all()
|
||||
sessions_by_username = db.exec(select(DBSession).where(DBSession.user_id == user.username)).all()
|
||||
|
||||
all_sessions = list(sessions_by_email) + list(sessions_by_username)
|
||||
# Remove duplicates using token (primary key) instead of id
|
||||
unique_sessions = {session.token: session for session in all_sessions}.values()
|
||||
|
||||
for session in unique_sessions:
|
||||
db.delete(session)
|
||||
print(f"[DELETE_ACCOUNT] Deleted {len(sessions)} active sessions for user {uid}")
|
||||
print(f"[DELETE_ACCOUNT] Deleted {len(unique_sessions)} active sessions for user {actual_uid} (checked both email and username)")
|
||||
|
||||
# Delete user account
|
||||
user_obj = db.get(User, user.email)
|
||||
user_obj = db.get(User, actual_uid) # Use actual_uid which is the email
|
||||
if user_obj:
|
||||
db.delete(user_obj)
|
||||
print(f"[DELETE_ACCOUNT] Deleted user account {uid} ({user.email})")
|
||||
print(f"[DELETE_ACCOUNT] Deleted user account {actual_uid}")
|
||||
|
||||
db.commit()
|
||||
print(f"[DELETE_ACCOUNT] Database changes committed for user {uid}")
|
||||
print(f"[DELETE_ACCOUNT] Database changes committed for user {actual_uid}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@ -87,7 +115,7 @@ async def delete_account(data: Dict[str, Any], request: Request, db: Session = D
|
||||
print(f"[DELETE_ACCOUNT] Error deleting user files: {str(e)}")
|
||||
# Continue even if file deletion fails, as the account is already deleted from the DB
|
||||
|
||||
print(f"[DELETE_ACCOUNT] Successfully deleted account for user {uid}")
|
||||
print(f"[DELETE_ACCOUNT] Successfully deleted account for user {actual_uid} (original UID: {uid})")
|
||||
return {"status": "success", "message": "Account and all associated data have been deleted"}
|
||||
|
||||
except HTTPException as he:
|
||||
|
@ -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")
|
||||
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
# Try to find user by email or username
|
||||
existing_user = db.get(User, email)
|
||||
if not existing_user:
|
||||
# Try by username (since username is not primary key, need to query)
|
||||
stmt = select(User).where(User.username == user)
|
||||
existing_user = db.exec(stmt).first()
|
||||
from datetime import datetime
|
||||
|
||||
# Check if user exists by email
|
||||
existing_user_by_email = db.get(User, email)
|
||||
|
||||
# Check if user exists by username
|
||||
stmt = select(User).where(User.username == user)
|
||||
existing_user_by_username = db.exec(stmt).first()
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
if existing_user:
|
||||
# Update token, timestamp, and ip, set confirmed False
|
||||
from datetime import datetime
|
||||
existing_user.token = token
|
||||
existing_user.token_created = datetime.utcnow()
|
||||
existing_user.confirmed = False
|
||||
existing_user.ip = request.client.host
|
||||
db.add(existing_user)
|
||||
|
||||
# Case 1: Email and username match in db - it's a login
|
||||
if existing_user_by_email and existing_user_by_username and existing_user_by_email.email == existing_user_by_username.email:
|
||||
# Update token for existing user (login)
|
||||
existing_user_by_email.token = token
|
||||
existing_user_by_email.token_created = datetime.utcnow()
|
||||
existing_user_by_email.confirmed = False
|
||||
existing_user_by_email.ip = request.client.host
|
||||
db.add(existing_user_by_email)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
||||
else:
|
||||
|
||||
action = "login"
|
||||
|
||||
# Case 2: Email matches but username does not - only one account per email
|
||||
elif existing_user_by_email and (not existing_user_by_username or existing_user_by_email.email != existing_user_by_username.email):
|
||||
raise HTTPException(status_code=409, detail="📧 This email is already registered with a different username.\nOnly one account per email is allowed.")
|
||||
|
||||
# Case 3: Email does not match but username is in db - username already taken
|
||||
elif not existing_user_by_email and existing_user_by_username:
|
||||
raise HTTPException(status_code=409, detail="👤 This username is already taken.\nPlease choose a different username.")
|
||||
|
||||
# Case 4: Neither email nor username exist - create new user
|
||||
elif not existing_user_by_email and not existing_user_by_username:
|
||||
# Register new user
|
||||
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
|
||||
db.add(UserQuota(uid=user))
|
||||
new_user = User(email=email, username=user, token=token, confirmed=False, ip=request.client.host)
|
||||
new_quota = UserQuota(uid=email) # Use email as UID for quota tracking
|
||||
|
||||
db.add(new_user)
|
||||
db.add(new_quota)
|
||||
|
||||
try:
|
||||
# First commit the user to the database
|
||||
@ -86,30 +105,46 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db
|
||||
db.rollback()
|
||||
if isinstance(e, IntegrityError):
|
||||
# Race condition: user created after our check
|
||||
# Try again as login
|
||||
stmt = select(User).where((User.email == email) | (User.username == user))
|
||||
existing_user = db.exec(stmt).first()
|
||||
if existing_user:
|
||||
existing_user.token = token
|
||||
existing_user.confirmed = False
|
||||
existing_user.ip = request.client.host
|
||||
db.add(existing_user)
|
||||
db.commit()
|
||||
# Check which constraint was violated to provide specific feedback
|
||||
error_str = str(e).lower()
|
||||
|
||||
if 'username' in error_str or 'user_username_key' in error_str:
|
||||
raise HTTPException(status_code=409, detail="👤 This username is already taken.\nPlease choose a different username.")
|
||||
elif 'email' in error_str or 'user_pkey' in error_str:
|
||||
raise HTTPException(status_code=409, detail="📧 This email is already registered with a different username.\nOnly one account per email is allowed.")
|
||||
else:
|
||||
raise HTTPException(status_code=409, detail="Username or email already exists.")
|
||||
# Generic fallback if we can't determine the specific constraint
|
||||
raise HTTPException(status_code=409, detail="⚠️ Registration failed due to a conflict.\nPlease try again with different credentials.")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
||||
# Send magic link
|
||||
|
||||
action = "registration"
|
||||
|
||||
else:
|
||||
# This should not happen, but handle it gracefully
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during registration.")
|
||||
# Send magic link with appropriate message based on action
|
||||
msg = EmailMessage()
|
||||
msg["From"] = MAGIC_FROM
|
||||
msg["To"] = email
|
||||
msg["Subject"] = "Your magic login link"
|
||||
msg.set_content(
|
||||
f"Hello {user},\n\nClick to confirm your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time login."
|
||||
)
|
||||
|
||||
if action == "login":
|
||||
msg["Subject"] = "Your magic login link"
|
||||
msg.set_content(
|
||||
f"Hello {user},\n\nClick to log in to your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time login."
|
||||
)
|
||||
response_message = "📧 Check your email for a magic login link!"
|
||||
else: # registration
|
||||
msg["Subject"] = "Welcome to dicta2stream - Confirm your account"
|
||||
msg.set_content(
|
||||
f"Hello {user},\n\nWelcome to dicta2stream! Click to confirm your new account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time confirmation."
|
||||
)
|
||||
response_message = "🎉 Account created! Check your email for a magic login link!"
|
||||
|
||||
try:
|
||||
with smtplib.SMTP("localhost") as smtp:
|
||||
smtp.send_message(msg)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Email failed: {e}")
|
||||
return { "message": "Confirmation sent" }
|
||||
|
||||
return {"message": response_message, "action": action}
|
||||
|
@ -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
|
||||
import { showOnly } from './router.js';
|
||||
import { showSection } from './nav.js';
|
||||
|
||||
// Data-target navigation (e.g., at #links)
|
||||
export function initNavLinks() {
|
||||
@ -10,7 +10,7 @@ export function initNavLinks() {
|
||||
if (!a || !linksContainer.contains(a)) return;
|
||||
e.preventDefault();
|
||||
const target = a.dataset.target;
|
||||
if (target) showOnly(target);
|
||||
if (target) showSection(target);
|
||||
const burger = document.getElementById('burger-toggle');
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
});
|
||||
@ -22,7 +22,7 @@ export function initBackButtons() {
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const target = btn.dataset.back;
|
||||
if (target) showOnly(target);
|
||||
if (target) showSection(target);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
252
static/auth.js
Normal file
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 { showSection } from './nav.js';
|
||||
|
||||
// Utility function to get cookie value by name
|
||||
function getCookie(name) {
|
||||
@ -14,6 +15,7 @@ let isLoggingOut = false;
|
||||
|
||||
async function handleLogout(event) {
|
||||
console.log('[LOGOUT] Logout initiated');
|
||||
|
||||
// Prevent multiple simultaneous logout attempts
|
||||
if (isLoggingOut) {
|
||||
console.log('[LOGOUT] Logout already in progress');
|
||||
@ -27,18 +29,66 @@ async function handleLogout(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// Get auth token before we clear it
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
|
||||
// 1. First try to invalidate the server session (but don't block on it)
|
||||
if (authToken) {
|
||||
try {
|
||||
// We'll use a timeout to prevent hanging on the server request
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
try {
|
||||
// Get auth token before we clear it
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
|
||||
// 1. Clear all client-side state first (most important)
|
||||
console.log('[LOGOUT] Clearing all client-side state');
|
||||
|
||||
// Clear localStorage and sessionStorage
|
||||
const storageKeys = [
|
||||
'uid', 'uid_time', 'confirmed_uid', 'last_page',
|
||||
'isAuthenticated', 'authToken', 'user', 'token', 'sessionid', 'sessionId'
|
||||
];
|
||||
|
||||
storageKeys.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// Get all current cookies for debugging
|
||||
const allCookies = document.cookie.split(';');
|
||||
console.log('[LOGOUT] Current cookies before clearing:', allCookies);
|
||||
|
||||
// Clear ALL cookies (aggressive approach)
|
||||
allCookies.forEach(cookie => {
|
||||
const [name] = cookie.trim().split('=');
|
||||
if (name) {
|
||||
const cookieName = name.trim();
|
||||
console.log(`[LOGOUT] Clearing cookie: ${cookieName}`);
|
||||
|
||||
// Try multiple clearing strategies to ensure cookies are removed
|
||||
const clearStrategies = [
|
||||
`${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`,
|
||||
`${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`,
|
||||
`${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${window.location.hostname};`,
|
||||
`${cookieName}=; max-age=0; path=/;`,
|
||||
`${cookieName}=; max-age=0; path=/; domain=${window.location.hostname};`
|
||||
];
|
||||
|
||||
clearStrategies.forEach(strategy => {
|
||||
document.cookie = strategy;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Verify cookies are cleared
|
||||
const remainingCookies = document.cookie.split(';').filter(c => c.trim());
|
||||
console.log('[LOGOUT] Remaining cookies after clearing:', remainingCookies);
|
||||
|
||||
// Update UI state
|
||||
document.body.classList.remove('authenticated', 'logged-in');
|
||||
document.body.classList.add('guest');
|
||||
|
||||
// 2. Try to invalidate server session (non-blocking)
|
||||
if (authToken) {
|
||||
try {
|
||||
await fetch('/api/logout', {
|
||||
console.log('[LOGOUT] Attempting to invalidate server session');
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
const response = await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
@ -47,141 +97,27 @@ async function handleLogout(event) {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
// Silently handle any errors during server logout
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle any unexpected errors
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clear all client-side state
|
||||
function clearClientState() {
|
||||
console.log('[LOGOUT] Clearing client state');
|
||||
|
||||
// Clear all authentication-related data from localStorage
|
||||
const keysToRemove = [
|
||||
'uid', 'uid_time', 'confirmed_uid', 'last_page',
|
||||
'isAuthenticated', 'authToken', 'user', 'token', 'sessionid'
|
||||
];
|
||||
|
||||
keysToRemove.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// Get current cookies for debugging
|
||||
const cookies = document.cookie.split(';');
|
||||
console.log('[LOGOUT] Current cookies before clearing:', cookies);
|
||||
|
||||
// Function to clear a cookie by name
|
||||
const clearCookie = (name) => {
|
||||
console.log(`[LOGOUT] Attempting to clear cookie: ${name}`);
|
||||
const baseOptions = 'Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=Lax';
|
||||
// Try with current domain
|
||||
document.cookie = `${name}=; ${baseOptions}`;
|
||||
// Try with domain
|
||||
document.cookie = `${name}=; ${baseOptions}; domain=${window.location.hostname}`;
|
||||
// Try with leading dot for subdomains
|
||||
document.cookie = `${name}=; ${baseOptions}; domain=.${window.location.hostname}`;
|
||||
};
|
||||
|
||||
// Clear all authentication-related cookies
|
||||
const authCookies = [
|
||||
'uid', 'authToken', 'isAuthenticated', 'sessionid', 'session_id',
|
||||
'token', 'remember_token', 'auth', 'authentication'
|
||||
];
|
||||
|
||||
// Clear specific auth cookies
|
||||
authCookies.forEach(clearCookie);
|
||||
|
||||
// Also clear any existing cookies that match our patterns
|
||||
cookies.forEach(cookie => {
|
||||
const [name] = cookie.trim().split('=');
|
||||
if (name && authCookies.some(authName => name.trim() === authName)) {
|
||||
clearCookie(name.trim());
|
||||
}
|
||||
});
|
||||
|
||||
// Clear all cookies by setting them to expire in the past
|
||||
document.cookie.split(';').forEach(cookie => {
|
||||
const [name] = cookie.trim().split('=');
|
||||
if (name) {
|
||||
clearCookie(name.trim());
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[LOGOUT] Cookies after clearing:', document.cookie);
|
||||
|
||||
// Update UI state
|
||||
document.body.classList.remove('authenticated');
|
||||
document.body.classList.add('guest');
|
||||
|
||||
// Force a hard reload to ensure all state is reset
|
||||
setTimeout(() => {
|
||||
// Clear all storage again before redirecting
|
||||
keysToRemove.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// Redirect to home with a cache-busting parameter
|
||||
window.location.href = '/?logout=' + Date.now();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear client state immediately to prevent any race conditions
|
||||
clearClientState();
|
||||
|
||||
// 2. Try to invalidate the server session (but don't block on it)
|
||||
console.log('[LOGOUT] Auth token exists:', !!authToken);
|
||||
if (authToken) {
|
||||
try {
|
||||
console.log('[LOGOUT] Attempting to invalidate server session');
|
||||
|
||||
const response = await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 401) {
|
||||
console.warn(`[LOGOUT] Server returned ${response.status} during logout`);
|
||||
// Don't throw - we've already cleared client state
|
||||
} else {
|
||||
console.log('[LOGOUT] Server session invalidated successfully');
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
console.log('[LOGOUT] Server session invalidation completed');
|
||||
} catch (error) {
|
||||
console.warn('[LOGOUT] Error during server session invalidation (non-critical):', error);
|
||||
// Continue with logout process
|
||||
console.warn('[LOGOUT] Server session invalidation failed (non-critical):', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update navigation if the function exists
|
||||
if (typeof injectNavigation === 'function') {
|
||||
injectNavigation(false);
|
||||
}
|
||||
|
||||
console.log('[LOGOUT] Logout completed');
|
||||
|
||||
// 3. Final redirect
|
||||
console.log('[LOGOUT] Redirecting to home page');
|
||||
window.location.href = '/?logout=' + Date.now();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[LOGOUT] Logout failed:', error);
|
||||
console.error('[LOGOUT] Unexpected error during logout:', error);
|
||||
if (window.showToast) {
|
||||
showToast('Logout failed. Please try again.');
|
||||
}
|
||||
// Even if there's an error, force redirect to clear state
|
||||
window.location.href = '/?logout=error-' + Date.now();
|
||||
} finally {
|
||||
isLoggingOut = false;
|
||||
|
||||
// 4. Redirect to home page after a short delay to ensure state is cleared
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,11 +157,60 @@ async function handleDeleteAccount() {
|
||||
if (response.ok) {
|
||||
showToast('Account deleted successfully');
|
||||
|
||||
// Clear user data
|
||||
localStorage.removeItem('uid');
|
||||
localStorage.removeItem('uid_time');
|
||||
localStorage.removeItem('confirmed_uid');
|
||||
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
// Use comprehensive logout logic to clear all cookies and storage
|
||||
console.log('🧹 Account deleted - clearing all authentication data...');
|
||||
|
||||
// Clear all authentication-related data from localStorage
|
||||
const keysToRemove = [
|
||||
'uid', 'uid_time', 'confirmed_uid', 'last_page',
|
||||
'isAuthenticated', 'authToken', 'user', 'token', 'sessionid'
|
||||
];
|
||||
|
||||
keysToRemove.forEach(key => {
|
||||
if (localStorage.getItem(key)) {
|
||||
console.log(`Removing localStorage key: ${key}`);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear sessionStorage completely
|
||||
sessionStorage.clear();
|
||||
console.log('Cleared sessionStorage');
|
||||
|
||||
// Clear all cookies using multiple strategies
|
||||
const clearCookie = (cookieName) => {
|
||||
const clearStrategies = [
|
||||
`${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`,
|
||||
`${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`,
|
||||
`${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${window.location.hostname};`,
|
||||
`${cookieName}=; max-age=0; path=/;`,
|
||||
`${cookieName}=; max-age=0; path=/; domain=${window.location.hostname};`
|
||||
];
|
||||
|
||||
clearStrategies.forEach(strategy => {
|
||||
document.cookie = strategy;
|
||||
});
|
||||
console.log(`Cleared cookie: ${cookieName}`);
|
||||
};
|
||||
|
||||
// Clear all cookies by setting them to expire in the past
|
||||
document.cookie.split(';').forEach(cookie => {
|
||||
const [name] = cookie.trim().split('=');
|
||||
if (name) {
|
||||
clearCookie(name.trim());
|
||||
}
|
||||
});
|
||||
|
||||
// Also specifically clear known authentication cookies
|
||||
const authCookies = ['authToken', 'isAuthenticated', 'sessionId', 'uid', 'token'];
|
||||
authCookies.forEach(clearCookie);
|
||||
|
||||
// Log remaining cookies for verification
|
||||
console.log('Remaining cookies after deletion cleanup:', document.cookie);
|
||||
|
||||
// Update UI state
|
||||
document.body.classList.remove('authenticated');
|
||||
document.body.classList.add('guest');
|
||||
|
||||
// Redirect to home page
|
||||
setTimeout(() => {
|
||||
@ -274,333 +259,54 @@ function debugElementVisibility(elementId) {
|
||||
*/
|
||||
async function initDashboard() {
|
||||
console.log('[DASHBOARD] Initializing dashboard...');
|
||||
|
||||
// Get all dashboard elements
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
const userUpload = document.getElementById('user-upload-area');
|
||||
const logoutButton = document.getElementById('logout-button');
|
||||
const deleteAccountButton = document.getElementById('delete-account-button');
|
||||
const fileList = document.getElementById('file-list');
|
||||
|
||||
// Add click event listeners for logout and delete account buttons
|
||||
if (logoutButton) {
|
||||
console.log('[DASHBOARD] Adding logout button handler');
|
||||
logoutButton.addEventListener('click', handleLogout);
|
||||
}
|
||||
|
||||
if (deleteAccountButton) {
|
||||
console.log('[DASHBOARD] Adding delete account button handler');
|
||||
deleteAccountButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteAccount();
|
||||
});
|
||||
}
|
||||
|
||||
// Check authentication state - consolidated to avoid duplicate declarations
|
||||
const hasAuthCookie = document.cookie.includes('isAuthenticated=true');
|
||||
const hasUidCookie = document.cookie.includes('uid=');
|
||||
const hasLocalStorageAuth = localStorage.getItem('isAuthenticated') === 'true';
|
||||
const hasAuthToken = localStorage.getItem('authToken') !== null;
|
||||
const isAuthenticated = hasAuthCookie || hasUidCookie || hasLocalStorageAuth || hasAuthToken;
|
||||
|
||||
// Ensure body class reflects authentication state
|
||||
if (isAuthenticated) {
|
||||
document.body.classList.add('authenticated');
|
||||
document.body.classList.remove('guest-mode');
|
||||
} else {
|
||||
document.body.classList.remove('authenticated');
|
||||
document.body.classList.add('guest-mode');
|
||||
}
|
||||
|
||||
// Debug authentication state
|
||||
console.log('[AUTH] Authentication state:', {
|
||||
hasAuthCookie,
|
||||
hasUidCookie,
|
||||
hasLocalStorageAuth,
|
||||
hasAuthToken,
|
||||
isAuthenticated,
|
||||
cookies: document.cookie,
|
||||
localStorage: {
|
||||
isAuthenticated: localStorage.getItem('isAuthenticated'),
|
||||
uid: localStorage.getItem('uid'),
|
||||
authToken: localStorage.getItem('authToken') ? 'present' : 'not present'
|
||||
},
|
||||
bodyClasses: document.body.className
|
||||
});
|
||||
|
||||
// Handle authenticated user
|
||||
if (isAuthenticated) {
|
||||
console.log('[DASHBOARD] User is authenticated, showing user dashboard');
|
||||
if (userDashboard) userDashboard.style.display = 'block';
|
||||
if (userUpload) userUpload.style.display = 'block';
|
||||
if (guestDashboard) guestDashboard.style.display = 'none';
|
||||
|
||||
// Add authenticated class to body if not present
|
||||
document.body.classList.add('authenticated');
|
||||
|
||||
// Get UID from cookies or localStorage
|
||||
let uid = getCookie('uid') || localStorage.getItem('uid');
|
||||
|
||||
if (!uid) {
|
||||
console.warn('[DASHBOARD] No UID found in cookies or localStorage');
|
||||
// Try to get UID from the URL or hash fragment
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
uid = urlParams.get('uid') || window.location.hash.substring(1);
|
||||
|
||||
if (uid) {
|
||||
console.log(`[DASHBOARD] Using UID from URL/hash: ${uid}`);
|
||||
localStorage.setItem('uid', uid);
|
||||
} else {
|
||||
console.error('[DASHBOARD] No UID available for file listing');
|
||||
if (fileList) {
|
||||
fileList.innerHTML = `
|
||||
<li class="error-message">
|
||||
Error: Could not determine user account. Please <a href="/#login" class="login-link">log in</a> again.
|
||||
</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize file listing if we have a UID
|
||||
if (window.fetchAndDisplayFiles) {
|
||||
console.log(`[DASHBOARD] Initializing file listing for UID: ${uid}`);
|
||||
try {
|
||||
await window.fetchAndDisplayFiles(uid);
|
||||
} catch (error) {
|
||||
console.error('[DASHBOARD] Error initializing file listing:', error);
|
||||
if (fileList) {
|
||||
fileList.innerHTML = `
|
||||
<li class="error-message">
|
||||
Error loading files: ${error.message || 'Unknown error'}.
|
||||
Please <a href="/#login" class="login-link">log in</a> again.
|
||||
</li>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Guest view
|
||||
console.log('[DASHBOARD] User not authenticated, showing guest dashboard');
|
||||
if (guestDashboard) guestDashboard.style.display = 'block';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
|
||||
// Remove authenticated class if present
|
||||
document.body.classList.remove('authenticated');
|
||||
|
||||
// Show login prompt
|
||||
if (fileList) {
|
||||
fileList.innerHTML = `
|
||||
<li class="error-message">
|
||||
Please <a href="/#login" class="login-link">log in</a> to view your files.
|
||||
</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Log authentication details for debugging
|
||||
console.log('[DASHBOARD] Authentication details:', {
|
||||
uid: getCookie('uid') || localStorage.getItem('uid'),
|
||||
cookies: document.cookie,
|
||||
localStorage: {
|
||||
uid: localStorage.getItem('uid'),
|
||||
isAuthenticated: localStorage.getItem('isAuthenticated'),
|
||||
authToken: localStorage.getItem('authToken') ? 'present' : 'not present'
|
||||
}
|
||||
});
|
||||
|
||||
// If not authenticated, show guest view and return early
|
||||
if (!isAuthenticated) {
|
||||
console.log('[DASHBOARD] User not authenticated, showing guest dashboard');
|
||||
if (guestDashboard) guestDashboard.style.display = 'block';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
|
||||
// Remove authenticated class if present
|
||||
document.body.classList.remove('authenticated');
|
||||
|
||||
// Show login prompt
|
||||
if (fileList) {
|
||||
fileList.innerHTML = `
|
||||
<li class="error-message">
|
||||
Please <a href="/#login" class="login-link">log in</a> to view your files.
|
||||
</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Logged-in view - show user dashboard
|
||||
console.log('[DASHBOARD] User is logged in, showing user dashboard');
|
||||
|
||||
// Get all page elements
|
||||
const mePage = document.getElementById('me-page');
|
||||
|
||||
// Log current display states for debugging
|
||||
console.log('[DASHBOARD] Updated display states:', {
|
||||
guestDashboard: guestDashboard ? window.getComputedStyle(guestDashboard).display : 'not found',
|
||||
userDashboard: userDashboard ? window.getComputedStyle(userDashboard).display : 'not found',
|
||||
userUpload: userUpload ? window.getComputedStyle(userUpload).display : 'not found',
|
||||
logoutButton: logoutButton ? window.getComputedStyle(logoutButton).display : 'not found',
|
||||
deleteAccountButton: deleteAccountButton ? window.getComputedStyle(deleteAccountButton).display : 'not found',
|
||||
mePage: mePage ? window.getComputedStyle(mePage).display : 'not found'
|
||||
});
|
||||
|
||||
// Hide guest dashboard
|
||||
if (guestDashboard) {
|
||||
console.log('[DASHBOARD] Hiding guest dashboard');
|
||||
guestDashboard.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show user dashboard
|
||||
if (userDashboard) {
|
||||
console.log('[DASHBOARD] Showing user dashboard');
|
||||
userDashboard.style.display = 'block';
|
||||
userDashboard.style.visibility = 'visible';
|
||||
userDashboard.hidden = false;
|
||||
|
||||
// Log final visibility state after changes
|
||||
console.log('[DEBUG] Final visibility state after showing user dashboard:', {
|
||||
userDashboard: debugElementVisibility('user-dashboard'),
|
||||
guestDashboard: debugElementVisibility('guest-dashboard'),
|
||||
computedDisplay: window.getComputedStyle(userDashboard).display,
|
||||
computedVisibility: window.getComputedStyle(userDashboard).visibility
|
||||
});
|
||||
|
||||
// Debug: Check if the element is actually in the DOM
|
||||
console.log('[DASHBOARD] User dashboard parent:', userDashboard.parentElement);
|
||||
console.log('[DASHBOARD] User dashboard computed display:', window.getComputedStyle(userDashboard).display);
|
||||
} else {
|
||||
console.error('[DASHBOARD] userDashboard element not found!');
|
||||
}
|
||||
|
||||
// Show essential elements for logged-in users
|
||||
const linksSection = document.getElementById('links');
|
||||
if (linksSection) {
|
||||
console.log('[DASHBOARD] Showing links section');
|
||||
linksSection.style.display = 'block';
|
||||
}
|
||||
|
||||
const showMeLink = document.getElementById('show-me');
|
||||
if (showMeLink && showMeLink.parentElement) {
|
||||
console.log('[DASHBOARD] Showing show-me link');
|
||||
showMeLink.parentElement.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show me-page for logged-in users
|
||||
if (mePage) {
|
||||
console.log('[DASHBOARD] Showing me-page');
|
||||
mePage.style.display = 'block';
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get UID from various sources
|
||||
let uid = getCookie('uid') || localStorage.getItem('uid');
|
||||
|
||||
// If we have a valid UID, try to fetch user data
|
||||
if (uid && uid !== 'welcome-page' && uid !== 'undefined' && uid !== 'null') {
|
||||
console.log('[DASHBOARD] Found valid UID:', uid);
|
||||
console.log(`[DEBUG] Fetching user data for UID: ${uid}`);
|
||||
const response = await fetch(`/me/${uid}`);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`[ERROR] Failed to fetch user data: ${response.status} ${response.statusText}`, errorText);
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Parse and handle the response data
|
||||
const data = await response.json();
|
||||
console.log('[DEBUG] User data loaded:', data);
|
||||
|
||||
// Ensure upload area is visible if last_page was me-page
|
||||
if (userUpload && localStorage.getItem('last_page') === 'me-page') {
|
||||
// userUpload visibility is now only controlled by nav.js SPA logic
|
||||
}
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
const userUpload = document.getElementById('user-upload-area');
|
||||
const logoutButton = document.getElementById('logout-button');
|
||||
const deleteAccountButton = document.getElementById('delete-account-button');
|
||||
const fileList = document.getElementById('file-list');
|
||||
|
||||
// Remove guest warning if present
|
||||
const guestMsg = document.getElementById('guest-warning-msg');
|
||||
if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg);
|
||||
|
||||
// Show user dashboard and logout button
|
||||
if (userDashboard) userDashboard.style.display = '';
|
||||
if (logoutButton) {
|
||||
logoutButton.style.display = 'block';
|
||||
logoutButton.onclick = handleLogout;
|
||||
}
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', handleLogout);
|
||||
}
|
||||
if (deleteAccountButton) {
|
||||
deleteAccountButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteAccount();
|
||||
});
|
||||
}
|
||||
|
||||
// Set audio source
|
||||
const meAudio = document.getElementById('me-audio');
|
||||
const username = data?.username || '';
|
||||
|
||||
if (meAudio) {
|
||||
if (username) {
|
||||
// Use username for the audio file path if available
|
||||
meAudio.src = `/audio/${encodeURIComponent(username)}/stream.opus?t=${Date.now()}`;
|
||||
console.log('Setting audio source to:', meAudio.src);
|
||||
} else if (uid) {
|
||||
// Fallback to UID if username is not available
|
||||
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
|
||||
console.warn('Using UID fallback for audio source:', meAudio.src);
|
||||
}
|
||||
}
|
||||
const isAuthenticated = (document.cookie.includes('isAuthenticated=true') || localStorage.getItem('isAuthenticated') === 'true');
|
||||
|
||||
// Update quota and ensure quota meter is visible if data is available
|
||||
const quotaMeter = document.getElementById('quota-meter');
|
||||
const quotaBar = document.getElementById('quota-bar');
|
||||
const quotaText = document.getElementById('quota-text');
|
||||
|
||||
if (quotaBar && data.quota !== undefined) {
|
||||
quotaBar.value = data.quota;
|
||||
}
|
||||
|
||||
if (quotaText && data.quota !== undefined) {
|
||||
quotaText.textContent = `${data.quota} MB`;
|
||||
}
|
||||
|
||||
if (quotaMeter) {
|
||||
quotaMeter.hidden = false;
|
||||
quotaMeter.style.display = 'block'; // Ensure it's not hidden by display:none
|
||||
}
|
||||
|
||||
// Fetch and display the list of uploaded files if the function is available
|
||||
if (window.fetchAndDisplayFiles) {
|
||||
console.log('[DASHBOARD] Calling fetchAndDisplayFiles with UID:', uid);
|
||||
// Ensure we have the most up-to-date UID from the response data if available
|
||||
const effectiveUid = data?.uid || uid;
|
||||
console.log('[DASHBOARD] Using effective UID:', effectiveUid);
|
||||
window.fetchAndDisplayFiles(effectiveUid);
|
||||
} else {
|
||||
console.error('[DASHBOARD] fetchAndDisplayFiles function not found!');
|
||||
if (isAuthenticated) {
|
||||
document.body.classList.add('authenticated');
|
||||
document.body.classList.remove('guest-mode');
|
||||
if (userDashboard) userDashboard.style.display = 'block';
|
||||
if (userUpload) userUpload.style.display = 'block';
|
||||
if (guestDashboard) guestDashboard.style.display = 'none';
|
||||
|
||||
const uid = getCookie('uid') || localStorage.getItem('uid');
|
||||
if (uid && window.fetchAndDisplayFiles) {
|
||||
await window.fetchAndDisplayFiles(uid);
|
||||
}
|
||||
} else {
|
||||
// No valid UID found, ensure we're in guest mode
|
||||
console.log('[DASHBOARD] No valid UID found, showing guest dashboard');
|
||||
userDashboard.style.display = 'none';
|
||||
guestDashboard.style.display = 'block';
|
||||
userUpload.style.display = 'none';
|
||||
document.body.classList.remove('authenticated');
|
||||
return; // Exit early for guest users
|
||||
document.body.classList.add('guest-mode');
|
||||
if (guestDashboard) guestDashboard.style.display = 'block';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
if (fileList) {
|
||||
fileList.innerHTML = `<li class="error-message">Please <a href="/#login" class="login-link">log in</a> to view your files.</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Streams link remains in nav, not moved
|
||||
// (No action needed if static)
|
||||
} catch (e) {
|
||||
console.warn('Dashboard init error, falling back to guest mode:', e);
|
||||
|
||||
// Ensure guest UI is shown
|
||||
userUpload.style.display = 'none';
|
||||
userDashboard.style.display = 'none';
|
||||
console.error('Dashboard initialization failed:', e);
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (guestDashboard) guestDashboard.style.display = 'block';
|
||||
|
||||
// Update body classes
|
||||
document.body.classList.remove('authenticated');
|
||||
document.body.classList.add('guest-mode');
|
||||
|
||||
// Ensure navigation is in correct state
|
||||
const registerLink = document.getElementById('guest-login');
|
||||
const streamsLink = document.getElementById('guest-streams');
|
||||
if (registerLink && streamsLink) {
|
||||
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -977,30 +683,36 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize dashboard components
|
||||
initDashboard(); // initFileUpload is called from within initDashboard
|
||||
|
||||
// Add event delegation for delete buttons
|
||||
// Delegated event listener for clicks on the document
|
||||
document.addEventListener('click', (e) => {
|
||||
const deleteButton = e.target.closest('.delete-file');
|
||||
if (!deleteButton) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const listItem = deleteButton.closest('.file-item');
|
||||
if (!listItem) return;
|
||||
|
||||
// Get UID from localStorage
|
||||
const uid = localStorage.getItem('uid') || localStorage.getItem('confirmed_uid');
|
||||
if (!uid) {
|
||||
showToast('You need to be logged in to delete files', 'error');
|
||||
console.error('[DELETE] No UID found in localStorage');
|
||||
// Logout Button
|
||||
if (e.target.closest('#logout-button')) {
|
||||
e.preventDefault();
|
||||
handleLogout(e);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = deleteButton.getAttribute('data-filename');
|
||||
const displayName = deleteButton.getAttribute('data-original-name') || fileName;
|
||||
|
||||
// Pass the UID to deleteFile
|
||||
deleteFile(uid, fileName, listItem, displayName);
|
||||
|
||||
// Delete File Button
|
||||
const deleteButton = e.target.closest('.delete-file');
|
||||
if (deleteButton) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const listItem = deleteButton.closest('.file-item');
|
||||
if (!listItem) return;
|
||||
|
||||
const uid = localStorage.getItem('uid') || localStorage.getItem('confirmed_uid');
|
||||
if (!uid) {
|
||||
showToast('You need to be logged in to delete files', 'error');
|
||||
console.error('[DELETE] No UID found in localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = deleteButton.getAttribute('data-filename');
|
||||
const displayName = deleteButton.getAttribute('data-original-name') || fileName;
|
||||
|
||||
deleteFile(uid, fileName, listItem, displayName);
|
||||
}
|
||||
});
|
||||
|
||||
// Make fetchAndDisplayFiles available globally
|
||||
@ -1062,184 +774,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
// Connect Login or Register link to register form
|
||||
// Login/Register (guest)
|
||||
const loginLink = document.getElementById('guest-login');
|
||||
if (loginLink) {
|
||||
loginLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'register-page';
|
||||
});
|
||||
const reg = document.getElementById('register-page');
|
||||
if (reg) reg.hidden = false;
|
||||
reg.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
// Terms of Service (all dashboards)
|
||||
const termsLinks = [
|
||||
document.getElementById('guest-terms'),
|
||||
document.getElementById('user-terms')
|
||||
];
|
||||
termsLinks.forEach(link => {
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'terms-page';
|
||||
});
|
||||
const terms = document.getElementById('terms-page');
|
||||
if (terms) terms.hidden = false;
|
||||
terms.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
// All navigation is now handled by the global click and hashchange listeners in nav.js.
|
||||
// The legacy setupPageNavigation function and manual nav link handlers have been removed.
|
||||
});
|
||||
|
||||
// Imprint (all dashboards)
|
||||
const imprintLinks = [
|
||||
document.getElementById('guest-imprint'),
|
||||
document.getElementById('user-imprint')
|
||||
];
|
||||
imprintLinks.forEach(link => {
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'imprint-page';
|
||||
});
|
||||
const imprint = document.getElementById('imprint-page');
|
||||
if (imprint) imprint.hidden = false;
|
||||
imprint.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Privacy Policy (all dashboards)
|
||||
const privacyLinks = [
|
||||
document.getElementById('guest-privacy'),
|
||||
document.getElementById('user-privacy')
|
||||
];
|
||||
privacyLinks.forEach(link => {
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'privacy-page';
|
||||
});
|
||||
const privacy = document.getElementById('privacy-page');
|
||||
if (privacy) privacy.hidden = false;
|
||||
privacy.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Back to top button functionality
|
||||
const backToTop = document.getElementById('back-to-top');
|
||||
if (backToTop) {
|
||||
backToTop.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile menu functionality
|
||||
const menuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mainNav = document.getElementById('main-navigation');
|
||||
|
||||
if (menuToggle && mainNav) {
|
||||
// Toggle mobile menu
|
||||
menuToggle.addEventListener('click', () => {
|
||||
const isExpanded = menuToggle.getAttribute('aria-expanded') === 'true' || false;
|
||||
menuToggle.setAttribute('aria-expanded', !isExpanded);
|
||||
mainNav.setAttribute('aria-hidden', isExpanded);
|
||||
|
||||
// Toggle mobile menu visibility
|
||||
if (isExpanded) {
|
||||
mainNav.classList.remove('mobile-visible');
|
||||
document.body.style.overflow = '';
|
||||
} else {
|
||||
mainNav.classList.add('mobile-visible');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const isClickInsideNav = mainNav.contains(e.target);
|
||||
const isClickOnToggle = menuToggle === e.target || menuToggle.contains(e.target);
|
||||
|
||||
if (mainNav.classList.contains('mobile-visible') && !isClickInsideNav && !isClickOnToggle) {
|
||||
mainNav.classList.remove('mobile-visible');
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle navigation link clicks
|
||||
const navLinks = document.querySelectorAll('nav a[href^="#"]');
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const targetId = link.getAttribute('href');
|
||||
if (targetId === '#') return;
|
||||
|
||||
const targetElement = document.querySelector(targetId);
|
||||
if (targetElement) {
|
||||
e.preventDefault();
|
||||
|
||||
// Close mobile menu if open
|
||||
if (mainNav && mainNav.classList.contains('mobile-visible')) {
|
||||
mainNav.classList.remove('mobile-visible');
|
||||
if (menuToggle) {
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// Smooth scroll to target
|
||||
targetElement.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
// Update URL without page reload
|
||||
if (history.pushState) {
|
||||
history.pushState(null, '', targetId);
|
||||
} else {
|
||||
location.hash = targetId;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to handle page section navigation
|
||||
const setupPageNavigation = (linkIds, pageId) => {
|
||||
const links = linkIds
|
||||
.map(id => document.getElementById(id))
|
||||
.filter(Boolean);
|
||||
|
||||
links.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== pageId;
|
||||
});
|
||||
const targetPage = document.getElementById(pageId);
|
||||
if (targetPage) {
|
||||
targetPage.hidden = false;
|
||||
targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Setup navigation for different sections
|
||||
setupPageNavigation(['guest-terms', 'user-terms'], 'terms-page');
|
||||
setupPageNavigation(['guest-imprint', 'user-imprint'], 'imprint-page');
|
||||
setupPageNavigation(['guest-privacy', 'user-privacy'], 'privacy-page');
|
||||
|
||||
// Registration form handler for guests - removed duplicate declaration
|
||||
// The form submission is already handled earlier in the file
|
||||
|
||||
// Login link handler - removed duplicate declaration
|
||||
// The login link is already handled by the setupPageNavigation function
|
||||
|
||||
// Handle drag and drop
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
if (uploadArea) {
|
||||
@ -1281,4 +819,3 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
}); // End of DOMContentLoaded
|
||||
|
@ -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
|
||||
*/
|
||||
startPlayback(playerType, uid, playerInstance = null) {
|
||||
console.log(`[GlobalAudioManager] startPlayback called by: ${playerType} for UID: ${uid}`);
|
||||
// If the same player is already playing the same UID, allow it
|
||||
if (this.currentPlayer === playerType && this.currentUid === uid) {
|
||||
return true;
|
||||
|
@ -22,7 +22,8 @@
|
||||
</style>
|
||||
<link rel="modulepreload" href="/static/sound.js" />
|
||||
<script src="/static/streams-ui.js?v=3" type="module"></script>
|
||||
<script src="/static/app.js" type="module"></script>
|
||||
<script src="/static/auth.js?v=2" type="module"></script>
|
||||
<script src="/static/app.js?v=5" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@ -36,7 +37,7 @@
|
||||
<nav id="guest-dashboard" class="dashboard-nav guest-only">
|
||||
<a href="#welcome-page" id="guest-welcome">Welcome</a>
|
||||
<a href="#stream-page" id="guest-streams">Streams</a>
|
||||
<a href="#account" id="guest-login">Account</a>
|
||||
<a href="#register-page" id="guest-login">Account</a>
|
||||
</nav>
|
||||
|
||||
<!-- User Dashboard -->
|
||||
@ -47,7 +48,7 @@
|
||||
</nav>
|
||||
<section id="me-page" class="auth-only">
|
||||
<div>
|
||||
<h2>Your Stream</h2>
|
||||
<h2 id="your-stream-heading">Your Stream</h2>
|
||||
</div>
|
||||
<article>
|
||||
<p>This is your personal stream. Only you can upload to it.</p>
|
||||
@ -187,27 +188,18 @@
|
||||
|
||||
<footer>
|
||||
<p class="footer-links">
|
||||
<a href="#" id="footer-terms" data-target="terms-page">Terms</a> |
|
||||
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
|
||||
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
|
||||
<a href="#terms-page" id="footer-terms">Terms</a> |
|
||||
<a href="#privacy-page" id="footer-privacy">Privacy</a> |
|
||||
<a href="#imprint-page" id="footer-imprint">Imprint</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/static/dashboard.js"></script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
<script type="module" src="/static/dashboard.js?v=5"></script>
|
||||
<!-- Load public streams UI logic -->
|
||||
<script type="module" src="/static/streams-ui.js?v=3"></script>
|
||||
<!-- Load upload functionality -->
|
||||
<script type="module" src="/static/upload.js"></script>
|
||||
<script type="module">
|
||||
import "/static/nav.js?v=2";
|
||||
window.addEventListener("pageshow", () => {
|
||||
const dz = document.querySelector("#user-upload-area");
|
||||
if (dz) dz.classList.remove("uploading");
|
||||
const spinner = document.querySelector("#spinner");
|
||||
if (spinner) spinner.style.display = "none";
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import { initMagicLogin } from '/static/magic-login.js';
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@ -220,7 +212,7 @@
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/static/init-personal-stream.js"></script>
|
||||
<!-- Temporary fix for mobile navigation -->
|
||||
<script src="/static/fix-nav.js"></script>
|
||||
<script type="module" src="/static/personal-player.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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
|
||||
import { showOnly } from './router.js';
|
||||
import { showSection } from './nav.js';
|
||||
|
||||
let magicLoginSubmitted = false;
|
||||
|
||||
@ -73,8 +73,8 @@ export async function initMagicLogin() {
|
||||
if (registerPage) registerPage.style.display = 'none';
|
||||
|
||||
// Show the user's stream page
|
||||
if (window.showOnly) {
|
||||
window.showOnly('me-page');
|
||||
if (typeof showSection === 'function') {
|
||||
showSection('me-page');
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
515
static/nav.js
515
static/nav.js
@ -7,468 +7,97 @@ function getCookie(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Check authentication status
|
||||
const isLoggedIn = !!getCookie('uid');
|
||||
|
||||
// Update body class for CSS-based visibility
|
||||
document.body.classList.toggle('logged-in', isLoggedIn);
|
||||
|
||||
// Get all main content sections
|
||||
const mainSections = Array.from(document.querySelectorAll('main > section'));
|
||||
|
||||
// Show/hide sections with smooth transitions
|
||||
const showSection = (sectionId) => {
|
||||
// Update body class to indicate current page
|
||||
document.body.className = '';
|
||||
if (sectionId) {
|
||||
document.body.classList.add(`page-${sectionId}`);
|
||||
// Determines the correct section to show based on auth status and requested section
|
||||
function getValidSection(sectionId) {
|
||||
const isLoggedIn = !!getCookie('uid');
|
||||
const protectedSections = ['me-page', 'account-page'];
|
||||
const guestOnlySections = ['login-page', 'register-page', 'magic-login-page'];
|
||||
|
||||
if (isLoggedIn) {
|
||||
// If logged in, guest-only sections are invalid, redirect to 'me-page'
|
||||
if (guestOnlySections.includes(sectionId)) {
|
||||
return 'me-page';
|
||||
}
|
||||
} else {
|
||||
document.body.classList.add('page-welcome');
|
||||
// If not logged in, protected sections are invalid, redirect to 'welcome-page'
|
||||
if (protectedSections.includes(sectionId)) {
|
||||
return 'welcome-page';
|
||||
}
|
||||
}
|
||||
|
||||
// If the section doesn't exist in the DOM, default to welcome page
|
||||
if (!document.getElementById(sectionId)) {
|
||||
return 'welcome-page';
|
||||
}
|
||||
|
||||
return sectionId;
|
||||
}
|
||||
|
||||
// Main function to show/hide sections
|
||||
export function showSection(sectionId) {
|
||||
const mainSections = Array.from(document.querySelectorAll('main > section'));
|
||||
|
||||
// Update body class for page-specific CSS
|
||||
document.body.className = document.body.className.replace(/page-\S+/g, '');
|
||||
document.body.classList.add(`page-${sectionId || 'welcome-page'}`);
|
||||
|
||||
// Update active state of navigation links
|
||||
document.querySelectorAll('.dashboard-nav a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
if ((!sectionId && link.getAttribute('href') === '#welcome-page') ||
|
||||
(sectionId && link.getAttribute('href') === `#${sectionId}`)) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
link.classList.remove('active');
|
||||
if (link.getAttribute('href') === `#${sectionId}`) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
mainSections.forEach(section => {
|
||||
// Skip navigation sections
|
||||
if (section.id === 'guest-dashboard' || section.id === 'user-dashboard') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTarget = section.id === sectionId;
|
||||
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
|
||||
const isWelcomePage = !sectionId || sectionId === 'welcome-page';
|
||||
|
||||
if (isTarget || (isLegalPage && section.id === sectionId)) {
|
||||
// Show the target section or legal page
|
||||
section.classList.add('active');
|
||||
section.hidden = false;
|
||||
|
||||
// Focus the section for accessibility with a small delay
|
||||
// Only focus if the section is focusable and in the viewport
|
||||
const focusSection = () => {
|
||||
try {
|
||||
if (section && typeof section.focus === 'function' &&
|
||||
section.offsetParent !== null && // Check if element is visible
|
||||
section.getBoundingClientRect().top < window.innerHeight &&
|
||||
section.getBoundingClientRect().bottom > 0) {
|
||||
section.focus({ preventScroll: true });
|
||||
section.hidden = section.id !== sectionId;
|
||||
});
|
||||
|
||||
// Update URL hash without causing a page scroll, this is for direct calls to showSection
|
||||
// Normal navigation is handled by the hashchange listener
|
||||
const currentHash = `#${sectionId}`;
|
||||
if (window.location.hash !== currentHash) {
|
||||
if (history.pushState) {
|
||||
if (sectionId && sectionId !== 'welcome-page') {
|
||||
history.pushState(null, null, currentHash);
|
||||
} else {
|
||||
history.pushState(null, null, window.location.pathname + window.location.search);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail if focusing isn't possible
|
||||
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
|
||||
console.debug('Could not focus section:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Use requestAnimationFrame for better performance
|
||||
requestAnimationFrame(() => {
|
||||
// Only set the timeout in debug mode or local development
|
||||
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
|
||||
setTimeout(focusSection, 50);
|
||||
} else {
|
||||
focusSection();
|
||||
}
|
||||
});
|
||||
} else if (isWelcomePage && section.id === 'welcome-page') {
|
||||
// Special handling for welcome page
|
||||
section.classList.add('active');
|
||||
section.hidden = false;
|
||||
} else {
|
||||
// Hide other sections
|
||||
section.classList.remove('active');
|
||||
section.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Update URL hash without page scroll
|
||||
if (sectionId && !['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId)) {
|
||||
if (sectionId === 'welcome-page') {
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
} else {
|
||||
history.replaceState(null, '', `#${sectionId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle initial page load
|
||||
const getValidSection = (sectionId) => {
|
||||
const protectedSections = ['me-page', 'register-page'];
|
||||
|
||||
// If not logged in and trying to access protected section
|
||||
if (!isLoggedIn && protectedSections.includes(sectionId)) {
|
||||
return 'welcome-page';
|
||||
}
|
||||
|
||||
// If section doesn't exist, default to welcome page
|
||||
if (!document.getElementById(sectionId)) {
|
||||
return 'welcome-page';
|
||||
}
|
||||
|
||||
return sectionId;
|
||||
};
|
||||
|
||||
// Process initial page load
|
||||
const initialPage = window.location.hash.substring(1) || 'welcome-page';
|
||||
const validSection = getValidSection(initialPage);
|
||||
|
||||
// Update URL if needed
|
||||
if (validSection !== initialPage) {
|
||||
window.location.hash = validSection;
|
||||
}
|
||||
|
||||
// Show the appropriate section
|
||||
showSection(validSection);
|
||||
|
||||
const Router = {
|
||||
sections: Array.from(document.querySelectorAll("main > section")),
|
||||
|
||||
showOnly(id) {
|
||||
// Validate the section ID
|
||||
const validId = getValidSection(id);
|
||||
|
||||
// Update URL if needed
|
||||
if (validId !== id) {
|
||||
window.location.hash = validId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the requested section
|
||||
showSection(validId);
|
||||
|
||||
// Handle the quota meter visibility - only show with 'me-page'
|
||||
const quotaMeter = document.getElementById('quota-meter');
|
||||
if (quotaMeter) {
|
||||
quotaMeter.hidden = validId !== 'me-page';
|
||||
quotaMeter.tabIndex = validId === 'me-page' ? 0 : -1;
|
||||
}
|
||||
|
||||
// Update navigation active states
|
||||
this.updateActiveNav(validId);
|
||||
},
|
||||
|
||||
updateActiveNav(activeId) {
|
||||
// Update active states for navigation links
|
||||
document.querySelectorAll('.dashboard-nav a').forEach(link => {
|
||||
const target = link.getAttribute('href').substring(1);
|
||||
if (target === activeId) {
|
||||
link.setAttribute('aria-current', 'page');
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.removeAttribute('aria-current');
|
||||
link.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the router
|
||||
const router = Router;
|
||||
|
||||
// Handle section visibility based on authentication
|
||||
const updateSectionVisibility = (sectionId) => {
|
||||
const section = document.getElementById(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
// Skip navigation sections and quota meter
|
||||
if (['guest-dashboard', 'user-dashboard', 'quota-meter'].includes(sectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHash = window.location.hash.substring(1);
|
||||
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
|
||||
|
||||
// Special handling for legal pages - always show when in hash
|
||||
if (isLegalPage) {
|
||||
const isActive = sectionId === currentHash;
|
||||
section.hidden = !isActive;
|
||||
section.tabIndex = isActive ? 0 : -1;
|
||||
if (isActive) section.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special handling for me-page - only show to authenticated users
|
||||
if (sectionId === 'me-page') {
|
||||
section.hidden = !isLoggedIn || currentHash !== 'me-page';
|
||||
section.tabIndex = (isLoggedIn && currentHash === 'me-page') ? 0 : -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Special handling for register page - only show to guests
|
||||
if (sectionId === 'register-page') {
|
||||
section.hidden = isLoggedIn || currentHash !== 'register-page';
|
||||
section.tabIndex = (!isLoggedIn && currentHash === 'register-page') ? 0 : -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// For other sections, show if they match the current section ID
|
||||
const isActive = sectionId === currentHash;
|
||||
section.hidden = !isActive;
|
||||
section.tabIndex = isActive ? 0 : -1;
|
||||
|
||||
if (isActive) {
|
||||
section.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the router
|
||||
router.init = function() {
|
||||
// Update visibility for all sections
|
||||
this.sections.forEach(section => {
|
||||
updateSectionVisibility(section.id);
|
||||
});
|
||||
|
||||
// Show user-upload-area only when me-page is shown and user is logged in
|
||||
const userUpload = document.getElementById("user-upload-area");
|
||||
if (userUpload) {
|
||||
const uid = getCookie("uid");
|
||||
userUpload.style.display = (window.location.hash === '#me-page' && uid) ? '' : 'none';
|
||||
}
|
||||
|
||||
// Store the current page
|
||||
localStorage.setItem("last_page", window.location.hash.substring(1));
|
||||
|
||||
// Initialize navigation
|
||||
initNavLinks();
|
||||
initBackButtons();
|
||||
initStreamLinks();
|
||||
|
||||
// Ensure proper focus management for accessibility
|
||||
const currentSection = document.querySelector('main > section:not([hidden])');
|
||||
if (currentSection) {
|
||||
currentSection.setAttribute('tabindex', '0');
|
||||
currentSection.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the router
|
||||
router.init();
|
||||
|
||||
// Handle footer links
|
||||
document.querySelectorAll('.footer-links a').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = link.dataset.target;
|
||||
if (target) {
|
||||
// Update URL hash to maintain proper history state
|
||||
window.location.hash = target;
|
||||
// Use the router to handle the navigation
|
||||
if (router && typeof router.showOnly === 'function') {
|
||||
router.showOnly(target);
|
||||
} else {
|
||||
// Fallback to showSection if router is not available
|
||||
showSection(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Export the showOnly function for global access
|
||||
window.showOnly = router.showOnly.bind(router);
|
||||
|
||||
// Make router available globally for debugging
|
||||
window.appRouter = router;
|
||||
}
|
||||
|
||||
// Highlight active profile link on browser back/forward navigation
|
||||
function highlightActiveProfileLink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get('profile');
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) return;
|
||||
ul.querySelectorAll('a.profile-link').forEach(link => {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
const uidParam = url.searchParams.get('profile');
|
||||
link.classList.toggle('active', uidParam === profileUid);
|
||||
});
|
||||
}
|
||||
window.addEventListener('popstate', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get('profile');
|
||||
const currentPage = window.location.hash.substring(1) || 'welcome-page';
|
||||
|
||||
// Prevent unauthorized access to me-page
|
||||
if ((currentPage === 'me-page' || profileUid) && !getCookie('uid')) {
|
||||
history.replaceState(null, '', '#welcome-page');
|
||||
showOnly('welcome-page');
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileUid) {
|
||||
showOnly('me-page');
|
||||
if (typeof window.showProfilePlayerFromUrl === 'function') {
|
||||
window.showProfilePlayerFromUrl();
|
||||
}
|
||||
} else {
|
||||
highlightActiveProfileLink();
|
||||
}
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const isLoggedIn = !!getCookie('uid');
|
||||
document.body.classList.toggle('authenticated', isLoggedIn);
|
||||
|
||||
/* restore last page (unless magic‑link token present) */
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) {
|
||||
const last = localStorage.getItem("last_page");
|
||||
if (last && document.getElementById(last)) {
|
||||
showOnly(last);
|
||||
} else if (document.getElementById("welcome-page")) {
|
||||
// Show Welcome page by default for all new/guest users
|
||||
showOnly("welcome-page");
|
||||
}
|
||||
// Highlight active link on initial load
|
||||
highlightActiveProfileLink();
|
||||
}
|
||||
// Unified click handler for SPA navigation
|
||||
document.body.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a[href^="#"]');
|
||||
// Ensure the link is not inside a component that handles its own navigation
|
||||
if (!link || link.closest('.no-global-nav')) return;
|
||||
|
||||
/* token → show magic‑login page */
|
||||
if (token) {
|
||||
document.getElementById("magic-token").value = token;
|
||||
showOnly("magic-login-page");
|
||||
const err = params.get("error");
|
||||
if (err) {
|
||||
const box = document.getElementById("magic-error");
|
||||
box.textContent = decodeURIComponent(err);
|
||||
box.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function renderStreamList(streams) {
|
||||
const ul = document.getElementById("stream-list");
|
||||
if (!ul) return;
|
||||
if (streams.length) {
|
||||
// Handle both array of UIDs (legacy) and array of stream objects (new)
|
||||
const streamItems = streams.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
// Legacy: array of UIDs
|
||||
return { uid: item, username: item };
|
||||
} else {
|
||||
// New: array of stream objects
|
||||
return {
|
||||
uid: item.uid || '',
|
||||
username: item.username || 'Unknown User'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
streamItems.sort((a, b) => (a.username || '').localeCompare(b.username || ''));
|
||||
ul.innerHTML = streamItems.map(stream => `
|
||||
<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();
|
||||
|
||||
// Save audio state before navigation
|
||||
const audio = document.getElementById('me-audio');
|
||||
const wasPlaying = audio && !audio.paused;
|
||||
const currentTime = audio ? audio.currentTime : 0;
|
||||
|
||||
const target = a.dataset.target;
|
||||
if (target) showOnly(target);
|
||||
|
||||
// Handle stream page specifically
|
||||
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
|
||||
window.maybeLoadStreamsOnShow();
|
||||
const newHash = link.getAttribute('href');
|
||||
if (window.location.hash !== newHash) {
|
||||
window.location.hash = newHash;
|
||||
}
|
||||
// Handle me-page specifically
|
||||
else if (target === "me-page" && audio) {
|
||||
// Restore audio state if it was playing
|
||||
if (wasPlaying) {
|
||||
audio.currentTime = currentTime;
|
||||
audio.play().catch(e => console.error('Play failed:', e));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click handlers for footer links with audio state saving
|
||||
document.querySelectorAll(".footer-links a").forEach(link => {
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const target = link.dataset.target;
|
||||
if (!target) return;
|
||||
// Main routing logic on hash change
|
||||
const handleNavigation = () => {
|
||||
const sectionId = window.location.hash.substring(1) || 'welcome-page';
|
||||
const validSectionId = getValidSection(sectionId);
|
||||
|
||||
// Save audio state before navigation
|
||||
const audio = document.getElementById('me-audio');
|
||||
const wasPlaying = audio && !audio.paused;
|
||||
const currentTime = audio ? audio.currentTime : 0;
|
||||
|
||||
showOnly(target);
|
||||
|
||||
// Handle me-page specifically
|
||||
if (target === "me-page" && audio) {
|
||||
// Restore audio state if it was playing
|
||||
if (wasPlaying) {
|
||||
audio.currentTime = currentTime;
|
||||
audio.play().catch(e => console.error('Play failed:', e));
|
||||
}
|
||||
if (sectionId !== validSectionId) {
|
||||
window.location.hash = validSectionId; // This will re-trigger handleNavigation
|
||||
} else {
|
||||
showSection(validSectionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initBackButtons() {
|
||||
document.querySelectorAll('a[data-back]').forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
const target = btn.dataset.back;
|
||||
if (target) showOnly(target);
|
||||
// Ensure streams load instantly when stream-page is shown
|
||||
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
|
||||
window.maybeLoadStreamsOnShow();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleNavigation);
|
||||
|
||||
|
||||
function initStreamLinks() {
|
||||
const ul = document.getElementById("stream-list");
|
||||
if (!ul) return;
|
||||
ul.addEventListener("click", e => {
|
||||
const a = e.target.closest("a.profile-link");
|
||||
if (!a || !ul.contains(a)) return;
|
||||
e.preventDefault();
|
||||
const url = new URL(a.href, window.location.origin);
|
||||
const profileUid = url.searchParams.get("profile");
|
||||
if (profileUid && window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
|
||||
window.profileNavigationTriggered = true;
|
||||
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}`);
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Router
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
// Re-check authentication when tab becomes visible again
|
||||
if (!document.hidden && window.location.hash === '#me-page' && !getCookie('uid')) {
|
||||
window.location.hash = 'welcome-page';
|
||||
showOnly('welcome-page');
|
||||
}
|
||||
});
|
||||
|
||||
Router.init();
|
||||
// Initial page load
|
||||
handleNavigation();
|
||||
});
|
||||
|
140
static/personal-player.js
Normal file
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
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
import { globalAudioManager } from './global-audio-manager.js';
|
||||
|
||||
// Global variable to track if we should force refresh the stream list
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { showToast } from "./toast.js";
|
||||
import { playBeep } from "./sound.js";
|
||||
import { logToServer } from "./app.js";
|
||||
import { logToServer } from "./logger.js";
|
||||
|
||||
// Initialize upload system when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
Reference in New Issue
Block a user