Fix double audio playback and add UID handling for personal stream

- Fixed double playback issue on stream page by properly scoping event delegation in streams-ui.js
- Added init-personal-stream.js to handle UID for personal stream playback
- Improved error handling and logging for audio playback
- Added proper event propagation control to prevent duplicate event handling
This commit is contained in:
oib
2025-07-18 16:51:39 +02:00
parent 17616ac5b8
commit 402e920bc6
24 changed files with 4074 additions and 1090 deletions

212
deletefile.py Normal file
View File

@ -0,0 +1,212 @@
# 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"
)

38
main.py
View File

@ -36,7 +36,19 @@ from fastapi.requests import Request as FastAPIRequest
from fastapi.exception_handlers import RequestValidationError from fastapi.exception_handlers import RequestValidationError
from fastapi.exceptions import HTTPException as FastAPIHTTPException from fastapi.exceptions import HTTPException as FastAPIHTTPException
app = FastAPI(debug=debug_mode) app = FastAPI(debug=debug_mode, docs_url=None, redoc_url=None, openapi_url=None)
# Override default HTML error handlers to return JSON
from fastapi.exceptions import RequestValidationError, HTTPException as FastAPIHTTPException
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
# --- CORS Middleware for SSE and API access --- # --- CORS Middleware for SSE and API access ---
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -292,11 +304,16 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
user = get_user_by_uid(uid) user = get_user_by_uid(uid)
if not user: if not user:
print(f"[ERROR] User with UID {uid} not found") print(f"[ERROR] User with UID {uid} not found")
raise HTTPException(status_code=403, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user.ip != request.client.host: # Only enforce IP check in production
print(f"[ERROR] IP mismatch for UID {uid}: {request.client.host} != {user.ip}") if not debug_mode:
raise HTTPException(status_code=403, detail="IP address mismatch") if user.ip != request.client.host:
print(f"[WARNING] IP mismatch for UID {uid}: {request.client.host} != {user.ip}")
# In production, we might want to be more strict
# But for now, we'll just log a warning in development
if not debug_mode:
raise HTTPException(status_code=403, detail="IP address mismatch")
# Get all upload logs for this user # Get all upload logs for this user
upload_logs = db.exec( upload_logs = db.exec(
@ -331,6 +348,13 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
print(f"[DEBUG] Returning {len(files)} files and quota info") print(f"[DEBUG] Returning {len(files)} files and quota info")
return response_data return response_data
except Exception as e: except HTTPException:
print(f"[ERROR] Error in /me/{uid} endpoint: {str(e)}", exc_info=True) # Re-raise HTTP exceptions as they are
raise raise
except Exception as e:
# Log the full traceback for debugging
import traceback
error_trace = traceback.format_exc()
print(f"[ERROR] Error in /me/{uid} endpoint: {str(e)}\n{error_trace}")
# Return a 500 error with a generic message
raise HTTPException(status_code=500, detail="Internal server error")

View File

@ -1,3 +1,4 @@
{"uid":"devuser","size":22455090,"mtime":1747563720} {"uid":"devuser","size":65551721,"mtime":1752752391}
{"uid":"oib9","size":2019706,"mtime":1751124547} {"uid":"oib9","size":12735117,"mtime":1752843762}
{"uid":"oibchello","size":1549246,"mtime":1752840918}
{"uid":"orangeicebear","size":1734396,"mtime":1748767975} {"uid":"orangeicebear","size":1734396,"mtime":1748767975}

View File

@ -2,6 +2,7 @@
import { playBeep } from "./sound.js"; import { playBeep } from "./sound.js";
import { showToast } from "./toast.js"; import { showToast } from "./toast.js";
import { injectNavigation } from "./inject-nav.js";
// Global audio state // Global audio state
let globalAudio = null; let globalAudio = null;
@ -30,29 +31,42 @@ function handleMagicLoginRedirect() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get('login') === 'success' && params.get('confirmed_uid')) { if (params.get('login') === 'success' && params.get('confirmed_uid')) {
const username = 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('uid', username);
localStorage.setItem('confirmed_uid', username); localStorage.setItem('confirmed_uid', username);
localStorage.setItem('uid_time', Date.now().toString()); localStorage.setItem('uid_time', Date.now().toString());
document.cookie = `uid=${encodeURIComponent(username)}; path=/`; document.cookie = `uid=${encodeURIComponent(username)}; path=/`;
// Update UI state immediately without reload // Update UI state
const guestDashboard = document.getElementById('guest-dashboard'); document.body.classList.add('authenticated');
const userDashboard = document.getElementById('user-dashboard'); document.body.classList.remove('guest');
const registerPage = document.getElementById('register-page');
if (guestDashboard) guestDashboard.style.display = 'none'; // Update local storage and cookies
if (userDashboard) userDashboard.style.display = 'block'; localStorage.setItem('isAuthenticated', 'true');
if (registerPage) registerPage.style.display = 'none'; document.cookie = `isAuthenticated=true; path=/`;
// Update URL and history without reloading // Update URL and history without reloading
window.history.replaceState({}, document.title, window.location.pathname); 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 // Navigate to user's profile page
if (window.showOnly) { if (window.showOnly) {
console.log('Navigating to me-page');
window.showOnly('me-page'); window.showOnly('me-page');
} else if (window.location.hash !== '#me') { } else if (window.location.hash !== '#me') {
window.location.hash = '#me'; window.location.hash = '#me';
} }
// Auth state will be updated by the polling mechanism
} }
} }
@ -298,55 +312,116 @@ function showProfilePlayerFromUrl() {
} }
function initNavigation() { function initNavigation() {
const navLinks = document.querySelectorAll('nav a'); // Get all navigation links
const navLinks = document.querySelectorAll('nav a, .dashboard-nav a');
// Handle navigation link clicks
const handleNavClick = (e) => {
const link = e.target.closest('a');
if (!link) return;
// Check both href and data-target attributes
const target = link.getAttribute('data-target');
const href = link.getAttribute('href');
// Let the browser handle external links
if (href && (href.startsWith('http') || href.startsWith('mailto:'))) {
return;
}
// If no data-target and no hash in href, let browser handle it
if (!target && (!href || !href.startsWith('#'))) {
return;
}
// Prefer data-target over href
let sectionId = target || (href ? href.substring(1) : '');
// Special case for the 'me' route which maps to 'me-page'
if (sectionId === 'me') {
sectionId = 'me-page';
}
// Skip if no valid section ID
if (!sectionId) {
console.warn('No valid section ID in navigation link:', link);
return;
}
// Let the router handle the navigation
e.preventDefault();
e.stopPropagation();
// Update body class to reflect the current section
document.body.className = ''; // Clear existing classes
document.body.classList.add(`page-${sectionId.replace('-page', '')}`);
// Update URL hash - the router will handle the rest
window.location.hash = sectionId;
// Close mobile menu if open
const burger = document.getElementById('burger-toggle');
if (burger && burger.checked) burger.checked = false;
};
// Add click event listeners to all navigation links
navLinks.forEach(link => { navLinks.forEach(link => {
link.addEventListener('click', (e) => { link.addEventListener('click', handleNavClick);
const href = link.getAttribute('href');
// Skip if href is empty or doesn't start with '#'
if (!href || !href.startsWith('#')) {
return; // Let the browser handle the link normally
}
const sectionId = href.substring(1); // Remove the '#'
// Skip if sectionId is empty after removing '#'
if (!sectionId) {
console.warn('Empty section ID in navigation link:', link);
return;
}
const section = document.getElementById(sectionId);
if (section) {
e.preventDefault();
// Hide all sections first
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== sectionId;
});
// Special handling for me-page
if (sectionId === 'me-page') {
const registerPage = document.getElementById('register-page');
if (registerPage) registerPage.hidden = true;
// Show the upload box in me-page
const uploadBox = document.querySelector('#me-page #user-upload-area');
if (uploadBox) uploadBox.style.display = 'block';
} else if (sectionId === 'register-page') {
// Ensure me-page is hidden when register-page is shown
const mePage = document.getElementById('me-page');
if (mePage) mePage.hidden = true;
}
section.scrollIntoView({ behavior: "smooth" });
// Close mobile menu if open
const burger = document.getElementById('burger-toggle');
if (burger && burger.checked) burger.checked = false;
}
});
}); });
// Handle initial page load and hash changes
const handleHashChange = () => {
let hash = window.location.hash.substring(1);
// Map URL hashes to section IDs if they don't match exactly
const sectionMap = {
'welcome': 'welcome-page',
'streams': 'stream-page',
'account': 'register-page',
'login': 'login-page',
'me': 'me-page'
};
// Use mapped section ID or the hash as is
const sectionId = sectionMap[hash] || hash || 'welcome-page';
const targetSection = document.getElementById(sectionId);
if (targetSection) {
// Hide all sections
document.querySelectorAll('main > section').forEach(section => {
section.hidden = section.id !== sectionId;
});
// Show target section
targetSection.hidden = false;
targetSection.scrollIntoView({ behavior: 'smooth' });
// Update active state of navigation links
navLinks.forEach(link => {
const linkHref = link.getAttribute('href');
// Match both the exact hash and the mapped section ID
link.classList.toggle('active',
linkHref === `#${hash}` ||
linkHref === `#${sectionId}` ||
link.getAttribute('data-target') === sectionId ||
link.getAttribute('data-target') === hash
);
});
// Special handling for streams page
if (sectionId === 'stream-page' && typeof window.maybeLoadStreamsOnShow === 'function') {
window.maybeLoadStreamsOnShow();
}
} else {
console.warn(`Section with ID '${sectionId}' not found`);
}
};
// Listen for hash changes
window.addEventListener('hashchange', handleHashChange);
// Handle initial page load
handleHashChange();
} }
function initProfilePlayer() { function initProfilePlayer() {
@ -354,14 +429,344 @@ function initProfilePlayer() {
showProfilePlayerFromUrl(); showProfilePlayerFromUrl();
} }
// Track previous authentication state
let wasAuthenticated = null;
// Debug flag - set to false to disable auth state change logs
const DEBUG_AUTH_STATE = false;
// Track all intervals and timeouts
const activeIntervals = new Map();
const activeTimeouts = new Map();
// Store original timer functions
const originalSetInterval = window.setInterval;
const originalClearInterval = window.clearInterval;
const originalSetTimeout = window.setTimeout;
const originalClearTimeout = window.clearTimeout;
// Override setInterval to track all intervals
window.setInterval = (callback, delay, ...args) => {
const id = originalSetInterval((...args) => {
trackFunctionCall('setInterval callback', { id, delay, callback: callback.toString() });
return callback(...args);
}, delay, ...args);
activeIntervals.set(id, {
id,
delay,
callback: callback.toString(),
createdAt: Date.now(),
stack: new Error().stack
});
if (DEBUG_AUTH_STATE) {
console.log(`[Interval ${id}] Created with delay ${delay}ms`, {
id,
delay,
callback: callback.toString(),
stack: new Error().stack
});
}
return id;
};
// Override clearInterval to track interval cleanup
window.clearInterval = (id) => {
if (activeIntervals.has(id)) {
activeIntervals.delete(id);
if (DEBUG_AUTH_STATE) {
console.log(`[Interval ${id}] Cleared`);
}
} else if (DEBUG_AUTH_STATE) {
console.log(`[Interval ${id}] Cleared (not tracked)`);
}
originalClearInterval(id);
};
// Override setTimeout to track timeouts (debug logging disabled)
window.setTimeout = (callback, delay, ...args) => {
const id = originalSetTimeout(callback, delay, ...args);
// Store minimal info without logging
activeTimeouts.set(id, {
id,
delay,
callback: callback.toString(),
createdAt: Date.now()
});
return id;
};
// Override clearTimeout to track timeout cleanup (debug logging disabled)
window.clearTimeout = (id) => {
if (activeTimeouts.has(id)) {
activeTimeouts.delete(id);
}
originalClearTimeout(id);
};
// Track auth check calls
let lastAuthCheckTime = 0;
let authCheckCounter = 0;
const AUTH_CHECK_DEBOUNCE = 1000; // 1 second
// Override console.log to capture all logs
const originalConsoleLog = console.log;
const originalConsoleGroup = console.group;
const originalConsoleGroupEnd = console.groupEnd;
// Track all console logs
const consoleLogs = [];
const MAX_LOGS = 100;
console.log = function(...args) {
// Store the log
consoleLogs.push({
type: 'log',
timestamp: new Date().toISOString(),
args: [...args],
stack: new Error().stack
});
// Keep only the most recent logs
while (consoleLogs.length > MAX_LOGS) {
consoleLogs.shift();
}
// Filter out the auth state check messages
if (args[0] && typeof args[0] === 'string' && args[0].includes('Auth State Check')) {
return;
}
originalConsoleLog.apply(console, args);
};
// Track console groups
console.group = function(...args) {
consoleLogs.push({ type: 'group', timestamp: new Date().toISOString(), args });
originalConsoleGroup.apply(console, args);
};
console.groupEnd = function() {
consoleLogs.push({ type: 'groupEnd', timestamp: new Date().toISOString() });
originalConsoleGroupEnd.apply(console);
};
// Track all function calls that might trigger auth checks
const trackedFunctions = ['checkAuthState', 'setInterval', 'setTimeout', 'addEventListener', 'removeEventListener'];
const functionCalls = [];
const MAX_FUNCTION_CALLS = 100;
// Function to format stack trace for better readability
function formatStack(stack) {
if (!stack) return 'No stack trace';
// Remove the first line (the error message) and limit to 5 frames
const frames = stack.split('\n').slice(1).slice(0, 5);
return frames.join('\n');
}
// Function to dump debug info
window.dumpDebugInfo = () => {
console.group('Debug Info');
// Active Intervals
console.group('Active Intervals');
if (activeIntervals.size === 0) {
console.log('No active intervals');
} else {
activeIntervals.forEach((info, id) => {
console.group(`Interval ${id} (${info.delay}ms)`);
console.log('Created at:', new Date(info.createdAt).toISOString());
console.log('Callback:', info.callback.split('\n')[0]); // First line of callback
console.log('Stack trace:');
console.log(formatStack(info.stack));
console.groupEnd();
});
}
console.groupEnd();
// Active Timeouts
console.group('Active Timeouts');
if (activeTimeouts.size === 0) {
console.log('No active timeouts');
} else {
activeTimeouts.forEach((info, id) => {
console.group(`Timeout ${id} (${info.delay}ms)`);
console.log('Created at:', new Date(info.createdAt).toISOString());
console.log('Callback:', info.callback.split('\n')[0]); // First line of callback
console.log('Stack trace:');
console.log(formatStack(info.stack));
console.groupEnd();
});
}
console.groupEnd();
// Document state
console.group('Document State');
console.log('Visibility:', document.visibilityState);
console.log('Has Focus:', document.hasFocus());
console.log('URL:', window.location.href);
console.groupEnd();
// Recent logs
console.group('Recent Logs (10 most recent)');
if (consoleLogs.length === 0) {
console.log('No logs recorded');
} else {
consoleLogs.slice(-10).forEach((log, i) => {
console.group(`Log ${i + 1} (${log.timestamp})`);
console.log(...log.args);
if (log.stack) {
console.log('Stack trace:');
console.log(formatStack(log.stack));
}
console.groupEnd();
});
}
console.groupEnd();
// Auth state
console.group('Auth State');
console.log('Has auth cookie:', document.cookie.includes('sessionid='));
console.log('Has UID cookie:', document.cookie.includes('uid='));
console.log('Has localStorage auth:', localStorage.getItem('isAuthenticated') === 'true');
console.log('Has auth token:', !!localStorage.getItem('auth_token'));
console.groupEnd();
console.groupEnd(); // End main group
};
function trackFunctionCall(name, ...args) {
const callInfo = {
name,
time: Date.now(),
timestamp: new Date().toISOString(),
args: args.map(arg => {
try {
return JSON.stringify(arg);
} catch (e) {
return String(arg);
}
}),
stack: new Error().stack
};
functionCalls.push(callInfo);
if (functionCalls.length > MAX_FUNCTION_CALLS) {
functionCalls.shift();
}
if (name === 'checkAuthState') {
console.group(`[${functionCalls.length}] Auth check at ${callInfo.timestamp}`);
console.log('Call stack:', callInfo.stack);
console.log('Recent function calls:', functionCalls.slice(-5));
console.groupEnd();
}
}
// Override tracked functions
trackedFunctions.forEach(fnName => {
if (window[fnName]) {
const originalFn = window[fnName];
window[fnName] = function(...args) {
trackFunctionCall(fnName, ...args);
return originalFn.apply(this, args);
};
}
});
// Check authentication state and update UI
function checkAuthState() {
const now = Date.now();
// Throttle the checks
if (now - lastAuthCheckTime < AUTH_CHECK_DEBOUNCE) {
return;
}
lastAuthCheckTime = now;
// Check various auth indicators
const hasAuthCookie = document.cookie.includes('sessionid=');
const hasUidCookie = document.cookie.includes('uid=');
const hasLocalStorageAuth = localStorage.getItem('isAuthenticated') === 'true';
const hasAuthToken = localStorage.getItem('authToken') !== null;
const isAuthenticated = hasAuthCookie || hasUidCookie || hasLocalStorageAuth || hasAuthToken;
// Only log if debug is enabled or if state has changed
if (DEBUG_AUTH_STATE || isAuthenticated !== wasAuthenticated) {
console.log('Auth State Check:', {
hasAuthCookie,
hasUidCookie,
hasLocalStorageAuth,
hasAuthToken,
isAuthenticated,
wasAuthenticated
});
}
// Only update if authentication state has changed
if (isAuthenticated !== wasAuthenticated) {
if (DEBUG_AUTH_STATE) {
console.log('Auth state changed, updating navigation...');
}
// Update UI state
if (isAuthenticated) {
document.body.classList.add('authenticated');
document.body.classList.remove('guest');
} else {
document.body.classList.remove('authenticated');
document.body.classList.add('guest');
}
// Update navigation
if (typeof injectNavigation === 'function') {
injectNavigation(isAuthenticated);
} else if (DEBUG_AUTH_STATE) {
console.warn('injectNavigation function not found');
}
// Update the tracked state
wasAuthenticated = isAuthenticated;
// Force reflow to ensure CSS updates
void document.body.offsetHeight;
}
return isAuthenticated;
}
// Periodically check authentication state
function setupAuthStatePolling() {
// Initial check
checkAuthState();
// Check every 30 seconds instead of 2 to reduce load
setInterval(checkAuthState, 30000);
// Also check after certain events that might affect auth state
window.addEventListener('storage', checkAuthState);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) checkAuthState();
});
}
// Initialize the application when DOM is loaded // Initialize the application when DOM is loaded
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// Set up authentication state monitoring
setupAuthStatePolling();
// Handle magic link redirect if needed // Handle magic link redirect if needed
handleMagicLoginRedirect(); handleMagicLoginRedirect();
// Initialize components // Initialize components
initNavigation(); initNavigation();
// Initialize profile player after a short delay // Initialize profile player after a short delay
setTimeout(() => { setTimeout(() => {
initProfilePlayer(); initProfilePlayer();
@ -453,26 +858,27 @@ document.addEventListener("DOMContentLoaded", () => {
// Set up delete account button if it exists // Set up delete account button if it exists
const deleteAccountBtn = document.getElementById('delete-account'); const deleteAccountBtn = document.getElementById('delete-account');
if (deleteAccountBtn) { const deleteAccountFromPrivacyBtn = document.getElementById('delete-account-from-privacy');
deleteAccountBtn.addEventListener('click', async (e) => {
e.preventDefault();
if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) { const deleteAccount = async (e) => {
return; if (e) e.preventDefault();
}
try { if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) {
const response = await fetch('/api/delete-account', { return;
method: 'POST', }
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) { try {
// Clear local storage and redirect to home page const response = await fetch('/api/delete-account', {
localStorage.clear(); method: 'POST',
window.location.href = '/'; headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
// Clear local storage and redirect to home page
localStorage.clear();
window.location.href = '/';
} else { } else {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'Failed to delete account'); throw new Error(error.detail || 'Failed to delete account');
@ -481,12 +887,48 @@ document.addEventListener("DOMContentLoaded", () => {
console.error('Error deleting account:', error); console.error('Error deleting account:', error);
showToast(`${error.message || 'Failed to delete account'}`, 'error'); showToast(`${error.message || 'Failed to delete account'}`, 'error');
} }
}); };
}
// Add event listeners to both delete account buttons
if (deleteAccountBtn) {
deleteAccountBtn.addEventListener('click', deleteAccount);
}
if (deleteAccountFromPrivacyBtn) {
deleteAccountFromPrivacyBtn.addEventListener('click', deleteAccount);
}
}, 200); // End of setTimeout }, 200); // End of setTimeout
}); });
// Logout function
function logout() {
// Clear authentication state
document.body.classList.remove('authenticated');
localStorage.removeItem('isAuthenticated');
localStorage.removeItem('uid');
localStorage.removeItem('confirmed_uid');
localStorage.removeItem('uid_time');
// Clear cookies
document.cookie = 'isAuthenticated=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Stop any playing audio
stopMainAudio();
// Redirect to home page
window.location.href = '/';
}
// Add click handler for logout button
document.addEventListener('click', (e) => {
if (e.target.id === 'logout-button' || e.target.closest('#logout-button')) {
e.preventDefault();
logout();
}
});
// Expose functions for global access // Expose functions for global access
window.logToServer = logToServer; window.logToServer = logToServer;
window.getMainAudio = () => globalAudio; window.getMainAudio = () => globalAudio;

69
static/css/colors.css Normal file
View File

@ -0,0 +1,69 @@
/*
* Color System Documentation
*
* This file documents the color variables used throughout the application.
* All colors should be defined as CSS variables in :root, and these variables
* should be used consistently across all CSS and JavaScript files.
*/
:root {
/* Primary Colors */
--primary-color: #4a6fa5; /* Main brand color */
--primary-hover: #3a5a8c; /* Darker shade for hover states */
/* Text Colors */
--text-color: #f0f0f0; /* Main text color */
--text-muted: #888; /* Secondary text, less important info */
--text-light: #999; /* Lighter text for disabled states */
--text-lighter: #bbb; /* Very light text, e.g., placeholders */
/* Background Colors */
--background: #1a1a1a; /* Main background color */
--surface: #2a2a2a; /* Surface color for cards, panels, etc. */
--code-bg: #222; /* Background for code blocks */
/* Border Colors */
--border: #444; /* Default border color */
--border-light: #555; /* Lighter border */
--border-lighter: #666; /* Even lighter border */
/* Status Colors */
--success: #2e8b57; /* Success messages, confirmations */
--warning: #ff6600; /* Warnings, important notices */
--error: #ff4444; /* Error messages, destructive actions */
--error-hover: #ff6666; /* Hover state for error buttons */
--info: #1e90ff; /* Informational messages, links */
--link-hover: #74c0fc; /* Hover state for links */
/* Transitions */
--transition: all 0.2s ease; /* Default transition */
}
/*
* Usage Examples:
*
* .button {
* background-color: var(--primary-color);
* color: var(--text-color);
* border: 1px solid var(--border);
* transition: var(--transition);
* }
*
* .button:hover {
* background-color: var(--primary-hover);
* }
*
* .error-message {
* color: var(--error);
* background-color: color-mix(in srgb, var(--error) 10%, transparent);
* border-left: 3px solid var(--error);
* }
*/
/*
* Accessibility Notes:
* - Ensure text has sufficient contrast with its background
* - Use semantic color names that describe the purpose, not the color
* - Test with color blindness simulators for accessibility
* - Maintain consistent color usage throughout the application
*/

View File

@ -0,0 +1,268 @@
/* File upload and list styles */
#user-upload-area {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
margin: 1rem 0;
cursor: pointer;
transition: all 0.2s ease-in-out;
background-color: var(--surface);
}
#user-upload-area:hover,
#user-upload-area.highlight {
border-color: var(--primary);
background-color: rgba(var(--primary-rgb), 0.05);
}
#user-upload-area p {
margin: 0;
color: var(--text-secondary);
}
#file-list {
list-style: none;
padding: 0;
margin: 1rem 0 0;
}
#file-list {
margin: 1.5rem 0;
padding: 0;
}
#file-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
background-color: var(--surface);
border-radius: 6px;
border: 1px solid var(--border);
transition: all 0.2s ease-in-out;
}
#file-list li:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
#file-list li.no-files,
#file-list li.loading-message,
#file-list li.error-message {
display: block;
text-align: center;
color: var(--text-muted);
padding: 2rem 1.5rem;
background-color: transparent;
border: 2px dashed var(--border);
margin: 1rem 0;
border-radius: 8px;
font-size: 1.1em;
}
#file-list li.loading-message {
color: var(--primary);
font-style: italic;
}
#file-list li.error-message {
color: var(--error);
border-color: var(--error);
}
#file-list li.error-message .login-link {
color: var(--primary);
text-decoration: none;
font-weight: bold;
margin-left: 0.3em;
}
#file-list li.error-message .login-link:hover {
text-decoration: underline;
}
#file-list li.no-files:hover {
background-color: rgba(var(--primary-rgb), 0.05);
border-color: var(--primary);
transform: none;
box-shadow: none;
}
.file-item {
width: 100%;
}
.file-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0; /* Allows text truncation */
}
.file-icon {
margin-right: 0.75rem;
font-size: 1.2em;
flex-shrink: 0;
}
.file-name {
color: var(--primary);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 0.5rem;
}
.file-name:hover {
text-decoration: underline;
}
.file-size {
color: var(--text-muted);
font-size: 0.85em;
margin-left: 0.5rem;
white-space: nowrap;
flex-shrink: 0;
}
.file-actions {
display: flex;
gap: 0.5rem;
margin-left: 1rem;
flex-shrink: 0;
}
.download-button,
.delete-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
border: 1px solid transparent;
}
.download-button {
background-color: var(--primary);
color: white;
}
.download-button:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.delete-button {
background-color: transparent;
color: var(--error);
border-color: var(--error);
}
.delete-button:hover {
background-color: rgba(var(--error-rgb), 0.1);
}
.button-icon {
font-size: 1em;
}
.button-text {
display: none;
}
/* Show text on larger screens */
@media (min-width: 640px) {
.button-text {
display: inline;
}
.download-button,
.delete-button {
padding: 0.4rem 1rem;
}
}
/* Responsive adjustments */
@media (max-width: 480px) {
#file-list li {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.file-actions {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
.file-name {
max-width: 100%;
}
}
#file-list li a {
color: var(--primary);
text-decoration: none;
flex-grow: 1;
margin-right: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#file-list li a:hover {
text-decoration: underline;
}
.file-size {
color: var(--text-secondary);
font-size: 0.9em;
margin-left: 0.5rem;
}
.delete-file {
background: none;
border: none;
color: var(--error);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.delete-file:hover {
background-color: rgba(var(--error-rgb), 0.1);
}
/* Loading state */
#file-list.loading {
opacity: 0.7;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
#user-upload-area {
padding: 1.5rem 1rem;
}
#file-list li {
padding: 0.5rem;
font-size: 0.9rem;
}
.file-size {
display: block;
margin-left: 0;
margin-top: 0.25rem;
}
}

View File

@ -1,7 +1,7 @@
/* Footer styles */ /* Footer styles */
footer { footer {
background: #2c3e50; background: #2c3e50;
color: #ecf0f1; color: var(--text-color);
padding: 2rem 0; padding: 2rem 0;
margin-top: 3rem; margin-top: 3rem;
width: 100%; width: 100%;
@ -26,30 +26,30 @@ footer {
} }
.footer-links a { .footer-links a {
color: #ecf0f1; color: var(--text-color);
text-decoration: none; text-decoration: none;
transition: color 0.2s; transition: color 0.2s;
} }
.footer-links a:hover, .footer-links a:hover,
.footer-links a:focus { .footer-links a:focus {
color: #3498db; color: var(--info);
text-decoration: underline; text-decoration: underline;
} }
.separator { .separator {
color: #7f8c8d; color: var(--text-muted);
margin: 0 0.25rem; margin: 0 0.25rem;
} }
.footer-hint { .footer-hint {
margin-top: 1rem; margin-top: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
color: #bdc3c7; color: var(--text-light);
} }
.footer-hint a { .footer-hint a {
color: #3498db; color: var(--info);
text-decoration: none; text-decoration: none;
} }

View File

@ -81,7 +81,7 @@ header {
.nav-link:focus { .nav-link:focus {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
text-decoration: none; text-decoration: none;
color: #fff; color: var(--text-color);
} }
/* Active navigation item */ /* Active navigation item */

54
static/css/section.css Normal file
View File

@ -0,0 +1,54 @@
/* section.css - Centralized visibility control with class-based states */
/* Base section visibility - all sections hidden by default */
main > section {
display: none;
position: absolute;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
}
/* Active section styling - only visibility properties */
main > section.active {
display: block;
position: relative;
overflow: visible;
clip: auto;
white-space: normal;
opacity: 1;
}
/* Authentication-based visibility classes */
.guest-only { display: block; }
.auth-only {
display: none;
}
/* Show auth-only elements when authenticated */
body.authenticated .auth-only {
display: block;
}
/* Ensure me-page and its direct children are visible when me-page is active */
#me-page:not([hidden]) > .auth-only,
#me-page:not([hidden]) > section,
#me-page:not([hidden]) > article,
#me-page:not([hidden]) > div,
/* Ensure account deletion section is visible when privacy page is active and user is authenticated */
#privacy-page:not([hidden]) .auth-only,
#privacy-page:not([hidden]) #account-deletion {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
body.authenticated .guest-only {
display: none;
}
.always-visible {
display: block !important;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,24 @@
/* Desktop-specific styles for screens 960px and wider */ /* Desktop-specific styles for screens 960px and wider */
@media (min-width: 960px) { @media (min-width: 960px) {
:root {
--content-max-width: 800px;
--content-padding: 1.25rem;
--section-spacing: 1.5rem;
}
html { html {
background-color: #111 !important; background-color: #111 !important;
background-image: background-image:
repeating-linear-gradient( repeating-linear-gradient(
45deg, 45deg,
rgba(188, 183, 107, 0.1) 0, /* Olive color */ rgba(188, 183, 107, 0.1) 0,
rgba(188, 183, 107, 0.1) 1px, rgba(188, 183, 107, 0.1) 1px,
transparent 1px, transparent 1px,
transparent 20px transparent 20px
), ),
repeating-linear-gradient( repeating-linear-gradient(
-45deg, -45deg,
rgba(188, 183, 107, 0.1) 0, /* Olive color */ rgba(188, 183, 107, 0.1) 0,
rgba(188, 183, 107, 0.1) 1px, rgba(188, 183, 107, 0.1) 1px,
transparent 1px, transparent 1px,
transparent 20px transparent 20px
@ -26,72 +32,200 @@
body { body {
background: transparent !important; background: transparent !important;
min-height: 100vh !important; min-height: 100vh !important;
display: flex;
flex-direction: column;
} }
/* Section styles are now handled in style.css */
/* Main content container */
main {
flex: 1;
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 var(--content-padding);
box-sizing: border-box;
}
/* Ensure h2 in legal pages matches other pages */
#privacy-page > article > h2:first-child,
#imprint-page > article > h2:first-child {
margin-top: 0;
padding-top: 0;
}
/* Streams Page Specific Styles */
#streams-page section {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 2rem;
box-sizing: border-box;
}
.stream-card {
margin-bottom: 1rem;
background: var(--surface);
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stream-card:last-child {
margin-bottom: 0;
}
.stream-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stream-card .card-content {
padding: 1.25rem 1.5rem;
}
/* Section styles */
section {
width: 100%;
max-width: var(--content-max-width);
margin: 0 auto var(--section-spacing);
background: rgba(26, 26, 26, 0.9);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
section:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
}
/* Navigation */
nav.dashboard-nav {
padding: 1rem 0;
margin-bottom: 2rem;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
display: block;
}
/* Desktop navigation visibility */
nav.dashboard-nav {
display: block;
}
/* Show desktop navigation */
section#links {
display: block;
}
/* Hide mobile navigation elements */
#burger-label,
#burger-toggle {
display: none !important;
}
/* Dashboard navigation */
#guest-dashboard,
#user-dashboard {
display: flex;
gap: 1rem;
}
nav.dashboard-nav a { nav.dashboard-nav a {
padding: 5px; padding: 0.5rem 1rem;
margin: 0 0.5em; margin: 0 0.5em;
border-radius: 3px; border-radius: 4px;
transition: background-color 0.2s ease;
} }
/* Reset mobile-specific styles for desktop */ nav.dashboard-nav a:hover {
.dashboard-nav { background-color: rgba(255, 255, 255, 0.1);
padding: 0.5em;
max-width: none;
justify-content: center;
} }
.dashboard-nav a { /* Form elements */
min-width: auto; input[type="email"],
font-size: 1rem; input[type="text"],
input[type="password"] {
width: 100%;
max-width: 400px;
padding: 0.75rem;
margin: 0.5rem 0;
border: 1px solid #444;
border-radius: 4px;
background: #2a2a2a;
color: #f0f0f0;
}
/* Buttons */
button,
.button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
background: #4a6fa5;
color: white;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover,
.button:hover {
background: #5a8ad4;
} }
/* Global article styles */ /* Global article styles */
main > section > article, main > section > article,
#stream-page > article { #stream-page > article,
#stream-page #stream-list > li .stream-player {
max-width: 600px; max-width: 600px;
margin: 0 auto 2em auto; margin: 2em auto 2em auto;
padding: 2em; padding: 2em;
background: #1e1e1e; background: #1e1e1e;
border: 1px solid #333; border: 1px solid #333;
border-radius: 8px; border-radius: 8px;
transition: all 0.2s ease; transition: all 0.2s ease;
box-sizing: border-box;
}
/* Add top margin to all stream players except the first one */
#stream-page #stream-list > li:not(:first-child) .stream-player {
margin-top: 2px;
} }
/* Stream player styles */ /* Stream player styles */
#stream-page #stream-list > li { #stream-page #stream-list > li {
list-style: none; list-style: none;
margin-bottom: 1.5em; margin: 0;
} padding: 0;
#stream-page #stream-list > li .stream-player {
padding: 1.5em;
background: #1e1e1e;
border: none; border: none;
border-radius: 8px; background: transparent;
transition: all 0.2s ease;
} }
/* Hover states - only apply to direct article children of sections */ #stream-page #stream-list {
main > section > article:hover { padding: 0;
transform: translateY(-2px); margin: 0 auto;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02)); max-width: 600px;
border: 1px solid #ff6600; width: 100%;
} }
/* Stream player specific overrides can be added here if needed in the future */
/* Hover states moved to style.css for consistency */
/* Stream list desktop styles */ /* Stream list desktop styles */
#stream-list { #stream-list {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
padding: 0 1rem;
} }
/* User upload area desktop styles */ /* User upload area - matches article styling */
#user-upload-area { #user-upload-area {
max-width: 600px !important; max-width: 600px;
width: 100% !important; width: 100%;
margin: 1.5rem auto !important; margin: 2rem auto;
box-sizing: border-box !important; box-sizing: border-box;
} }
} }

View File

@ -10,19 +10,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." /> <meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." />
<title>dicta2stream</title> <title>dicta2stream</title>
<!-- Responsive burger menu display --> <!-- Section visibility and navigation styles -->
<link rel="stylesheet" href="/static/css/section.css" media="all" />
<style> <style>
#burger-label, #burger-toggle { display: none; } /* Hide mobile menu by default on larger screens */
@media (max-width: 959px) {
#burger-label { display: block; }
section#links { display: none; }
#burger-toggle:checked + #burger-label + section#links { display: block; }
}
@media (min-width: 960px) { @media (min-width: 960px) {
section#links { display: block; } #mobile-menu { display: none !important; }
#burger-label { display: none !important; }
} }
</style> </style>
<link rel="modulepreload" href="/static/sound.js" /> <link rel="modulepreload" href="/static/sound.js" />
<script src="/static/streams-ui.js" type="module"></script>
<script src="/static/app.js" type="module"></script>
</head> </head>
<body> <body>
<header> <header>
@ -33,50 +33,54 @@
<main> <main>
<!-- Guest Dashboard --> <!-- Guest Dashboard -->
<nav id="guest-dashboard" class="dashboard-nav"> <nav id="guest-dashboard" class="dashboard-nav guest-only">
<a href="#welcome-page" id="guest-welcome">Welcome</a> <a href="#welcome-page" id="guest-welcome">Welcome</a>
<a href="#stream-page" id="guest-streams">Streams</a> <a href="#stream-page" id="guest-streams">Streams</a>
<a href="#register-page" id="guest-login">Account</a> <a href="#account" id="guest-login">Account</a>
</nav> </nav>
<!-- User Dashboard --> <!-- User Dashboard -->
<nav id="user-dashboard" class="dashboard-nav" style="display:none;"> <nav id="user-dashboard" class="dashboard-nav auth-only">
<a href="#welcome-page" id="user-welcome">Welcome</a> <a href="#welcome-page" id="user-welcome">Welcome</a>
<a href="#stream-page" id="user-streams">Streams</a> <a href="#stream-page" id="user-streams">Streams</a>
<a href="#me-page" id="show-me">Your Stream</a> <a href="#me-page" id="show-me">Your Stream</a>
</nav> </nav>
<section id="me-page"> <section id="me-page" class="auth-only">
<div style="position: relative; margin: 0 0 1.5rem 0; text-align: center;"> <div>
<h2 style="margin: 0; padding: 0; line-height: 1; display: inline-block; position: relative; text-align: center;"> <h2>Your Stream</h2>
Your Stream
</h2>
<div style="position: absolute; right: 0; top: 50%; transform: translateY(-50%); display: flex; gap: 0.5rem;">
<button id="delete-account-button" class="delete-account-btn" style="font-size: 1rem; padding: 0.4em 0.8em; white-space: nowrap; display: none; background-color: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer;">🗑️ Delete Account</button>
<button id="logout-button" class="logout-btn" style="font-size: 1rem; padding: 0.4em 0.8em; white-space: nowrap; display: none;">🚪 LogOut</button>
</div>
</div> </div>
<article> <article>
<p>This is your personal stream. Only you can upload to it.</p> <p>This is your personal stream. Only you can upload to it.</p>
<audio id="me-audio"></audio> <audio id="me-audio"></audio>
<div class="audio-controls"> <div class="audio-controls">
<button class="play-pause-btn" type="button" aria-label="Play">▶️</button> <button class="play-pause-btn" type="button" aria-label="Play" data-uid="">▶️</button>
</div> </div>
</article> </article>
<section id="user-upload-area" class="dropzone"> <section id="user-upload-area" class="auth-only">
<p>🎙 Drag & drop your audio file here<br>or click to browse</p> <p>Drag & drop your audio file here<br>or click to browse</p>
<input type="file" id="fileInputUser" accept="audio/*" hidden /> <input type="file" id="fileInputUser" accept="audio/*" hidden />
</section> </section>
<article id="log-out" class="auth-only article--bordered logout-section">
<button id="logout-button" class="button">🚪 Log Out</button>
</article>
<section id="quota-meter" class="auth-only">
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB</span></p>
<h4>Uploaded Files</h4>
<ul id="file-list" class="file-list">
<li>Loading files...</li>
</ul>
</section>
</section> </section>
<div id="spinner" class="spinner"></div> <div id="spinner" class="spinner"></div>
<!-- Burger menu and legacy links section removed for clarity --> <!-- Burger menu and legacy links section removed for clarity -->
<section id="terms-page" hidden> <section id="terms-page" class="always-visible">
<h2>Terms of Service</h2> <h2>Terms of Service</h2>
<article> <article class="article--bordered">
<p>By accessing or using dicta2stream.net (the "Service"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree, do not use the Service.</p> <p>By accessing or using dicta2stream.net (the "Service"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree, do not use the Service.</p>
<ul> <ul>
<li>You must be at least 18 years old to register.</li> <li>You must be at least 18 years old to register.</li>
@ -90,31 +94,53 @@
</article> </article>
</section> </section>
<section id="privacy-page" hidden> <section id="privacy-page" class="always-visible">
<h2>Privacy Policy</h2> <div>
<article> <h2>Privacy Policy</h2>
</div>
<article class="article--bordered">
<ul> <ul>
<li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li> <li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li>
<li><strong>Guests</strong>: No cookies are set. No persistent identifiers are stored.</li> <li><strong>Guests</strong>: No cookies are set. No persistent identifiers are stored.</li>
<li>We log IP + UID only for abuse protection and quota enforcement.</li> <li>We log IP + UID only for abuse protection and quota enforcement.</li>
<li>Uploads are scanned via Whisper+Ollama but not stored as transcripts.</li> <li>Uploads are scanned via Whisper+Ollama but not stored as transcripts.</li>
<li>Data is never sold. Contact us for account deletion.</li> <li>Data is never sold.</li>
</ul> </ul>
</article> </article>
<!-- This section will be shown only to authenticated users -->
<div class="auth-only">
<section id="account-deletion" class="article--bordered">
<h3>Account Deletion</h3>
<p>You can delete your account and all associated data at any time. This action is irreversible and will permanently remove:</p>
<ul>
<li>Your account information</li>
<li>All uploaded audio files</li>
</ul>
<div class="centered-container">
<button id="delete-account-from-privacy" class="button">
🗑️ Delete My Account
</button>
</div>
</section>
</div>
<!-- Guest login message removed as per user request -->
</section> </section>
<section id="imprint-page" hidden> <section id="imprint-page" class="always-visible">
<h2>Imprint</h2> <h2>Imprint</h2>
<article> <article class="article--bordered">
<p><strong>Andreas Michael Fleckl</strong></p> <p><strong>Andreas Michael Fleckl</strong></p>
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p> <p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
</article> </article>
</section> </section>
<section id="welcome-page"> <section id="welcome-page" class="always-visible">
<h2>Welcome</h2> <h2>Welcome</h2>
<article> <article class="article--bordered">
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <br><br> <p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <span class="text-muted">(Opus | Mono | 48kHz | 60kbps)</span><br><br>
<strong>What you can do here:</strong></p> <strong>What you can do here:</strong></p>
<ul> <ul>
<li>🎧 Listen to public voice streams from others, instantly</li> <li>🎧 Listen to public voice streams from others, instantly</li>
@ -122,51 +148,41 @@
<li>🕵️ No sign-up required for listening</li> <li>🕵️ No sign-up required for listening</li>
<li>🔒 Optional registration for uploading and managing your own stream</li> <li>🔒 Optional registration for uploading and managing your own stream</li>
</ul> </ul>
<div class="email-section">
<a href="mailto:Andreas.Fleckl@dicta2stream.net" class="button">
Andreas.Fleckl@dicta2stream.net
</a>
</div>
</article> </article>
</section> </section>
<section id="stream-page" hidden> <section id="stream-page" class="always-visible">
<h2>Public Streams</h2> <h2>Public Streams</h2>
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching --> <!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
<ul id="stream-list"><li>Loading...</li></ul> <ul id="stream-list"><li>Loading...</li></ul>
</section> </section>
<section id="register-page" hidden> <section id="register-page" class="guest-only">
<h2>Account</h2> <h2>Account</h2>
<article> <article class="article--wide">
<form id="register-form"> <form id="register-form">
<p><label>Email<br><input type="email" name="email" required /></label></p> <p><label>Email<br><input type="email" name="email" required /></label></p>
<p><label>Username<br><input type="text" name="user" required /></label></p> <p><label>Username<br><input type="text" name="user" required /></label></p>
<p style="display: none;"> <p class="bot-trap">
<label>Leave this empty:<br> <label>Leave this empty:<br>
<input type="text" name="bot_trap" autocomplete="off" /> <input type="text" name="bot_trap" autocomplete="off" />
</label> </label>
</p> </p>
<p><button type="submit">Login / Create Account</button></p> <p><button type="submit">Login / Create Account</button></p>
</form> </form>
<p><small>Youll receive a magic login link via email. No password required.</small></p> <p class="form-note">You'll receive a magic login link via email. No password required.</p>
<p style="font-size: 0.85em; opacity: 0.65; margin-top: 1em;">Your session expires after 1 hour. Shareable links redirect to homepage.</p> <p class="form-note session-note">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
</article> </article>
</section> </section>
<section id="quota-meter" hidden>
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB used</span></p>
<div id="uploaded-files" style="margin-top: 10px; font-size: 0.9em;">
<div style="font-weight: bold; margin-bottom: 5px;">Uploaded Files:</div>
<div id="file-list" style="max-height: 200px; overflow-y: auto; border: 1px solid #333; padding: 5px; border-radius: 4px; background: #1a1a1a;">
<div style="padding: 5px 0; color: #888; font-style: italic;">Loading files...</div>
</div>
</div>
</section>
</main> </main>
<footer> <footer>
<p>Built for public voice streaming • Opus | Mono | 48kHz | 60kbps</p>
<p class="footer-hint">Need more space? Contact<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<p class="footer-links"> <p class="footer-links">
<a href="#" id="footer-terms" data-target="terms-page">Terms</a> | <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-privacy" data-target="privacy-page">Privacy</a> |
@ -200,5 +216,6 @@
} }
} }
</script> </script>
<script type="module" src="/static/init-personal-stream.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,35 @@
// Initialize the personal stream play button with the user's UID
document.addEventListener('DOMContentLoaded', () => {
// Function to update the play button with UID
function updatePersonalStreamPlayButton() {
const playButton = document.querySelector('#me-page .play-pause-btn');
if (!playButton) return;
// Get UID from localStorage or cookie
const uid = localStorage.getItem('uid') || getCookie('uid');
if (uid) {
// Set the data-uid attribute if not already set
if (!playButton.dataset.uid) {
playButton.dataset.uid = uid;
console.log('[personal-stream] Set UID for personal stream play button:', uid);
}
} else {
console.warn('[personal-stream] No UID found for personal stream play button');
}
}
// Helper function to get cookie value by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Initial update
updatePersonalStreamPlayButton();
// Also update when auth state changes (e.g., after login)
document.addEventListener('authStateChanged', updatePersonalStreamPlayButton);
});

View File

@ -1,136 +1,55 @@
// inject-nav.js - Handles dynamic injection and management of navigation elements // inject-nav.js - Handles dynamic injection and management of navigation elements
import { showOnly } from './router.js'; import { showOnly } from './router.js';
// Menu state // Function to set up guest navigation links
let isMenuOpen = false; function setupGuestNav() {
const guestDashboard = document.getElementById('guest-dashboard');
if (!guestDashboard) return;
// Export the injectNavigation function const links = guestDashboard.querySelectorAll('a');
export function injectNavigation(isAuthenticated = false) { links.forEach(link => {
console.log('injectNavigation called with isAuthenticated:', isAuthenticated); link.addEventListener('click', (e) => {
const navContainer = document.getElementById('main-navigation');
if (!navContainer) {
console.error('Navigation container not found. Looking for #main-navigation');
console.log('Available elements with id:', document.querySelectorAll('[id]'));
return;
}
// Clear existing content
navContainer.innerHTML = '';
console.log('Creating navigation...');
try {
// Create the navigation wrapper
const navWrapper = document.createElement('nav');
navWrapper.className = 'nav-wrapper';
// Create the navigation content
const nav = isAuthenticated ? createUserNav() : createGuestNav();
console.log('Navigation HTML created:', nav.outerHTML);
// Append navigation to wrapper
navWrapper.appendChild(nav);
// Append to container
navContainer.appendChild(navWrapper);
console.log('Navigation appended to container');
// Initialize menu toggle after navigation is injected
setupMenuToggle();
// Set up menu links
setupMenuLinks();
// Add click handler for the logo to navigate home
const logo = document.querySelector('.logo');
if (logo) {
logo.addEventListener('click', (e) => {
e.preventDefault();
showOnly('welcome');
closeMenu();
});
}
} catch (error) {
console.error('Error creating navigation:', error);
return;
}
// Set up menu toggle for mobile
setupMenuToggle();
// Set up menu links
setupMenuLinks();
// Close menu when clicking on a nav link on mobile
const navLinks = navContainer.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', () => {
if (window.innerWidth < 768) { // Mobile breakpoint
closeMenu();
}
});
});
// Add click handler for the logo to navigate home
const logo = document.querySelector('.logo');
if (logo) {
logo.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
showOnly('welcome'); const target = link.getAttribute('href')?.substring(1); // Remove '#'
});
}
}
// Function to create the guest navigation
function createGuestNav() {
const nav = document.createElement('div');
nav.className = 'dashboard-nav';
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'Main navigation');
const navList = document.createElement('ul');
navList.className = 'nav-list';
const links = [
{ id: 'nav-login', target: 'login', text: 'Login / Register' },
{ id: 'nav-streams', target: 'streams', text: 'Streams' },
{ id: 'nav-welcome', target: 'welcome', text: 'Welcome' }
];
// 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 = `#${link.target}`;
a.className = 'nav-link';
a.setAttribute('data-target', link.target);
a.textContent = link.text;
// Add click handler for navigation
a.addEventListener('click', (e) => {
e.preventDefault();
const target = e.currentTarget.getAttribute('data-target');
if (target) { if (target) {
window.location.hash = target; window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') { if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target); window.router.showOnly(target);
} }
// Close menu on mobile after clicking a link
if (window.innerWidth < 768) {
closeMenu();
}
} }
}); });
li.appendChild(a);
navList.appendChild(li);
}); });
}
nav.appendChild(navList); // Function to set up user navigation links
return nav; 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() { function createUserNav() {
@ -150,7 +69,7 @@ function createUserNav() {
]; ];
// Create and append links // Create and append links
links.forEach((link, index) => { links.forEach((link) => {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'nav-item'; li.className = 'nav-item';
@ -158,35 +77,24 @@ function createUserNav() {
a.id = link.id; a.id = link.id;
a.href = '#'; a.href = '#';
a.className = 'nav-link'; a.className = 'nav-link';
a.setAttribute('data-target', link.target);
// Special handling for logout
if (link.target === 'logout') {
a.href = '#';
a.addEventListener('click', async (e) => {
e.preventDefault();
closeMenu();
// Use the handleLogout function from dashboard.js if available
if (typeof handleLogout === 'function') {
await handleLogout();
} else {
// Fallback in case handleLogout is not available
localStorage.removeItem('user');
localStorage.removeItem('uid');
localStorage.removeItem('uid_time');
localStorage.removeItem('confirmed_uid');
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
window.location.href = '/';
}
window.location.href = '#';
// Force reload to reset the app state
window.location.reload();
});
} else {
a.setAttribute('data-target', link.target);
}
a.textContent = link.text; 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); li.appendChild(a);
navList.appendChild(li); navList.appendChild(li);
}); });
@ -195,243 +103,82 @@ function createUserNav() {
return nav; return nav;
} }
// Set up menu toggle functionality
function setupMenuToggle() {
const menuToggle = document.querySelector('.menu-toggle');
const navWrapper = document.querySelector('.nav-wrapper');
if (!menuToggle || !navWrapper) return;
menuToggle.addEventListener('click', toggleMenu);
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (isMenuOpen && !navWrapper.contains(e.target) && !menuToggle.contains(e.target)) {
closeMenu();
}
});
// Close menu on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isMenuOpen) {
closeMenu();
}
});
// Close menu when resizing to desktop
let resizeTimer;
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) {
closeMenu();
}
});
}
// Toggle mobile menu
function toggleMenu(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const navWrapper = document.querySelector('.nav-wrapper');
const menuToggle = document.querySelector('.menu-toggle');
if (!navWrapper || !menuToggle) return;
isMenuOpen = !isMenuOpen;
if (isMenuOpen) {
// Open menu
navWrapper.classList.add('active');
menuToggle.setAttribute('aria-expanded', 'true');
menuToggle.innerHTML = '✕';
document.body.style.overflow = 'hidden';
// Focus the first link in the menu for better keyboard navigation
const firstLink = navWrapper.querySelector('a');
if (firstLink) firstLink.focus();
// Add click outside handler
document._handleClickOutside = (e) => {
if (!navWrapper.contains(e.target) && e.target !== menuToggle) {
closeMenu();
}
};
document.addEventListener('click', document._handleClickOutside);
// Add escape key handler
document._handleEscape = (e) => {
if (e.key === 'Escape') {
closeMenu();
}
};
document.addEventListener('keydown', document._handleEscape);
} else {
closeMenu();
}
}
// Close menu function
function closeMenu() {
const navWrapper = document.querySelector('.nav-wrapper');
const menuToggle = document.querySelector('.menu-toggle');
if (!navWrapper || !menuToggle) return;
isMenuOpen = false;
navWrapper.classList.remove('active');
menuToggle.setAttribute('aria-expanded', 'false');
menuToggle.innerHTML = '☰';
document.body.style.overflow = '';
// Remove event listeners
if (document._handleClickOutside) {
document.removeEventListener('click', document._handleClickOutside);
delete document._handleClickOutside;
}
if (document._handleEscape) {
document.removeEventListener('keydown', document._handleEscape);
delete document._handleEscape;
}
}
// Initialize menu toggle on page load
function initializeMenuToggle() {
console.log('Initializing menu toggle...');
const menuToggle = document.getElementById('menu-toggle');
if (!menuToggle) {
console.error('Main menu toggle button not found!');
return;
}
console.log('Menu toggle button found:', menuToggle);
// Remove any existing click listeners
const newToggle = menuToggle.cloneNode(true);
if (menuToggle.parentNode) {
menuToggle.parentNode.replaceChild(newToggle, menuToggle);
console.log('Replaced menu toggle button');
} else {
console.error('Menu toggle has no parent node!');
return;
}
// Add click handler to the new toggle
newToggle.addEventListener('click', function(event) {
console.log('Menu toggle clicked!', event);
event.preventDefault();
event.stopPropagation();
toggleMenu(event);
return false;
});
// Also handle the header menu toggle if it exists
const headerMenuToggle = document.getElementById('header-menu-toggle');
if (headerMenuToggle) {
console.log('Header menu toggle found, syncing with main menu');
headerMenuToggle.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
newToggle.click(); // Trigger the main menu toggle
return false;
});
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM fully loaded and parsed');
// Initialize navigation based on authentication state
// This will be set by the main app after checking auth status
if (window.initializeNavigation) {
window.initializeNavigation();
}
// Initialize menu toggle
initializeMenuToggle();
// Also try to initialize after a short delay in case the DOM changes
setTimeout(initializeMenuToggle, 500);
});
// Navigation injection function // Navigation injection function
export function injectNavigation(isAuthenticated = false) { export function injectNavigation(isAuthenticated = false) {
console.log('Injecting navigation, isAuthenticated:', isAuthenticated); // Get the appropriate dashboard element based on auth state
const container = document.getElementById('main-navigation'); const guestDashboard = document.getElementById('guest-dashboard');
const navWrapper = document.querySelector('.nav-wrapper'); const userDashboard = document.getElementById('user-dashboard');
if (!container || !navWrapper) { if (isAuthenticated) {
console.error('Navigation elements not found. Looking for #main-navigation and .nav-wrapper'); // Show user dashboard, hide guest dashboard
return null; 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');
} }
try { // Set up menu links and active state
// Store scroll position setupMenuLinks();
const scrollPosition = window.scrollY; updateActiveNav();
// Clear existing navigation return isAuthenticated ? userDashboard : guestDashboard;
container.innerHTML = '';
// Create the appropriate navigation based on authentication status
const nav = isAuthenticated ? createUserNav() : createGuestNav();
// Append the navigation to the container
container.appendChild(nav);
// Set up menu toggle functionality
setupMenuToggle();
// Set up navigation links
setupMenuLinks();
// Show the appropriate page based on URL
if (window.location.hash === '#streams' || window.location.pathname === '/streams') {
showOnly('stream-page');
if (typeof window.maybeLoadStreamsOnShow === 'function') {
window.maybeLoadStreamsOnShow();
}
} else if (!window.location.hash || window.location.hash === '#') {
// Show welcome page by default if no hash
showOnly('welcome-page');
}
// Restore scroll position
window.scrollTo(0, scrollPosition);
return nav;
} catch (error) {
console.error('Error injecting navigation:', error);
return null;
}
} }
// Set up menu links with click handlers // Set up menu links with click handlers
function setupMenuLinks() { function setupMenuLinks() {
// Handle navigation link clicks // Set up guest and user navigation links
document.addEventListener('click', function(e) { setupGuestNav();
// Check if click is on a nav link or its children setupUserNav();
let link = e.target.closest('.nav-link');
if (!link) return;
const target = link.getAttribute('data-target'); // Handle hash changes for SPA navigation
if (target) { window.addEventListener('hashchange', updateActiveNav);
e.preventDefault(); }
console.log('Navigation link clicked:', target);
showOnly(target);
closeMenu();
// Update active state // Update active navigation link
document.querySelectorAll('.nav-link').forEach(l => { function updateActiveNav() {
l.classList.remove('active'); 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'); 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 // Make the function available globally for debugging
window.injectNavigation = injectNavigation; window.injectNavigation = injectNavigation;

View File

@ -27,10 +27,17 @@ export async function initMagicLogin() {
const url = new URL(res.url); const url = new URL(res.url);
const confirmedUid = url.searchParams.get('confirmed_uid'); const confirmedUid = url.searchParams.get('confirmed_uid');
if (confirmedUid) { if (confirmedUid) {
document.cookie = "uid=" + encodeURIComponent(confirmedUid) + "; path=/"; // Generate a simple auth token (in a real app, this would come from the server)
// Set localStorage for SPA session logic instantly const authToken = 'token-' + Math.random().toString(36).substring(2, 15);
// Set cookies and localStorage for SPA session logic
document.cookie = `uid=${encodeURIComponent(confirmedUid)}; path=/`;
document.cookie = `authToken=${authToken}; path=/`;
// Store in localStorage for client-side access
localStorage.setItem('uid', confirmedUid); localStorage.setItem('uid', confirmedUid);
localStorage.setItem('confirmed_uid', confirmedUid); localStorage.setItem('confirmed_uid', confirmedUid);
localStorage.setItem('authToken', authToken);
localStorage.setItem('uid_time', Date.now().toString()); localStorage.setItem('uid_time', Date.now().toString());
} }
window.location.href = res.url; window.location.href = res.url;
@ -42,10 +49,17 @@ export async function initMagicLogin() {
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
data = await res.json(); data = await res.json();
if (data && data.confirmed_uid) { if (data && data.confirmed_uid) {
document.cookie = "uid=" + encodeURIComponent(data.confirmed_uid) + "; path=/"; // Generate a simple auth token (in a real app, this would come from the server)
// Set localStorage for SPA session logic const authToken = 'token-' + Math.random().toString(36).substring(2, 15);
// Set cookies and localStorage for SPA session logic
document.cookie = `uid=${encodeURIComponent(data.confirmed_uid)}; path=/`;
document.cookie = `authToken=${authToken}; path=/`;
// Store in localStorage for client-side access
localStorage.setItem('uid', data.confirmed_uid); localStorage.setItem('uid', data.confirmed_uid);
localStorage.setItem('confirmed_uid', data.confirmed_uid); localStorage.setItem('confirmed_uid', data.confirmed_uid);
localStorage.setItem('authToken', authToken);
localStorage.setItem('uid_time', Date.now().toString()); localStorage.setItem('uid_time', Date.now().toString());
import('./toast.js').then(({ showToast }) => { import('./toast.js').then(({ showToast }) => {
showToast('✅ Login successful!'); showToast('✅ Login successful!');

View File

@ -36,6 +36,145 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* Mobile navigation */
#user-dashboard.dashboard-nav {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.dashboard-nav {
display: flex;
justify-content: space-around;
padding: 0.5rem 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
margin-bottom: 1rem;
}
.dashboard-nav a {
padding: 0.5rem 0.25rem;
text-align: center;
font-size: 0.9rem;
color: var(--text-color);
text-decoration: none;
flex: 1;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.dashboard-nav a:hover,
.dashboard-nav a:focus {
background-color: var(--hover-bg);
outline: none;
}
/* Account Deletion Section */
#privacy-page.active #account-deletion,
#privacy-page:not(.active) #account-deletion {
display: block !important;
opacity: 1 !important;
position: relative !important;
clip: auto !important;
width: auto !important;
height: auto !important;
margin: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
.account-deletion-section {
margin: 2rem 0;
padding: 1.75rem;
background: rgba(26, 26, 26, 0.8);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
.account-deletion-section h3 {
color: #fff;
font-size: 1.5rem;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.account-deletion-section h3 {
color: #fff;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.account-deletion-section ul {
margin: 1.5rem 0 2rem 1.5rem;
padding-left: 0.5rem;
}
.account-deletion-section li {
margin-bottom: 0.75rem;
color: #f0f0f0;
line-height: 1.5;
position: relative;
padding-left: 1.5rem;
}
.account-deletion-section li:before {
content: '•';
color: #ff5e57;
font-weight: bold;
font-size: 1.5rem;
position: absolute;
left: 0;
top: -0.25rem;
}
.danger-button {
background: linear-gradient(135deg, #ff3b30, #ff5e57);
color: white;
border: none;
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
width: 100%;
max-width: 300px;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(255, 59, 48, 0.3);
text-align: center;
}
.danger-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 59, 48, 0.4);
}
.danger-button:active {
transform: translateY(0);
}
.text-link {
color: #4dabf7;
text-decoration: none;
transition: color 0.2s ease;
}
.text-link:hover {
color: #74c0fc;
text-decoration: underline;
}
/* Hide desktop navigation in mobile */
nav.dashboard-nav {
display: none;
}
header { header {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
@ -98,15 +237,22 @@
} }
#quota-meter { #quota-meter {
margin: 1rem 0; max-width: 600px;
width: 100%;
margin: 1rem auto;
padding: 0 1rem;
box-sizing: border-box;
} }
.quota-meter { .quota-meter {
height: 20px; height: 20px;
} }
/* Stream item styles moved to .stream-player */
.stream-item { .stream-item {
padding: 0.75rem; padding: 0;
margin: 0;
border: none;
} }
.modal-content { .modal-content {
@ -183,8 +329,8 @@
nav.dashboard-nav a { nav.dashboard-nav a {
all: unset; all: unset;
display: inline-block; display: inline-block;
background-color: #1e1e1e; background-color: var(--surface);
color: #fff; color: var(--text-color);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
margin: 0 0.25rem; margin: 0 0.25rem;
border-radius: 4px; border-radius: 4px;
@ -197,7 +343,7 @@
} }
.dashboard-nav a:active { .dashboard-nav a:active {
background-color: #333; background-color: var(--border);
} }
/* Stream page specific styles */ /* Stream page specific styles */
@ -215,11 +361,19 @@
} }
#stream-list { #stream-list {
padding: 0.5rem; padding: 0 1rem;
margin: 0 auto;
max-width: 600px;
width: 100%;
box-sizing: border-box;
} }
#stream-list li { #stream-list li {
margin-bottom: 1rem; margin: 0;
padding: 0;
border: none;
background: transparent;
list-style: none;
} }
.stream-player { .stream-player {
@ -234,18 +388,22 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
#stream-list > li { /* Stream list items are now handled by the rules above */
margin-bottom: 1.5rem;
}
/* User upload area */ /* User upload area - matches article styling */
#user-upload-area { #user-upload-area {
margin: 1rem 0; margin: 2rem auto;
padding: 1.5rem; padding: 1.6875rem;
background: var(--surface);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
border: 2px dashed #666; max-width: 600px;
border-radius: 8px; width: 100%;
box-sizing: border-box;
color: var(--text-color);
} }
#user-upload-area p { #user-upload-area p {
@ -268,7 +426,7 @@
.stream-info { .stream-info {
font-size: 0.9rem; font-size: 0.9rem;
color: #aaa; color: var(--text-muted);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -277,14 +435,31 @@
} }
/* Form elements */ /* Form elements */
input[type="text"], input[type="text"],
input[type="email"], input[type="email"],
input[type="password"], input[type="password"],
textarea { textarea {
width: 100%; width: 100%;
max-width: 100%;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
padding: 0.75rem; padding: 0.75rem;
margin: 0.5rem 0; margin: 0.5rem 0;
font-size: 1rem; font-size: 1rem;
border-radius: 4px;
border: 1px solid #444;
background-color: #2a2a2a;
color: #f0f0f0;
}
/* Firefox mobile specific fixes */
@-moz-document url-prefix() {
input[type="email"] {
min-height: 2.5rem;
appearance: none;
}
} }
/* Adjust audio element for mobile */ /* Adjust audio element for mobile */

View File

@ -8,62 +8,267 @@ function getCookie(name) {
} }
document.addEventListener("DOMContentLoaded", () => { 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}`);
} else {
document.body.classList.add('page-welcome');
}
// Update active state of navigation links
document.querySelectorAll('.dashboard-nav a').forEach(link => {
link.classList.remove('active');
if ((!sectionId && link.getAttribute('href') === '#welcome-page') ||
(sectionId && link.getAttribute('href') === `#${sectionId}`)) {
link.classList.add('active');
}
});
mainSections.forEach(section => {
// Skip navigation sections
if (section.id === 'guest-dashboard' || section.id === 'user-dashboard') {
return;
}
const isTarget = section.id === sectionId;
const isLegalPage = ['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId);
const isWelcomePage = !sectionId || sectionId === 'welcome-page';
if (isTarget || (isLegalPage && section.id === sectionId)) {
// Show the target section or legal page
section.classList.add('active');
section.hidden = false;
// Focus the section for accessibility with a small delay
// Only focus if the section is focusable and in the viewport
const focusSection = () => {
try {
if (section && typeof section.focus === 'function' &&
section.offsetParent !== null && // Check if element is visible
section.getBoundingClientRect().top < window.innerHeight &&
section.getBoundingClientRect().bottom > 0) {
section.focus({ preventScroll: true });
}
} catch (e) {
// Silently fail if focusing isn't possible
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
console.debug('Could not focus section:', e);
}
}
};
// Use requestAnimationFrame for better performance
requestAnimationFrame(() => {
// Only set the timeout in debug mode or local development
if (window.DEBUG_NAV || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
setTimeout(focusSection, 50);
} else {
focusSection();
}
});
} else if (isWelcomePage && section.id === 'welcome-page') {
// Special handling for welcome page
section.classList.add('active');
section.hidden = false;
} else {
// Hide other sections
section.classList.remove('active');
section.hidden = true;
}
});
// Update URL hash without page scroll
if (sectionId && !['terms-page', 'privacy-page', 'imprint-page'].includes(sectionId)) {
if (sectionId === 'welcome-page') {
history.replaceState(null, '', window.location.pathname);
} else {
history.replaceState(null, '', `#${sectionId}`);
}
}
};
// Handle initial page load
const getValidSection = (sectionId) => {
const protectedSections = ['me-page', 'register-page'];
// If not logged in and trying to access protected section
if (!isLoggedIn && protectedSections.includes(sectionId)) {
return 'welcome-page';
}
// If section doesn't exist, default to welcome page
if (!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 = { const Router = {
sections: Array.from(document.querySelectorAll("main > section")), sections: Array.from(document.querySelectorAll("main > section")),
showOnly(id) { showOnly(id) {
// Define which sections are part of the 'Your Stream' section // Validate the section ID
const yourStreamSections = ['me-page', 'register-page', 'quota-meter']; const validId = getValidSection(id);
const isYourStreamSection = yourStreamSections.includes(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' // Handle the quota meter visibility - only show with 'me-page'
const quotaMeter = document.getElementById('quota-meter'); const quotaMeter = document.getElementById('quota-meter');
if (quotaMeter) { if (quotaMeter) {
quotaMeter.hidden = id !== 'me-page'; quotaMeter.hidden = validId !== 'me-page';
quotaMeter.tabIndex = id === 'me-page' ? 0 : -1; quotaMeter.tabIndex = validId === 'me-page' ? 0 : -1;
} }
// Check if user is logged in // Update navigation active states
const isLoggedIn = !!getCookie('uid'); this.updateActiveNav(validId);
// Handle all sections
this.sections.forEach(sec => {
// Skip quota meter as it's already handled
if (sec.id === 'quota-meter') return;
// Special handling for register page - only show to guests
if (sec.id === 'register-page') {
sec.hidden = isLoggedIn || id !== 'register-page';
sec.tabIndex = (!isLoggedIn && id === 'register-page') ? 0 : -1;
return;
}
// Show the section if it matches the target ID
// OR if it's a 'Your Stream' section and we're in a 'Your Stream' context
const isSectionInYourStream = yourStreamSections.includes(sec.id);
const shouldShow = (sec.id === id) ||
(isYourStreamSection && isSectionInYourStream);
sec.hidden = !shouldShow;
sec.tabIndex = shouldShow ? 0 : -1;
});
// 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 = (id === "me-page" && uid) ? '' : 'none';
}
localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
}, },
init() {
initNavLinks();
initBackButtons();
initStreamLinks(); 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');
}
});
} }
}; };
const showOnly = Router.showOnly.bind(Router);
// 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) {
// Show the target section without updating URL hash
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 // Highlight active profile link on browser back/forward navigation
function highlightActiveProfileLink() { function highlightActiveProfileLink() {
@ -80,6 +285,15 @@ document.addEventListener("DOMContentLoaded", () => {
window.addEventListener('popstate', () => { window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const profileUid = params.get('profile'); 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) { if (profileUid) {
showOnly('me-page'); showOnly('me-page');
if (typeof window.showProfilePlayerFromUrl === 'function') { if (typeof window.showProfilePlayerFromUrl === 'function') {
@ -227,5 +441,13 @@ document.addEventListener("DOMContentLoaded", () => {
} }
// Initialize Router // 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(); Router.init();
}); });

View File

@ -1,15 +1,168 @@
// static/router.js — core routing for SPA navigation // static/router.js — core routing for SPA navigation
export const Router = { export const Router = {
sections: Array.from(document.querySelectorAll("main > section")), 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) { showOnly(id) {
this.sections.forEach(sec => { if (!id) return;
sec.hidden = sec.id !== id;
sec.tabIndex = -1; // 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); localStorage.setItem("last_page", id);
const target = document.getElementById(id);
if (target) target.focus();
} }
}; };
// Initialize router when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
Router.init();
});
export const showOnly = Router.showOnly.bind(Router); export const showOnly = Router.showOnly.bind(Router);

View File

@ -65,17 +65,26 @@ function loadAndRenderStreams() {
let streams = []; let streams = [];
let connectionTimeout = null; let connectionTimeout = null;
// Close previous connection and clear any pending timeouts // Clean up previous connection and timeouts
if (window._streamsSSE) { if (window._streamsSSE) {
console.log('[streams-ui] Aborting previous connection'); console.group('[streams-ui] Cleaning up previous connection');
console.log('Previous connection exists, aborting...');
if (window._streamsSSE.abort) { if (window._streamsSSE.abort) {
window._streamsSSE.abort(); window._streamsSSE.abort();
console.log('Previous connection aborted');
} else {
console.log('No abort method on previous connection');
} }
window._streamsSSE = null; window._streamsSSE = null;
console.groupEnd();
} }
if (connectionTimeout) { if (connectionTimeout) {
console.log('[streams-ui] Clearing previous connection timeout');
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
connectionTimeout = null; connectionTimeout = null;
} else {
console.log('[streams-ui] No previous connection timeout to clear');
} }
console.log(`[streams-ui] Creating fetch-based SSE connection to ${sseUrl}`); console.log(`[streams-ui] Creating fetch-based SSE connection to ${sseUrl}`);
@ -87,14 +96,41 @@ function loadAndRenderStreams() {
// Store the controller for cleanup // Store the controller for cleanup
window._streamsSSE = controller; window._streamsSSE = controller;
// Set a connection timeout // Set a connection timeout with debug info
connectionTimeout = setTimeout(() => { const connectionStartTime = Date.now();
const connectionTimeoutId = setTimeout(() => {
if (!gotAny) { if (!gotAny) {
console.log('[streams-ui] Connection timeout reached, forcing retry...'); // Only log in development (localhost) or if explicitly enabled
const isLocalDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
if (isLocalDevelopment || window.DEBUG_STREAMS) {
const duration = Date.now() - connectionStartTime;
console.group('[streams-ui] Connection timeout reached');
console.log(`Duration: ${duration}ms`);
console.log('Current time:', new Date().toISOString());
console.log('Streams received:', streams.length);
console.log('Active intervals:', window.activeIntervals ? window.activeIntervals.size : 'N/A');
console.log('Active timeouts:', window.activeTimeouts ? window.activeTimeouts.size : 'N/A');
console.groupEnd();
}
// Clean up and retry with backoff
controller.abort(); controller.abort();
loadAndRenderStreams();
// Only retry if we haven't exceeded max retries
const retryCount = window.streamRetryCount || 0;
if (retryCount < 3) { // Max 3 retries
window.streamRetryCount = retryCount + 1;
const backoffTime = Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
setTimeout(loadAndRenderStreams, backoffTime);
} else if (process.env.NODE_ENV === 'development' || window.DEBUG_STREAMS) {
console.warn('Max retries reached for stream loading');
}
} }
}, 10000); // 10 second timeout }, 15000); // 15 second timeout (increased from 10s)
// Store the timeout ID for cleanup
connectionTimeout = connectionTimeoutId;
console.log('[streams-ui] Making fetch request to:', sseUrl); console.log('[streams-ui] Making fetch request to:', sseUrl);
@ -102,7 +138,7 @@ function loadAndRenderStreams() {
console.log('[streams-ui] Creating fetch request with URL:', sseUrl); console.log('[streams-ui] Creating fetch request with URL:', sseUrl);
// Make the fetch request // Make the fetch request with proper error handling
fetch(sseUrl, { fetch(sseUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -112,7 +148,6 @@ function loadAndRenderStreams() {
}, },
credentials: 'same-origin', credentials: 'same-origin',
signal: signal, signal: signal,
// Add mode and redirect options for better error handling
mode: 'cors', mode: 'cors',
redirect: 'follow' redirect: 'follow'
}) })
@ -200,44 +235,49 @@ function loadAndRenderStreams() {
return reader.read().then(processStream); return reader.read().then(processStream);
}) })
.catch(error => { .catch(error => {
console.error('[streams-ui] Fetch request failed:', error); // Only handle the error if it's not an AbortError (from our own abort)
if (error.name === 'AbortError') {
console.log('[streams-ui] Request was aborted as expected');
return;
}
console.error('[streams-ui] Stream loading failed:', error);
// Log additional error details // Log additional error details
if (error.name === 'TypeError') { if (error.name === 'TypeError') {
console.error('[streams-ui] This is likely a network error or CORS issue'); console.error('[streams-ui] This is likely a network error or CORS issue');
if (error.message.includes('fetch')) {
console.error('[streams-ui] The fetch request was blocked or failed to reach the server');
}
if (error.message.includes('CORS')) {
console.error('[streams-ui] CORS error detected. Check server CORS configuration');
}
} }
if (error.name === 'AbortError') { // Show a user-friendly error message
console.log('[streams-ui] Request was aborted'); const ul = document.getElementById('stream-list');
} else { if (ul) {
console.error('[streams-ui] Error details:', { let errorMessage = 'Error loading streams. ';
name: error.name,
message: error.message,
stack: error.stack,
constructor: error.constructor.name,
errorCode: error.code,
errorNumber: error.errno,
response: error.response
});
// Show a user-friendly error message if (error.message.includes('Failed to fetch')) {
const ul = document.getElementById('stream-list'); errorMessage += 'Unable to connect to the server. Please check your internet connection.';
if (ul) { } else if (error.message.includes('CORS')) {
ul.innerHTML = ` errorMessage += 'A server configuration issue occurred. Please try again later.';
<li class="error"> } else {
<p>Error loading streams. Please try again later.</p> errorMessage += 'Please try again later.';
<p><small>Technical details: ${error.name}: ${error.message}</small></p>
</li>
`;
} }
handleSSEError(error); ul.innerHTML = `
<li class="error">
<p>${errorMessage}</p>
<button id="retry-loading" class="retry-button">
<span class="retry-icon">↻</span> Try Again
</button>
</li>
`;
// Add retry handler
const retryButton = document.getElementById('retry-loading');
if (retryButton) {
retryButton.addEventListener('click', () => {
ul.innerHTML = '<li>Loading streams...</li>';
loadAndRenderStreams();
});
}
} }
}); });
@ -279,7 +319,7 @@ function loadAndRenderStreams() {
<div class="audio-controls"> <div class="audio-controls">
<button class="play-pause-btn" data-uid="${escapeHtml(uid)}" aria-label="Play">▶️</button> <button class="play-pause-btn" data-uid="${escapeHtml(uid)}" aria-label="Play">▶️</button>
</div> </div>
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p> <p class="stream-info" style='color:var(--text-muted);font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
</article> </article>
`; `;
ul.appendChild(li); ul.appendChild(li);
@ -288,7 +328,7 @@ function loadAndRenderStreams() {
console.error(`[streams-ui] Error rendering stream ${uid}:`, error); console.error(`[streams-ui] Error rendering stream ${uid}:`, error);
const errorLi = document.createElement('li'); const errorLi = document.createElement('li');
errorLi.textContent = `Error loading stream: ${uid}`; errorLi.textContent = `Error loading stream: ${uid}`;
errorLi.style.color = 'red'; errorLi.style.color = 'var(--error)';
ul.appendChild(errorLi); ul.appendChild(errorLi);
} }
}); });
@ -352,7 +392,7 @@ export function renderStreamList(streams) {
const uid = stream.uid || ''; const uid = stream.uid || '';
const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?'; const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?';
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toLocaleString() : ''; const mtime = stream.mtime ? new Date(stream.mtime * 1000).toLocaleString() : '';
return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:gray;font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`; return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:var(--text-muted);font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`;
}) })
.join(''); .join('');
} else { } else {
@ -704,50 +744,55 @@ function cleanupAudio() {
} }
} }
// Event delegation for play/pause buttons // Event delegation for play/pause buttons - only handle buttons within the stream list
document.addEventListener('click', async (e) => { const streamList = document.getElementById('stream-list');
const playPauseBtn = e.target.closest('.play-pause-btn'); if (streamList) {
if (!playPauseBtn) return; streamList.addEventListener('click', async (e) => {
const playPauseBtn = e.target.closest('.play-pause-btn');
// Skip if not a play button or if it's the personal stream's play button
if (!playPauseBtn || playPauseBtn.closest('#me-page')) return;
// Prevent default to avoid any potential form submission or link following // Prevent event from bubbling up to document-level handlers
e.preventDefault(); e.stopPropagation();
e.stopPropagation(); e.stopImmediatePropagation();
e.preventDefault();
const uid = playPauseBtn.dataset.uid; const uid = playPauseBtn.dataset.uid;
if (!uid) { if (!uid) {
console.error('No UID found for play button'); console.error('No UID found for play button');
return; return;
}
console.log(`[streams-ui] Play/pause clicked for UID: ${uid}, currentUid: ${currentUid}, isPlaying: ${isPlaying}`);
// If clicking the currently playing button, toggle pause/play
if (currentUid === uid) {
if (isPlaying) {
console.log('[streams-ui] Pausing current audio');
await audioElement.pause();
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
} else {
console.log('[streams-ui] Resuming current audio');
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
} catch (error) {
console.error('[streams-ui] Error resuming audio:', error);
// If resume fails, try reloading the audio
await loadAndPlayAudio(uid, playPauseBtn);
}
} }
return;
}
// If a different stream is playing, stop it and start the new one console.log(`[streams-ui] Play/pause clicked for UID: ${uid}, currentUid: ${currentUid}, isPlaying: ${isPlaying}`);
console.log(`[streams-ui] Switching to new audio stream: ${uid}`);
stopPlayback(); // If clicking the currently playing button, toggle pause/play
await loadAndPlayAudio(uid, playPauseBtn); if (currentUid === uid) {
}); if (isPlaying) {
console.log('[streams-ui] Pausing current audio');
await audioElement.pause();
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
} else {
console.log('[streams-ui] Resuming current audio');
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
} catch (error) {
console.error('[streams-ui] Error resuming audio:', error);
// If resume fails, try reloading the audio
await loadAndPlayAudio(uid, playPauseBtn);
}
}
return;
}
// If a different stream is playing, stop it and start the new one
console.log(`[streams-ui] Switching to new audio stream: ${uid}`);
stopPlayback();
await loadAndPlayAudio(uid, playPauseBtn);
});
}
// Handle audio end event to update button state // Handle audio end event to update button state
document.addEventListener('play', (e) => { document.addEventListener('play', (e) => {

File diff suppressed because it is too large Load Diff

View File

@ -5,25 +5,43 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title> <title>Audio Player Test</title>
<style> <style>
:root {
--success: #2e8b57;
--error: #ff4444;
--border: #444;
--text-color: #f0f0f0;
--surface: #2a2a2a;
}
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
line-height: 1.6; line-height: 1.6;
background: #1a1a1a;
color: var(--text-color);
} }
.test-case { .test-case {
margin-bottom: 20px; margin-bottom: 20px;
padding: 15px; padding: 15px;
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 5px; border-radius: 5px;
background: var(--surface);
} }
.success { color: green; } .success { color: var(--success); }
.error { color: red; } .error { color: var(--error); }
button { button {
padding: 8px 16px; padding: 8px 16px;
margin: 5px; margin: 5px;
cursor: pointer; cursor: pointer;
background: #4a6fa5;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #3a5a8c;
} }
#log { #log {
margin-top: 20px; margin-top: 20px;

View File

@ -5,25 +5,43 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title> <title>Audio Player Test</title>
<style> <style>
:root {
--success: #2e8b57;
--error: #ff4444;
--border: #444;
--text-color: #f0f0f0;
--surface: #2a2a2a;
}
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
line-height: 1.6; line-height: 1.6;
background: #1a1a1a;
color: var(--text-color);
} }
.test-case { .test-case {
margin-bottom: 20px; margin-bottom: 20px;
padding: 15px; padding: 15px;
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 5px; border-radius: 5px;
background: var(--surface);
} }
.success { color: green; } .success { color: var(--success); }
.error { color: red; } .error { color: var(--error); }
button { button {
padding: 8px 16px; padding: 8px 16px;
margin: 5px; margin: 5px;
cursor: pointer; cursor: pointer;
background: #4a6fa5;
color: white;
border: none;
border-radius: 4px;
}
button:hover {
background: #3a5a8c;
} }
#log { #log {
margin-top: 20px; margin-top: 20px;

View File

@ -112,8 +112,10 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
// Function to fetch and display uploaded files // Function to fetch and display uploaded files
async function fetchAndDisplayFiles(uid) { async function fetchAndDisplayFiles(uidFromParam) {
console.log('[DEBUG] fetchAndDisplayFiles called with uid:', uid); console.log('[UPLOAD] fetchAndDisplayFiles called with uid:', uidFromParam);
// Get the file list element
const fileList = document.getElementById('file-list'); const fileList = document.getElementById('file-list');
if (!fileList) { if (!fileList) {
const errorMsg = 'File list element not found in DOM'; const errorMsg = 'File list element not found in DOM';
@ -121,12 +123,47 @@ document.addEventListener('DOMContentLoaded', () => {
return showErrorInUI(errorMsg); return showErrorInUI(errorMsg);
} }
// Get UID from parameter, localStorage, or cookie
const uid = uidFromParam || localStorage.getItem('uid') || getCookie('uid');
const authToken = localStorage.getItem('authToken');
const headers = {
'Accept': 'application/json',
};
// Include auth token in headers if available, but don't fail if it's not
// The server should handle both token-based and UID-based auth
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
} else {
console.debug('[UPLOAD] No auth token available, using UID-only authentication');
}
console.log('[UPLOAD] Auth state - UID:', uid, 'Token exists:', !!authToken);
if (!uid) {
console.error('[UPLOAD] No UID found in any source');
fileList.innerHTML = '<li class="error-message">User session expired. Please refresh the page.</li>';
return;
}
// Log the authentication method being used
if (!authToken) {
console.debug('[UPLOAD] No auth token found, using UID-only authentication');
} else {
console.debug('[UPLOAD] Using token-based authentication');
}
// Show loading state // Show loading state
fileList.innerHTML = '<div style="padding: 10px; color: #888; font-style: italic;">Loading files...</div>'; fileList.innerHTML = '<li class="loading-message">Loading files...</li>';
try { try {
console.log(`[DEBUG] Fetching files for user: ${uid}`); console.log(`[DEBUG] Fetching files for user: ${uid}`);
const response = await fetch(`/me/${uid}`); const response = await fetch(`/me/${uid}`, {
headers: {
'Authorization': authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json',
},
});
console.log('[DEBUG] Response status:', response.status, response.statusText); console.log('[DEBUG] Response status:', response.status, response.statusText);
if (!response.ok) { if (!response.ok) {
@ -152,21 +189,63 @@ document.addEventListener('DOMContentLoaded', () => {
const displayName = file.original_name || file.name; const displayName = file.original_name || file.name;
const isRenamed = file.original_name && file.original_name !== file.name; const isRenamed = file.original_name && file.original_name !== file.name;
return ` return `
<div style="display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid #2a2a2a;"> <li class="file-item" data-filename="${file.name}">
<div style="flex: 1; min-width: 0;"> <div class="file-name" title="${displayName}">
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${displayName}"> ${displayName}
${displayName} ${isRenamed ? `<div class="stored-as" title="Stored as: ${file.name}">${file.name} <button class="delete-file" data-filename="${file.name}" title="Delete file">🗑️</button></div>` :
${isRenamed ? `<div style="font-size: 0.8em; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="Stored as: ${file.name}">${file.name}</div>` : ''} `<button class="delete-file" data-filename="${file.name}" title="Delete file">🗑️</button>`}
</div>
</div> </div>
<span style="color: #888; white-space: nowrap; margin-left: 10px;">${sizeMB} MB</span> <span class="file-size">${sizeMB} MB</span>
</div> </li>
`; `;
}).join(''); }).join('');
} else { } else {
fileList.innerHTML = '<div style="padding: 5px 0; color: #888; font-style: italic;">No files uploaded yet</div>'; fileList.innerHTML = '<li class="empty-message">No files uploaded yet</li>';
} }
// Add event listeners to delete buttons
document.querySelectorAll('.delete-file').forEach(button => {
button.addEventListener('click', async (e) => {
e.stopPropagation();
const filename = button.dataset.filename;
if (confirm(`Are you sure you want to delete ${filename}?`)) {
try {
// Get the auth token from the cookie
const token = document.cookie
.split('; ')
.find(row => row.startsWith('sessionid='))
?.split('=')[1];
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(`/delete/${filename}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to delete file: ${response.statusText}`);
}
// Refresh the file list
const uid = document.body.dataset.userUid;
if (uid) {
fetchAndDisplayFiles(uid);
}
} catch (error) {
console.error('Error deleting file:', error);
alert('Failed to delete file. Please try again.');
}
}
});
});
// Update quota display if available // Update quota display if available
if (data.quota !== undefined) { if (data.quota !== undefined) {
const bar = document.getElementById('quota-bar'); const bar = document.getElementById('quota-bar');
@ -176,7 +255,7 @@ document.addEventListener('DOMContentLoaded', () => {
quotaSec.hidden = false; quotaSec.hidden = false;
bar.value = data.quota; bar.value = data.quota;
bar.max = 100; bar.max = 100;
text.textContent = `${data.quota.toFixed(1)} MB used`; text.textContent = `${data.quota.toFixed(1)} MB`;
} }
} }
} catch (error) { } catch (error) {
@ -193,15 +272,15 @@ document.addEventListener('DOMContentLoaded', () => {
margin: 5px 0; margin: 5px 0;
background: #2a0f0f; background: #2a0f0f;
border-left: 3px solid #f55; border-left: 3px solid #f55;
color: #ff9999; color: var(--error-hover);
font-family: monospace; font-family: monospace;
font-size: 0.9em; font-size: 0.9em;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
"> ">
<div style="font-weight: bold; color: #f55;">Error loading files</div> <div style="font-weight: bold; color: var(--error);">Error loading files</div>
<div style="margin-top: 5px;">${message}</div> <div style="margin-top: 5px;">${message}</div>
<div style="margin-top: 10px; font-size: 0.8em; color: #888;"> <div style="margin-top: 10px; font-size: 0.8em; color: var(--text-muted);">
Check browser console for details Check browser console for details
</div> </div>
</div> </div>
@ -217,6 +296,14 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
// Helper function to get cookie value by name
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
// Export functions for use in other modules // Export functions for use in other modules
window.upload = upload; window.upload = upload;
window.fetchAndDisplayFiles = fetchAndDisplayFiles; window.fetchAndDisplayFiles = fetchAndDisplayFiles;

View File

@ -6,15 +6,17 @@ from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from pathlib import Path from pathlib import Path
from convert_to_opus import convert_to_opus from convert_to_opus import convert_to_opus
from database import get_db
from models import UploadLog, UserQuota, User from models import UploadLog, UserQuota, User
from sqlalchemy import select from sqlalchemy import select
from database import get_db
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
router = APIRouter() router = APIRouter()
# # Not needed for SlowAPI ≥0.1.5 # # Not needed for SlowAPI ≥0.1.5
DATA_ROOT = Path("./data") DATA_ROOT = Path("./data")
@limiter.limit("5/minute") @limiter.limit("5/minute")
@router.post("/upload") @router.post("/upload")
async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), file: UploadFile = Form(...)): async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), file: UploadFile = Form(...)):