diff --git a/deletefile.py b/deletefile.py new file mode 100644 index 0000000..711621f --- /dev/null +++ b/deletefile.py @@ -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" + ) diff --git a/main.py b/main.py index b9b09f0..9dfa99b 100644 --- a/main.py +++ b/main.py @@ -36,7 +36,19 @@ from fastapi.requests import Request as FastAPIRequest from fastapi.exception_handlers import RequestValidationError 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 --- 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) if not user: print(f"[ERROR] User with UID {uid} not found") - raise HTTPException(status_code=403, detail="User not found") - - if user.ip != request.client.host: - print(f"[ERROR] IP mismatch for UID {uid}: {request.client.host} != {user.ip}") - raise HTTPException(status_code=403, detail="IP address mismatch") + raise HTTPException(status_code=404, detail="User not found") + + # Only enforce IP check in production + if not debug_mode: + 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 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") return response_data - except Exception as e: - print(f"[ERROR] Error in /me/{uid} endpoint: {str(e)}", exc_info=True) + except HTTPException: + # Re-raise HTTP exceptions as they are 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") diff --git a/public_streams.txt b/public_streams.txt index 46633cf..59b6010 100644 --- a/public_streams.txt +++ b/public_streams.txt @@ -1,3 +1,4 @@ -{"uid":"devuser","size":22455090,"mtime":1747563720} -{"uid":"oib9","size":2019706,"mtime":1751124547} +{"uid":"devuser","size":65551721,"mtime":1752752391} +{"uid":"oib9","size":12735117,"mtime":1752843762} +{"uid":"oibchello","size":1549246,"mtime":1752840918} {"uid":"orangeicebear","size":1734396,"mtime":1748767975} diff --git a/static/app.js b/static/app.js index 194b80a..e7d0980 100644 --- a/static/app.js +++ b/static/app.js @@ -2,6 +2,7 @@ import { playBeep } from "./sound.js"; import { showToast } from "./toast.js"; +import { injectNavigation } from "./inject-nav.js"; // Global audio state let globalAudio = null; @@ -30,29 +31,42 @@ function handleMagicLoginRedirect() { const params = new URLSearchParams(window.location.search); if (params.get('login') === 'success' && params.get('confirmed_uid')) { const username = params.get('confirmed_uid'); + console.log('Magic link login detected for user:', username); + + // Update authentication state localStorage.setItem('uid', username); localStorage.setItem('confirmed_uid', username); localStorage.setItem('uid_time', Date.now().toString()); document.cookie = `uid=${encodeURIComponent(username)}; path=/`; - // Update UI state immediately without reload - const guestDashboard = document.getElementById('guest-dashboard'); - const userDashboard = document.getElementById('user-dashboard'); - const registerPage = document.getElementById('register-page'); + // Update UI state + document.body.classList.add('authenticated'); + document.body.classList.remove('guest'); - if (guestDashboard) guestDashboard.style.display = 'none'; - if (userDashboard) userDashboard.style.display = 'block'; - if (registerPage) registerPage.style.display = 'none'; + // Update local storage and cookies + localStorage.setItem('isAuthenticated', 'true'); + document.cookie = `isAuthenticated=true; path=/`; // Update URL and history without reloading window.history.replaceState({}, document.title, window.location.pathname); + // Update navigation + if (typeof injectNavigation === 'function') { + console.log('Updating navigation after magic link login'); + injectNavigation(true); + } else { + console.warn('injectNavigation function not available after magic link login'); + } + // Navigate to user's profile page if (window.showOnly) { + console.log('Navigating to me-page'); window.showOnly('me-page'); } else if (window.location.hash !== '#me') { window.location.hash = '#me'; } + + // Auth state will be updated by the polling mechanism } } @@ -298,55 +312,116 @@ function showProfilePlayerFromUrl() { } 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 => { - link.addEventListener('click', (e) => { - 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; - } - }); + link.addEventListener('click', handleNavClick); }); + + // Handle initial page load and hash changes + const handleHashChange = () => { + let hash = window.location.hash.substring(1); + + // Map URL hashes to section IDs if they don't match exactly + const sectionMap = { + 'welcome': 'welcome-page', + 'streams': 'stream-page', + 'account': 'register-page', + 'login': 'login-page', + 'me': 'me-page' + }; + + // Use mapped section ID or the hash as is + const sectionId = sectionMap[hash] || hash || 'welcome-page'; + const targetSection = document.getElementById(sectionId); + + if (targetSection) { + // Hide all sections + document.querySelectorAll('main > section').forEach(section => { + section.hidden = section.id !== sectionId; + }); + + // Show target section + targetSection.hidden = false; + targetSection.scrollIntoView({ behavior: 'smooth' }); + + // Update active state of navigation links + navLinks.forEach(link => { + const linkHref = link.getAttribute('href'); + // Match both the exact hash and the mapped section ID + link.classList.toggle('active', + linkHref === `#${hash}` || + linkHref === `#${sectionId}` || + link.getAttribute('data-target') === sectionId || + link.getAttribute('data-target') === hash + ); + }); + + // Special handling for streams page + if (sectionId === 'stream-page' && typeof window.maybeLoadStreamsOnShow === 'function') { + window.maybeLoadStreamsOnShow(); + } + } else { + console.warn(`Section with ID '${sectionId}' not found`); + } + }; + + // Listen for hash changes + window.addEventListener('hashchange', handleHashChange); + + // Handle initial page load + handleHashChange(); } function initProfilePlayer() { @@ -354,14 +429,344 @@ function initProfilePlayer() { 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 document.addEventListener("DOMContentLoaded", () => { + // Set up authentication state monitoring + setupAuthStatePolling(); + // Handle magic link redirect if needed handleMagicLoginRedirect(); // Initialize components initNavigation(); + // Initialize profile player after a short delay setTimeout(() => { initProfilePlayer(); @@ -453,26 +858,27 @@ document.addEventListener("DOMContentLoaded", () => { // Set up delete account button if it exists const deleteAccountBtn = document.getElementById('delete-account'); - if (deleteAccountBtn) { - deleteAccountBtn.addEventListener('click', async (e) => { - e.preventDefault(); + const deleteAccountFromPrivacyBtn = document.getElementById('delete-account-from-privacy'); + + const deleteAccount = async (e) => { + if (e) e.preventDefault(); + + if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) { + return; + } + + try { + const response = await fetch('/api/delete-account', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }); - if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) { - return; - } - - try { - const response = await fetch('/api/delete-account', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - } - }); - - if (response.ok) { - // Clear local storage and redirect to home page - localStorage.clear(); - window.location.href = '/'; + if (response.ok) { + // Clear local storage and redirect to home page + localStorage.clear(); + window.location.href = '/'; } else { const error = await response.json(); throw new Error(error.detail || 'Failed to delete account'); @@ -481,12 +887,48 @@ document.addEventListener("DOMContentLoaded", () => { console.error('Error deleting 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 }); +// 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 window.logToServer = logToServer; window.getMainAudio = () => globalAudio; diff --git a/static/css/colors.css b/static/css/colors.css new file mode 100644 index 0000000..265a2a5 --- /dev/null +++ b/static/css/colors.css @@ -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 + */ diff --git a/static/css/components/file-upload.css b/static/css/components/file-upload.css new file mode 100644 index 0000000..dc95409 --- /dev/null +++ b/static/css/components/file-upload.css @@ -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; + } +} diff --git a/static/css/layout/footer.css b/static/css/layout/footer.css index 8def7f8..46ca031 100644 --- a/static/css/layout/footer.css +++ b/static/css/layout/footer.css @@ -1,7 +1,7 @@ /* Footer styles */ footer { background: #2c3e50; - color: #ecf0f1; + color: var(--text-color); padding: 2rem 0; margin-top: 3rem; width: 100%; @@ -26,30 +26,30 @@ footer { } .footer-links a { - color: #ecf0f1; + color: var(--text-color); text-decoration: none; transition: color 0.2s; } .footer-links a:hover, .footer-links a:focus { - color: #3498db; + color: var(--info); text-decoration: underline; } .separator { - color: #7f8c8d; + color: var(--text-muted); margin: 0 0.25rem; } .footer-hint { margin-top: 1rem; font-size: 0.9rem; - color: #bdc3c7; + color: var(--text-light); } .footer-hint a { - color: #3498db; + color: var(--info); text-decoration: none; } diff --git a/static/css/layout/header.css b/static/css/layout/header.css index eb9d305..497b46a 100644 --- a/static/css/layout/header.css +++ b/static/css/layout/header.css @@ -81,7 +81,7 @@ header { .nav-link:focus { background: rgba(255, 255, 255, 0.1); text-decoration: none; - color: #fff; + color: var(--text-color); } /* Active navigation item */ diff --git a/static/css/section.css b/static/css/section.css new file mode 100644 index 0000000..453aafb --- /dev/null +++ b/static/css/section.css @@ -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; +} diff --git a/static/dashboard.js b/static/dashboard.js index d60a19a..2c75e7e 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -1,5 +1,6 @@ import { showToast } from "./toast.js"; +// Utility function to get cookie value by name function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); @@ -8,7 +9,7 @@ function getCookie(name) { } // dashboard.js — toggle guest vs. user dashboard and reposition streams link -// Logout function +// Global state let isLoggingOut = false; async function handleLogout(event) { @@ -31,8 +32,10 @@ async function handleLogout(event) { localStorage.removeItem('confirmed_uid'); localStorage.removeItem('last_page'); - // Clear cookie - document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + // Clear session cookie with SameSite attribute to match how it was set + const isLocalhost = window.location.hostname === 'localhost'; + const secureFlag = !isLocalhost ? '; Secure' : ''; + document.cookie = `sessionid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=Lax${secureFlag};`; // Update UI state immediately const userDashboard = document.getElementById('user-dashboard'); @@ -134,6 +137,31 @@ async function handleDeleteAccount() { } } +// Debug function to check element visibility and styles +function debugElementVisibility(elementId) { + const el = document.getElementById(elementId); + if (!el) { + console.error(`[DEBUG] Element ${elementId} not found`); + return {}; + } + const style = window.getComputedStyle(el); + return { + id: elementId, + exists: true, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + hidden: el.hidden, + classList: Array.from(el.classList), + parentDisplay: el.parentElement ? window.getComputedStyle(el.parentElement).display : 'no-parent', + parentVisibility: el.parentElement ? window.getComputedStyle(el.parentElement).visibility : 'no-parent', + rect: el.getBoundingClientRect() + }; +} + +/** + * Initialize the dashboard and handle authentication state + */ async function initDashboard() { console.log('[DASHBOARD] Initializing dashboard...'); @@ -143,14 +171,7 @@ async function initDashboard() { const userUpload = document.getElementById('user-upload-area'); const logoutButton = document.getElementById('logout-button'); const deleteAccountButton = document.getElementById('delete-account-button'); - - console.log('[DASHBOARD] Elements found:', { - guestDashboard: !!guestDashboard, - userDashboard: !!userDashboard, - userUpload: !!userUpload, - logoutButton: !!logoutButton, - deleteAccountButton: !!deleteAccountButton - }); + const fileList = document.getElementById('file-list'); // Add click event listeners for logout and delete account buttons if (logoutButton) { @@ -165,41 +186,146 @@ async function initDashboard() { handleDeleteAccount(); }); } - - const uid = getCookie('uid'); - console.log('[DASHBOARD] UID from cookie:', uid); - // Guest view - if (!uid) { - console.log('[DASHBOARD] No UID found, showing guest dashboard'); + // Check authentication state - consolidated to avoid duplicate declarations + const hasAuthCookie = document.cookie.includes('isAuthenticated=true'); + const hasUidCookie = document.cookie.includes('uid='); + const hasLocalStorageAuth = localStorage.getItem('isAuthenticated') === 'true'; + const hasAuthToken = localStorage.getItem('authToken') !== null; + const isAuthenticated = hasAuthCookie || hasUidCookie || hasLocalStorageAuth || hasAuthToken; + + // Debug authentication state + console.log('[AUTH] Authentication state:', { + hasAuthCookie, + hasUidCookie, + hasLocalStorageAuth, + hasAuthToken, + isAuthenticated, + cookies: document.cookie, + localStorage: { + isAuthenticated: localStorage.getItem('isAuthenticated'), + uid: localStorage.getItem('uid'), + authToken: localStorage.getItem('authToken') ? 'present' : 'not present' + }, + bodyClasses: document.body.className + }); + + // Handle authenticated user + if (isAuthenticated) { + console.log('[DASHBOARD] User is authenticated, showing user dashboard'); + if (userDashboard) userDashboard.style.display = 'block'; + if (userUpload) userUpload.style.display = 'block'; + if (guestDashboard) guestDashboard.style.display = 'none'; + + // Add authenticated class to body if not present + document.body.classList.add('authenticated'); + + // Get UID from cookies or localStorage + let uid = getCookie('uid') || localStorage.getItem('uid'); + + if (!uid) { + console.warn('[DASHBOARD] No UID found in cookies or localStorage'); + // Try to get UID from the URL or hash fragment + const urlParams = new URLSearchParams(window.location.search); + uid = urlParams.get('uid') || window.location.hash.substring(1); + + if (uid) { + console.log(`[DASHBOARD] Using UID from URL/hash: ${uid}`); + localStorage.setItem('uid', uid); + } else { + console.error('[DASHBOARD] No UID available for file listing'); + if (fileList) { + fileList.innerHTML = ` +
  • + Error: Could not determine user account. Please log in again. +
  • `; + } + return; + } + } + + // Initialize file listing if we have a UID + if (window.fetchAndDisplayFiles) { + console.log(`[DASHBOARD] Initializing file listing for UID: ${uid}`); + try { + await window.fetchAndDisplayFiles(uid); + } catch (error) { + console.error('[DASHBOARD] Error initializing file listing:', error); + if (fileList) { + fileList.innerHTML = ` +
  • + Error loading files: ${error.message || 'Unknown error'}. + Please log in again. +
  • `; + } + } + } + } else { + // Guest view + console.log('[DASHBOARD] User not authenticated, showing guest dashboard'); if (guestDashboard) guestDashboard.style.display = 'block'; if (userDashboard) userDashboard.style.display = 'none'; if (userUpload) userUpload.style.display = 'none'; - if (logoutButton) logoutButton.style.display = 'none'; - if (deleteAccountButton) deleteAccountButton.style.display = 'none'; - const mePage = document.getElementById('me-page'); - if (mePage) mePage.style.display = 'none'; + + // Remove authenticated class if present + document.body.classList.remove('authenticated'); + + // Show login prompt + if (fileList) { + fileList.innerHTML = ` +
  • + Please log in to view your files. +
  • `; + } + } + + // Log authentication details for debugging + console.log('[DASHBOARD] Authentication details:', { + uid: getCookie('uid') || localStorage.getItem('uid'), + cookies: document.cookie, + localStorage: { + uid: localStorage.getItem('uid'), + isAuthenticated: localStorage.getItem('isAuthenticated'), + authToken: localStorage.getItem('authToken') ? 'present' : 'not present' + } + }); + + // If not authenticated, show guest view and return early + if (!isAuthenticated) { + console.log('[DASHBOARD] User not authenticated, showing guest dashboard'); + if (guestDashboard) guestDashboard.style.display = 'block'; + if (userDashboard) userDashboard.style.display = 'none'; + if (userUpload) userUpload.style.display = 'none'; + + // Remove authenticated class if present + document.body.classList.remove('authenticated'); + + // Show login prompt + if (fileList) { + fileList.innerHTML = ` +
  • + Please log in to view your files. +
  • `; + } return; } - // Logged-in view - show user dashboard by default + // Logged-in view - show user dashboard console.log('[DASHBOARD] User is logged in, showing user dashboard'); - // Log current display states - console.log('[DASHBOARD] Current display states:', { + // Get all page elements + const mePage = document.getElementById('me-page'); + + // Log current display states for debugging + console.log('[DASHBOARD] Updated display states:', { guestDashboard: guestDashboard ? window.getComputedStyle(guestDashboard).display : 'not found', userDashboard: userDashboard ? window.getComputedStyle(userDashboard).display : 'not found', userUpload: userUpload ? window.getComputedStyle(userUpload).display : 'not found', logoutButton: logoutButton ? window.getComputedStyle(logoutButton).display : 'not found', - deleteAccountButton: deleteAccountButton ? window.getComputedStyle(deleteAccountButton).display : 'not found' + deleteAccountButton: deleteAccountButton ? window.getComputedStyle(deleteAccountButton).display : 'not found', + mePage: mePage ? window.getComputedStyle(mePage).display : 'not found' }); - // Show delete account button for logged-in users - if (deleteAccountButton) { - deleteAccountButton.style.display = 'block'; - console.log('[DASHBOARD] Showing delete account button'); - } - // Hide guest dashboard if (guestDashboard) { console.log('[DASHBOARD] Hiding guest dashboard'); @@ -213,6 +339,14 @@ async function initDashboard() { userDashboard.style.visibility = 'visible'; userDashboard.hidden = false; + // Log final visibility state after changes + console.log('[DEBUG] Final visibility state after showing user dashboard:', { + userDashboard: debugElementVisibility('user-dashboard'), + guestDashboard: debugElementVisibility('guest-dashboard'), + computedDisplay: window.getComputedStyle(userDashboard).display, + computedVisibility: window.getComputedStyle(userDashboard).visibility + }); + // Debug: Check if the element is actually in the DOM console.log('[DASHBOARD] User dashboard parent:', userDashboard.parentElement); console.log('[DASHBOARD] User dashboard computed display:', window.getComputedStyle(userDashboard).display); @@ -234,73 +368,115 @@ async function initDashboard() { } // Show me-page for logged-in users - const mePage = document.getElementById('me-page'); if (mePage) { console.log('[DASHBOARD] Showing me-page'); mePage.style.display = 'block'; } try { - console.log(`[DEBUG] Fetching user data for UID: ${uid}`); - const res = await fetch(`/me/${uid}`); - if (!res.ok) { - const errorText = await res.text(); - console.error(`[ERROR] Failed to fetch user data: ${res.status} ${res.statusText}`, errorText); - throw new Error(`HTTP ${res.status}: ${res.statusText}`); - } - const data = await res.json(); - console.log('[DEBUG] User data loaded:', data); + // Try to get UID from various sources + let uid = getCookie('uid') || localStorage.getItem('uid'); - // Ensure upload area is visible if last_page was me-page - if (userUpload && localStorage.getItem('last_page') === 'me-page') { - // userUpload visibility is now only controlled by nav.js SPA logic - } + // If we have a valid UID, try to fetch user data + if (uid && uid !== 'welcome-page' && uid !== 'undefined' && uid !== 'null') { + console.log('[DASHBOARD] Found valid UID:', uid); + console.log(`[DEBUG] Fetching user data for UID: ${uid}`); + const response = await fetch(`/me/${uid}`); + if (!response.ok) { + const errorText = await response.text(); + console.error(`[ERROR] Failed to fetch user data: ${response.status} ${response.statusText}`, errorText); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Parse and handle the response data + const data = await response.json(); + console.log('[DEBUG] User data loaded:', data); + + // Ensure upload area is visible if last_page was me-page + if (userUpload && localStorage.getItem('last_page') === 'me-page') { + // userUpload visibility is now only controlled by nav.js SPA logic + } - // Remove guest warning if present - const guestMsg = document.getElementById('guest-warning-msg'); - if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg); - // Show user dashboard and logout button - if (userDashboard) userDashboard.style.display = ''; - if (logoutButton) { - logoutButton.style.display = 'block'; - logoutButton.onclick = handleLogout; - } + // Remove guest warning if present + const guestMsg = document.getElementById('guest-warning-msg'); + if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg); + + // Show user dashboard and logout button + if (userDashboard) userDashboard.style.display = ''; + if (logoutButton) { + logoutButton.style.display = 'block'; + logoutButton.onclick = handleLogout; + } - // Set audio source - const meAudio = document.getElementById('me-audio'); - if (meAudio && data && data.username) { - // Use username instead of UID for the audio file path - meAudio.src = `/audio/${encodeURIComponent(data.username)}/stream.opus?t=${Date.now()}`; - console.log('Setting audio source to:', meAudio.src); - } else if (meAudio && uid) { - // Fallback to UID if username is not available - meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; - console.warn('Using UID fallback for audio source:', meAudio.src); - } + // Set audio source + const meAudio = document.getElementById('me-audio'); + const username = data?.username || ''; + + if (meAudio) { + if (username) { + // Use username for the audio file path if available + meAudio.src = `/audio/${encodeURIComponent(username)}/stream.opus?t=${Date.now()}`; + console.log('Setting audio source to:', meAudio.src); + } else if (uid) { + // Fallback to UID if username is not available + meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`; + console.warn('Using UID fallback for audio source:', meAudio.src); + } + } - // Update quota and ensure quota meter is visible - const quotaMeter = document.getElementById('quota-meter'); - const quotaBar = document.getElementById('quota-bar'); - const quotaText = document.getElementById('quota-text'); - if (quotaBar) quotaBar.value = data.quota; - if (quotaText) quotaText.textContent = `${data.quota} MB used`; - if (quotaMeter) { - quotaMeter.hidden = false; - quotaMeter.style.display = 'block'; // Ensure it's not hidden by display:none - } - - // Fetch and display the list of uploaded files if the function is available - if (window.fetchAndDisplayFiles) { - window.fetchAndDisplayFiles(uid); + // Update quota and ensure quota meter is visible if data is available + const quotaMeter = document.getElementById('quota-meter'); + const quotaBar = document.getElementById('quota-bar'); + const quotaText = document.getElementById('quota-text'); + + if (quotaBar && data.quota !== undefined) { + quotaBar.value = data.quota; + } + + if (quotaText && data.quota !== undefined) { + quotaText.textContent = `${data.quota} MB`; + } + + if (quotaMeter) { + quotaMeter.hidden = false; + quotaMeter.style.display = 'block'; // Ensure it's not hidden by display:none + } + + // Fetch and display the list of uploaded files if the function is available + if (window.fetchAndDisplayFiles) { + console.log('[DASHBOARD] Calling fetchAndDisplayFiles with UID:', uid); + // Ensure we have the most up-to-date UID from the response data if available + const effectiveUid = data?.uid || uid; + console.log('[DASHBOARD] Using effective UID:', effectiveUid); + window.fetchAndDisplayFiles(effectiveUid); + } else { + console.error('[DASHBOARD] fetchAndDisplayFiles function not found!'); + } + } else { + // No valid UID found, ensure we're in guest mode + console.log('[DASHBOARD] No valid UID found, showing guest dashboard'); + userDashboard.style.display = 'none'; + guestDashboard.style.display = 'block'; + userUpload.style.display = 'none'; + document.body.classList.remove('authenticated'); + return; // Exit early for guest users } // Ensure Streams link remains in nav, not moved // (No action needed if static) } catch (e) { - console.warn('Dashboard init error, treating as guest:', e); + console.warn('Dashboard init error, falling back to guest mode:', e); - userUpload.style.display = ''; + // Ensure guest UI is shown + userUpload.style.display = 'none'; userDashboard.style.display = 'none'; + if (guestDashboard) guestDashboard.style.display = 'block'; + + // Update body classes + document.body.classList.remove('authenticated'); + document.body.classList.add('guest-mode'); + + // Ensure navigation is in correct state const registerLink = document.getElementById('guest-login'); const streamsLink = document.getElementById('guest-streams'); if (registerLink && streamsLink) { @@ -309,45 +485,417 @@ async function initDashboard() { } } -document.addEventListener('DOMContentLoaded', initDashboard); +// Function to delete a file - only define if not already defined +if (typeof window.deleteFile === 'undefined') { + window.deleteFile = async function(uid, fileName, listItem) { + if (!confirm(`Are you sure you want to delete "${fileName}"? This cannot be undone.`)) { + return; + } -// Registration form handler for guests -// Handles the submit event on #register-form, sends data to /register, and alerts the user with the result + try { + console.log(`[FILES] Deleting file: ${fileName} for user: ${uid}`); + + const response = await fetch(`/delete-file/${uid}/${encodeURIComponent(fileName)}`, { + method: 'DELETE', + credentials: 'include', + headers: { + 'Accept': 'application/json' + } + }); + + console.log('[FILES] Delete response:', response.status, response.statusText); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[FILES] Delete error:', errorText); + showToast(`Error deleting file: ${response.statusText}`, 'error'); + return; + } + + // Remove the file from the UI + if (listItem && listItem.parentNode) { + listItem.parentNode.removeChild(listItem); + } + + showToast('File deleted successfully', 'success'); + + // If no files left, show "no files" message + const fileList = document.getElementById('file-list'); + if (fileList && fileList.children.length === 0) { + fileList.innerHTML = '
  • No files uploaded yet.
  • '; + } + + } catch (error) { + console.error('[FILES] Error deleting file:', error); + showToast('Error deleting file. Please try again.', 'error'); + } + }; // Close the function expression +} // Close the if statement +// Function to fetch and display user's uploaded files +async function fetchAndDisplayFiles(uid) { + const fileList = document.getElementById('file-list'); + + if (!fileList) { + console.error('[FILES] File list element not found'); + return; + } + + console.log(`[FILES] Fetching files for user: ${uid}`); + fileList.innerHTML = '
  • Loading your files...
  • '; + + // Prepare headers with auth token if available + const authToken = localStorage.getItem('authToken'); + const headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + console.log('[FILES] Making request to /me with headers:', headers); + + try { + // The backend should handle authentication via session cookies + // We include the auth token in headers if available, but don't rely on it for auth + console.log('[FILES] Making request to /me with credentials...'); + const response = await fetch('/me', { + method: 'GET', + credentials: 'include', // Important: include cookies for session auth + headers: headers + }); + + console.log('[FILES] Response status:', response.status); + console.log('[FILES] Response headers:', Object.fromEntries([...response.headers.entries()])); + + // Get response as text first to handle potential JSON parsing errors + const responseText = await response.text(); + console.log('[FILES] Raw response text:', responseText); + + // Parse the JSON response + let responseData = {}; + if (responseText && responseText.trim() !== '') { + try { + responseData = JSON.parse(responseText); + console.log('[FILES] Successfully parsed JSON response:', responseData); + } catch (e) { + console.error('[FILES] Failed to parse JSON response. Response text:', responseText); + console.error('[FILES] Error details:', e); + + // If we have a non-JSON response but the status is 200, try to handle it + if (response.ok) { + console.warn('[FILES] Non-JSON response with 200 status, treating as empty response'); + } else { + throw new Error(`Invalid JSON response from server: ${e.message}`); + } + } + } else { + console.log('[FILES] Empty response received, using empty object'); + } + + // Note: Authentication is handled by the parent component + // We'll just handle the response status without clearing auth state + + if (response.ok) { + // Check if the response has the expected format + if (!responseData || !Array.isArray(responseData.files)) { + console.error('[FILES] Invalid response format, expected {files: [...]}:', responseData); + fileList.innerHTML = '
  • Error: Invalid response from server
  • '; + return; + } + + const files = responseData.files; + console.log('[FILES] Files array:', files); + + if (files.length === 0) { + fileList.innerHTML = '
  • No files uploaded yet.
  • '; + return; + } + + // Clear the loading message + fileList.innerHTML = ''; + + // Add each file to the list + files.forEach(fileName => { + const fileExt = fileName.split('.').pop().toLowerCase(); + const fileUrl = `/data/${uid}/${encodeURIComponent(fileName)}`; + const fileSize = 'N/A'; // We don't have size info in the current API response + + const listItem = document.createElement('li'); + listItem.className = 'file-item'; + + // Create file icon based on file extension + let fileIcon = '📄'; // Default icon + if (['mp3', 'wav', 'ogg', 'm4a', 'opus'].includes(fileExt)) { + fileIcon = '🎵'; + } else if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExt)) { + fileIcon = '🖼️'; + } else if (['pdf', 'doc', 'docx', 'txt'].includes(fileExt)) { + fileIcon = '📄'; + } + + listItem.innerHTML = ` +
    + ${fileIcon} + + ${fileName} + + ${fileSize} +
    +
    + + ⬇️ + Download + + +
    + `; + + // Add delete button handler + const deleteButton = listItem.querySelector('.delete-button'); + if (deleteButton) { + deleteButton.addEventListener('click', () => deleteFile(uid, fileName, listItem)); + } + + fileList.appendChild(listItem); + }); + } else { + // Handle non-OK responses + if (response.status === 401) { + // Parent component will handle authentication state + fileList.innerHTML = ` +
  • + Please log in to view your files. +
  • `; + } else { + fileList.innerHTML = ` +
  • + Error loading files (${response.status}). Please try again later. +
  • `; + } + console.error('[FILES] Server error:', response.status, response.statusText); + } + } catch (error) { + console.error('[FILES] Error fetching files:', error); + const fileList = document.getElementById('file-list'); + if (fileList) { + fileList.innerHTML = ` +
  • + Error loading files: ${error.message || 'Unknown error'} +
  • `; + } + } +} + +// Function to handle file deletion +async function deleteFile(fileId, uid) { + if (!confirm('Are you sure you want to delete this file?')) { + return; + } + + try { + const response = await fetch(`/api/files/${fileId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Refresh the file list + fetchAndDisplayFiles(uid); + showToast('File deleted successfully'); + + } catch (error) { + console.error('[FILES] Error deleting file:', error); + showToast('Error deleting file', 'error'); + } +} + +// Initialize file upload functionality +function initFileUpload() { + const uploadArea = document.getElementById('user-upload-area'); + const fileInput = document.getElementById('fileInputUser'); + + if (!uploadArea || !fileInput) { + console.warn('[UPLOAD] Required elements not found for file upload'); + return; + } + + // Handle click on upload area + uploadArea.addEventListener('click', () => { + fileInput.click(); + }); + + // Handle file selection + fileInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Check file size (100MB limit) + if (file.size > 100 * 1024 * 1024) { + showToast('File is too large. Maximum size is 100MB.', 'error'); + return; + } + + // Show loading state + const originalText = uploadArea.innerHTML; + uploadArea.innerHTML = 'Uploading...'; + + try { + const formData = new FormData(); + formData.append('file', file); + + // Get UID from localStorage (parent UI ensures we're authenticated) + const uid = localStorage.getItem('uid'); + formData.append('uid', uid); + + // Proceed with the upload + const response = await fetch('/upload', { + method: 'POST', + body: formData, + credentials: 'include', // Include cookies for authentication + headers: { + 'Accept': 'application/json' // Explicitly accept JSON response + } + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Upload failed'); + } + + const result = await response.json(); + showToast('File uploaded successfully!'); + + // Refresh file list + if (window.fetchAndDisplayFiles) { + window.fetchAndDisplayFiles(uid); + } + + } catch (error) { + console.error('[UPLOAD] Error uploading file:', error); + showToast(`Upload failed: ${error.message}`, 'error'); + } finally { + // Reset file input and restore upload area text + fileInput.value = ''; + uploadArea.innerHTML = originalText; + } + }); + + // Handle drag and drop + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ['dragenter', 'dragover'].forEach(eventName => { + uploadArea.addEventListener(eventName, highlight, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, unhighlight, false); + }); + + function highlight() { + uploadArea.classList.add('highlight'); + } + + function unhighlight() { + uploadArea.classList.remove('highlight'); + } + + // Handle dropped files + uploadArea.addEventListener('drop', (e) => { + const dt = e.dataTransfer; + const files = dt.files; + + if (files.length) { + fileInput.files = files; + const event = new Event('change'); + fileInput.dispatchEvent(event); + } + }); +} + +// Main initialization when the DOM is fully loaded document.addEventListener('DOMContentLoaded', () => { + // Initialize dashboard components + initDashboard(); + initFileUpload(); + + // Make fetchAndDisplayFiles available globally + window.fetchAndDisplayFiles = fetchAndDisplayFiles; + + // Login/Register (guest) const regForm = document.getElementById('register-form'); if (regForm) { regForm.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(regForm); + const submitButton = regForm.querySelector('button[type="submit"]'); + const originalButtonText = submitButton.textContent; + try { + // Disable button during submission + submitButton.disabled = true; + submitButton.textContent = 'Sending...'; + const res = await fetch('/register', { method: 'POST', - body: formData + body: formData, + headers: { + 'Accept': 'application/json' + } }); + let data; const contentType = res.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - data = await res.json(); - } else { - data = { detail: await res.text() }; - } - if (res.ok) { - showToast('Confirmation sent! Check your email.'); - } else { - showToast('Registration failed: ' + (data.detail || res.status)); + + try { + if (contentType && contentType.includes('application/json')) { + data = await res.json(); + } else { + const text = await res.text(); + data = { detail: text }; + } + + if (res.ok) { + showToast('', 'success'); + // Clear the form on success + regForm.reset(); + } else { + showToast(`Error: ${data.detail || 'Unknown error occurred'}`, 'error'); + console.error('Registration failed:', data); + } + } catch (parseError) { + console.error('Error parsing response:', parseError); + showToast('Error processing the response. Please try again.', 'error'); } } catch (err) { - showToast('Network error: ' + err); + console.error('Network error:', err); + showToast('Network error. Please check your connection and try again.', 'error'); + } finally { + // Re-enable button + submitButton.disabled = false; + submitButton.textContent = originalButtonText; } }); } -}); - - -// Connect Login or Register link to register form - -document.addEventListener('DOMContentLoaded', () => { + // Connect Login or Register link to register form // Login/Register (guest) const loginLink = document.getElementById('guest-login'); if (loginLink) { @@ -417,4 +965,154 @@ document.addEventListener('DOMContentLoaded', () => { }); } }); -}); + + // Back to top button functionality + const backToTop = document.getElementById('back-to-top'); + if (backToTop) { + backToTop.addEventListener('click', (e) => { + e.preventDefault(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + } + + // Mobile menu functionality + const menuToggle = document.getElementById('mobile-menu-toggle'); + const mainNav = document.getElementById('main-navigation'); + + if (menuToggle && mainNav) { + // Toggle mobile menu + menuToggle.addEventListener('click', () => { + const isExpanded = menuToggle.getAttribute('aria-expanded') === 'true' || false; + menuToggle.setAttribute('aria-expanded', !isExpanded); + mainNav.setAttribute('aria-hidden', isExpanded); + + // Toggle mobile menu visibility + if (isExpanded) { + mainNav.classList.remove('mobile-visible'); + document.body.style.overflow = ''; + } else { + mainNav.classList.add('mobile-visible'); + document.body.style.overflow = 'hidden'; + } + }); + + // Close mobile menu when clicking outside + document.addEventListener('click', (e) => { + const isClickInsideNav = mainNav.contains(e.target); + const isClickOnToggle = menuToggle === e.target || menuToggle.contains(e.target); + + if (mainNav.classList.contains('mobile-visible') && !isClickInsideNav && !isClickOnToggle) { + mainNav.classList.remove('mobile-visible'); + menuToggle.setAttribute('aria-expanded', 'false'); + document.body.style.overflow = ''; + } + }); + } + + // Handle navigation link clicks + const navLinks = document.querySelectorAll('nav a[href^="#"]'); + navLinks.forEach(link => { + link.addEventListener('click', (e) => { + const targetId = link.getAttribute('href'); + if (targetId === '#') return; + + const targetElement = document.querySelector(targetId); + if (targetElement) { + e.preventDefault(); + + // Close mobile menu if open + if (mainNav && mainNav.classList.contains('mobile-visible')) { + mainNav.classList.remove('mobile-visible'); + if (menuToggle) { + menuToggle.setAttribute('aria-expanded', 'false'); + } + document.body.style.overflow = ''; + } + + // Smooth scroll to target + targetElement.scrollIntoView({ behavior: 'smooth' }); + + // Update URL without page reload + if (history.pushState) { + history.pushState(null, '', targetId); + } else { + location.hash = targetId; + } + } + }); + }); + + // Helper function to handle page section navigation + const setupPageNavigation = (linkIds, pageId) => { + const links = linkIds + .map(id => document.getElementById(id)) + .filter(Boolean); + + links.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + document.querySelectorAll('main > section').forEach(sec => { + sec.hidden = sec.id !== pageId; + }); + const targetPage = document.getElementById(pageId); + if (targetPage) { + targetPage.hidden = false; + targetPage.scrollIntoView({ behavior: 'smooth' }); + } + }); + }); + }; + + // Setup navigation for different sections + setupPageNavigation(['guest-terms', 'user-terms'], 'terms-page'); + setupPageNavigation(['guest-imprint', 'user-imprint'], 'imprint-page'); + setupPageNavigation(['guest-privacy', 'user-privacy'], 'privacy-page'); + + // Registration form handler for guests - removed duplicate declaration + // The form submission is already handled earlier in the file + + // Login link handler - removed duplicate declaration + // The login link is already handled by the setupPageNavigation function + + // Handle drag and drop + const uploadArea = document.getElementById('upload-area'); + if (uploadArea) { + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, preventDefaults, false); + }); + + ['dragenter', 'dragover'].forEach(eventName => { + uploadArea.addEventListener(eventName, highlight, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, unhighlight, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + function highlight() { + uploadArea.classList.add('highlight'); + } + + function unhighlight() { + uploadArea.classList.remove('highlight'); + } + + // Handle dropped files + uploadArea.addEventListener('drop', (e) => { + const dt = e.dataTransfer; + const files = dt.files; + + if (files.length) { + const fileInput = document.getElementById('file-input'); + fileInput.files = files; + const event = new Event('change'); + fileInput.dispatchEvent(event); + } + }); + } +}); // End of DOMContentLoaded diff --git a/static/desktop.css b/static/desktop.css index 8e6d414..6c85ecc 100644 --- a/static/desktop.css +++ b/static/desktop.css @@ -1,18 +1,24 @@ /* Desktop-specific styles for screens 960px and wider */ @media (min-width: 960px) { + :root { + --content-max-width: 800px; + --content-padding: 1.25rem; + --section-spacing: 1.5rem; + } + html { background-color: #111 !important; background-image: repeating-linear-gradient( 45deg, - rgba(188, 183, 107, 0.1) 0, /* Olive color */ + rgba(188, 183, 107, 0.1) 0, rgba(188, 183, 107, 0.1) 1px, transparent 1px, transparent 20px ), repeating-linear-gradient( -45deg, - rgba(188, 183, 107, 0.1) 0, /* Olive color */ + rgba(188, 183, 107, 0.1) 0, rgba(188, 183, 107, 0.1) 1px, transparent 1px, transparent 20px @@ -26,72 +32,200 @@ body { background: transparent !important; min-height: 100vh !important; + display: flex; + flex-direction: column; + } + + /* 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; } - /* Section styles are now handled in style.css */ + /* 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 { - padding: 5px; + padding: 0.5rem 1rem; margin: 0 0.5em; - border-radius: 3px; + border-radius: 4px; + transition: background-color 0.2s ease; } - - /* Reset mobile-specific styles for desktop */ - .dashboard-nav { - padding: 0.5em; - max-width: none; - justify-content: center; + + nav.dashboard-nav a:hover { + background-color: rgba(255, 255, 255, 0.1); } - - .dashboard-nav a { - min-width: auto; - font-size: 1rem; + + /* Form elements */ + input[type="email"], + 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 */ main > section > article, - #stream-page > article { + #stream-page > article, + #stream-page #stream-list > li .stream-player { max-width: 600px; - margin: 0 auto 2em auto; + margin: 2em auto 2em auto; padding: 2em; background: #1e1e1e; border: 1px solid #333; border-radius: 8px; 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-page #stream-list > li { list-style: none; - margin-bottom: 1.5em; - } - - #stream-page #stream-list > li .stream-player { - padding: 1.5em; - background: #1e1e1e; + margin: 0; + padding: 0; border: none; - border-radius: 8px; - transition: all 0.2s ease; + background: transparent; } - /* Hover states - only apply to direct article children of sections */ - main > section > article:hover { - transform: translateY(-2px); - background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02)); - border: 1px solid #ff6600; + #stream-page #stream-list { + padding: 0; + margin: 0 auto; + max-width: 600px; + 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 { max-width: 600px; margin: 0 auto; - padding: 0 1rem; } - /* User upload area desktop styles */ + /* User upload area - matches article styling */ #user-upload-area { - max-width: 600px !important; - width: 100% !important; - margin: 1.5rem auto !important; - box-sizing: border-box !important; + max-width: 600px; + width: 100%; + margin: 2rem auto; + box-sizing: border-box; } } diff --git a/static/index.html b/static/index.html index 4371ea4..3b4d872 100644 --- a/static/index.html +++ b/static/index.html @@ -10,19 +10,19 @@ dicta2stream - + + + + +
    @@ -33,50 +33,54 @@
    -