RC1
This commit is contained in:
310
static/app.js
310
static/app.js
@ -37,7 +37,7 @@ function handleMagicLoginRedirect() {
|
||||
localStorage.setItem('uid', username);
|
||||
localStorage.setItem('confirmed_uid', username);
|
||||
localStorage.setItem('uid_time', Date.now().toString());
|
||||
document.cookie = `uid=${encodeURIComponent(username)}; path=/`;
|
||||
document.cookie = `uid=${encodeURIComponent(username)}; path=/; SameSite=Lax`;
|
||||
|
||||
// Update UI state
|
||||
document.body.classList.add('authenticated');
|
||||
@ -45,7 +45,7 @@ function handleMagicLoginRedirect() {
|
||||
|
||||
// Update local storage and cookies
|
||||
localStorage.setItem('isAuthenticated', 'true');
|
||||
document.cookie = `isAuthenticated=true; path=/`;
|
||||
document.cookie = `isAuthenticated=true; path=/; SameSite=Lax`;
|
||||
|
||||
// Update URL and history without reloading
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
@ -677,25 +677,170 @@ trackedFunctions.forEach(fnName => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update the visibility of the account deletion section based on authentication state
|
||||
function updateAccountDeletionVisibility(isAuthenticated) {
|
||||
console.log('[ACCOUNT-DELETION] updateAccountDeletionVisibility called with isAuthenticated:', isAuthenticated);
|
||||
|
||||
// Find the account deletion section and its auth-only wrapper
|
||||
const authOnlyWrapper = document.querySelector('#privacy-page .auth-only');
|
||||
const accountDeletionSection = document.getElementById('account-deletion');
|
||||
|
||||
console.log('[ACCOUNT-DELETION] Elements found:', {
|
||||
authOnlyWrapper: !!authOnlyWrapper,
|
||||
accountDeletionSection: !!accountDeletionSection
|
||||
});
|
||||
|
||||
// Function to show an element with all necessary styles
|
||||
const showElement = (element) => {
|
||||
if (!element) return;
|
||||
|
||||
console.log('[ACCOUNT-DELETION] Showing element:', element);
|
||||
|
||||
// Remove any hiding classes
|
||||
element.classList.remove('hidden', 'auth-only-hidden');
|
||||
|
||||
// Set all possible visibility properties
|
||||
element.style.display = 'block';
|
||||
element.style.visibility = 'visible';
|
||||
element.style.opacity = '1';
|
||||
element.style.height = 'auto';
|
||||
element.style.position = 'relative';
|
||||
element.style.clip = 'auto';
|
||||
element.style.overflow = 'visible';
|
||||
|
||||
// Add a class to mark as visible
|
||||
element.classList.add('account-visible');
|
||||
};
|
||||
|
||||
// Function to hide an element
|
||||
const hideElement = (element) => {
|
||||
if (!element) return;
|
||||
|
||||
console.log('[ACCOUNT-DELETION] Hiding element:', element);
|
||||
|
||||
// Set display to none to completely remove from layout
|
||||
element.style.display = 'none';
|
||||
|
||||
// Remove any visibility-related classes
|
||||
element.classList.remove('account-visible');
|
||||
};
|
||||
|
||||
if (isAuthenticated) {
|
||||
console.log('[ACCOUNT-DELETION] User is authenticated, checking if on privacy page');
|
||||
|
||||
// Get the current page state - only show on #privacy-page
|
||||
const currentHash = window.location.hash;
|
||||
const isPrivacyPage = currentHash === '#privacy-page';
|
||||
|
||||
console.log('[ACCOUNT-DELETION] Debug - Page State:', {
|
||||
isAuthenticated,
|
||||
currentHash,
|
||||
isPrivacyPage,
|
||||
documentTitle: document.title
|
||||
});
|
||||
|
||||
if (isAuthenticated && isPrivacyPage) {
|
||||
console.log('[ACCOUNT-DELETION] On privacy page, showing account deletion section');
|
||||
|
||||
// Show the auth wrapper and account deletion section
|
||||
if (authOnlyWrapper) {
|
||||
authOnlyWrapper.style.display = 'block';
|
||||
authOnlyWrapper.style.visibility = 'visible';
|
||||
}
|
||||
|
||||
if (accountDeletionSection) {
|
||||
accountDeletionSection.style.display = 'block';
|
||||
accountDeletionSection.style.visibility = 'visible';
|
||||
}
|
||||
} else {
|
||||
console.log('[ACCOUNT-DELETION] Not on privacy page, hiding account deletion section');
|
||||
|
||||
// Hide the account deletion section
|
||||
if (accountDeletionSection) {
|
||||
accountDeletionSection.style.display = 'none';
|
||||
accountDeletionSection.style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
// Only hide the auth wrapper if we're not on the privacy page
|
||||
if (authOnlyWrapper && !isPrivacyPage) {
|
||||
authOnlyWrapper.style.display = 'none';
|
||||
authOnlyWrapper.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log the current state after updates
|
||||
if (accountDeletionSection) {
|
||||
console.log('[ACCOUNT-DELETION] Account deletion section state after show:', {
|
||||
display: window.getComputedStyle(accountDeletionSection).display,
|
||||
visibility: window.getComputedStyle(accountDeletionSection).visibility,
|
||||
classes: accountDeletionSection.className,
|
||||
parent: accountDeletionSection.parentElement ? {
|
||||
tag: accountDeletionSection.parentElement.tagName,
|
||||
classes: accountDeletionSection.parentElement.className,
|
||||
display: window.getComputedStyle(accountDeletionSection.parentElement).display
|
||||
} : 'no parent'
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('[ACCOUNT-DELETION] User is not authenticated, hiding account deletion section');
|
||||
|
||||
// Hide the account deletion section but keep the auth-only wrapper for other potential content
|
||||
if (accountDeletionSection) {
|
||||
hideElement(accountDeletionSection);
|
||||
}
|
||||
|
||||
// Only hide the auth-only wrapper if it doesn't contain other important content
|
||||
if (authOnlyWrapper) {
|
||||
const hasOtherContent = Array.from(authOnlyWrapper.children).some(
|
||||
child => child.id !== 'account-deletion' && child.offsetParent !== null
|
||||
);
|
||||
|
||||
if (!hasOtherContent) {
|
||||
hideElement(authOnlyWrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log final state for debugging
|
||||
console.log('[ACCOUNT-DELETION] Final state:', {
|
||||
authOnlyWrapper: authOnlyWrapper ? {
|
||||
display: window.getComputedStyle(authOnlyWrapper).display,
|
||||
visibility: window.getComputedStyle(authOnlyWrapper).visibility,
|
||||
classes: authOnlyWrapper.className
|
||||
} : 'not found',
|
||||
accountDeletionSection: accountDeletionSection ? {
|
||||
display: window.getComputedStyle(accountDeletionSection).display,
|
||||
visibility: window.getComputedStyle(accountDeletionSection).visibility,
|
||||
classes: accountDeletionSection.className,
|
||||
parent: accountDeletionSection.parentElement ? {
|
||||
tag: accountDeletionSection.parentElement.tagName,
|
||||
classes: accountDeletionSection.parentElement.className,
|
||||
display: window.getComputedStyle(accountDeletionSection.parentElement).display
|
||||
} : 'no parent'
|
||||
} : 'not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check authentication state and update UI
|
||||
function checkAuthState() {
|
||||
// Debounce rapid calls
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle the checks
|
||||
if (now - lastAuthCheckTime < AUTH_CHECK_DEBOUNCE) {
|
||||
return;
|
||||
return wasAuthenticated === true;
|
||||
}
|
||||
lastAuthCheckTime = now;
|
||||
|
||||
// Check various auth indicators
|
||||
const hasAuthCookie = document.cookie.includes('sessionid=');
|
||||
authCheckCounter++;
|
||||
|
||||
// Check various authentication indicators
|
||||
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 hasAuthToken = !!localStorage.getItem('authToken');
|
||||
|
||||
// User is considered authenticated if any of these are true
|
||||
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,
|
||||
@ -729,6 +874,9 @@ function checkAuthState() {
|
||||
console.warn('injectNavigation function not found');
|
||||
}
|
||||
|
||||
// Update account deletion section visibility
|
||||
updateAccountDeletionVisibility(isAuthenticated);
|
||||
|
||||
// Update the tracked state
|
||||
wasAuthenticated = isAuthenticated;
|
||||
|
||||
@ -755,6 +903,12 @@ function setupAuthStatePolling() {
|
||||
}
|
||||
|
||||
|
||||
// Function to handle page navigation
|
||||
function handlePageNavigation() {
|
||||
const isAuthenticated = checkAuthState();
|
||||
updateAccountDeletionVisibility(isAuthenticated);
|
||||
}
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Set up authentication state monitoring
|
||||
@ -766,6 +920,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// Initialize components
|
||||
initNavigation();
|
||||
|
||||
// Initialize account deletion section visibility
|
||||
handlePageNavigation();
|
||||
|
||||
// Listen for hash changes to update visibility when navigating
|
||||
window.addEventListener('hashchange', handlePageNavigation);
|
||||
|
||||
// Initialize profile player after a short delay
|
||||
setTimeout(() => {
|
||||
@ -861,32 +1020,96 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const deleteAccountFromPrivacyBtn = document.getElementById('delete-account-from-privacy');
|
||||
|
||||
const deleteAccount = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) {
|
||||
if (!confirm('Are you sure you want to delete your account?\n\nThis action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = e?.target.closest('button');
|
||||
const originalText = deleteBtn?.textContent || 'Delete My Account';
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
}
|
||||
|
||||
try {
|
||||
// Get UID from localStorage
|
||||
const uid = localStorage.getItem('uid');
|
||||
if (!uid) {
|
||||
throw new Error('User not authenticated. Please log in again.');
|
||||
}
|
||||
|
||||
console.log('Sending delete account request for UID:', uid);
|
||||
const response = await fetch('/api/delete-account', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
uid: uid // Include UID in the request body
|
||||
})
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
showToast(`❌ ${error.message || 'Failed to delete account'}`, 'error');
|
||||
console.log('Received response status:', response.status, response.statusText);
|
||||
|
||||
// Try to parse response as JSON, but handle non-JSON responses
|
||||
let data;
|
||||
const text = await response.text();
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse response as JSON:', parseError);
|
||||
console.log('Raw response text:', text);
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Account deletion successful');
|
||||
showToast('✅ Account deleted successfully', 'success');
|
||||
// Clear local storage and redirect to home page after a short delay
|
||||
setTimeout(() => {
|
||||
localStorage.clear();
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
} else {
|
||||
console.error('Delete account failed:', { status: response.status, data });
|
||||
const errorMessage = data.detail || data.message ||
|
||||
data.error ||
|
||||
`Server returned ${response.status} ${response.statusText}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in deleteAccount:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
error: error
|
||||
});
|
||||
|
||||
// Try to extract a meaningful error message
|
||||
let errorMessage = 'Failed to delete account';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message || error.toString();
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
} else if (error && typeof error === 'object') {
|
||||
errorMessage = error.message || JSON.stringify(error);
|
||||
}
|
||||
|
||||
showToast(`❌ ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
// Restore button state
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners to both delete account buttons
|
||||
@ -902,22 +1125,49 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
// Logout function
|
||||
function logout() {
|
||||
async function logout(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// If handleLogout is available in dashboard.js, use it for comprehensive logout
|
||||
if (typeof handleLogout === 'function') {
|
||||
try {
|
||||
await handleLogout(event);
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Fall back to basic logout if handleLogout fails
|
||||
basicLogout();
|
||||
}
|
||||
} else {
|
||||
// Fallback to basic logout if handleLogout is not available
|
||||
basicLogout();
|
||||
}
|
||||
}
|
||||
|
||||
// Basic client-side logout as fallback
|
||||
function basicLogout() {
|
||||
// Clear authentication state
|
||||
document.body.classList.remove('authenticated');
|
||||
localStorage.removeItem('isAuthenticated');
|
||||
localStorage.removeItem('uid');
|
||||
localStorage.removeItem('confirmed_uid');
|
||||
localStorage.removeItem('uid_time');
|
||||
localStorage.removeItem('authToken');
|
||||
|
||||
// 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;';
|
||||
// Clear all cookies with proper SameSite attribute
|
||||
document.cookie.split(';').forEach(cookie => {
|
||||
const [name] = cookie.trim().split('=');
|
||||
if (name) {
|
||||
document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=${window.location.hostname}; SameSite=Lax`;
|
||||
}
|
||||
});
|
||||
|
||||
// Stop any playing audio
|
||||
stopMainAudio();
|
||||
|
||||
// Redirect to home page
|
||||
// Force a hard redirect to ensure all state is cleared
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
|
@ -36,16 +36,78 @@ body.authenticated .auth-only {
|
||||
#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 {
|
||||
#me-page:not([hidden]) > div {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
body.authenticated .guest-only {
|
||||
/* Show auth-only elements when authenticated */
|
||||
body.authenticated .auth-only {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Account deletion section - improved width and formatting */
|
||||
#account-deletion {
|
||||
margin: 2.5rem auto;
|
||||
padding: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
|
||||
max-width: 600px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#account-deletion h3 {
|
||||
color: var(--color-primary);
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#account-deletion p {
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#account-deletion ul {
|
||||
margin: 1rem 0 1.5rem 1.5rem;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
#account-deletion .centered-container {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
#delete-account-from-privacy {
|
||||
background-color: #ff4d4f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#delete-account-from-privacy:hover {
|
||||
background-color: #ff6b6b;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Hide guest-only elements when authenticated */
|
||||
body.authenticated .guest-only {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
@ -3,19 +3,22 @@ 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;
|
||||
const streamPlayer = document.querySelector('#me-page .stream-player');
|
||||
|
||||
if (!playButton || !streamPlayer) 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
|
||||
// Show the player and set the UID if not already set
|
||||
streamPlayer.style.display = 'block';
|
||||
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');
|
||||
// Hide the player for guests
|
||||
streamPlayer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,8 +31,8 @@ export async function initMagicLogin() {
|
||||
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=/`;
|
||||
document.cookie = `uid=${encodeURIComponent(confirmedUid)}; path=/; SameSite=Lax`;
|
||||
document.cookie = `authToken=${authToken}; path=/; SameSite=Lax; Secure`;
|
||||
|
||||
// Store in localStorage for client-side access
|
||||
localStorage.setItem('uid', confirmedUid);
|
||||
@ -53,8 +53,8 @@ export async function initMagicLogin() {
|
||||
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=/`;
|
||||
document.cookie = `uid=${encodeURIComponent(data.confirmed_uid)}; path=/; SameSite=Lax`;
|
||||
document.cookie = `authToken=${authToken}; path=/; SameSite=Lax; Secure`;
|
||||
|
||||
// Store in localStorage for client-side access
|
||||
localStorage.setItem('uid', data.confirmed_uid);
|
||||
|
@ -1,12 +1,22 @@
|
||||
// static/streams-ui.js — public streams loader and profile-link handling
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
console.log('[streams-ui] Module loaded');
|
||||
// Global variable to track if we should force refresh the stream list
|
||||
let shouldForceRefresh = false;
|
||||
|
||||
// Function to refresh the stream list
|
||||
window.refreshStreamList = function(force = true) {
|
||||
shouldForceRefresh = force;
|
||||
loadAndRenderStreams();
|
||||
return new Promise((resolve) => {
|
||||
// Resolve after a short delay to allow the stream list to update
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
};
|
||||
|
||||
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
|
||||
|
||||
export function initStreamsUI() {
|
||||
console.log('[streams-ui] Initializing streams UI');
|
||||
initStreamLinks();
|
||||
window.addEventListener('popstate', () => {
|
||||
highlightActiveProfileLink();
|
||||
@ -29,25 +39,55 @@ window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
|
||||
// Global variables for audio control
|
||||
let currentlyPlayingAudio = null;
|
||||
|
||||
// Global variable to track the active SSE connection
|
||||
let activeSSEConnection = null;
|
||||
|
||||
// Global cleanup function for SSE connections
|
||||
const cleanupConnections = () => {
|
||||
if (window._streamsSSE) {
|
||||
if (window._streamsSSE.abort) {
|
||||
window._streamsSSE.abort();
|
||||
}
|
||||
window._streamsSSE = null;
|
||||
}
|
||||
|
||||
if (window.connectionTimeout) {
|
||||
clearTimeout(window.connectionTimeout);
|
||||
window.connectionTimeout = null;
|
||||
}
|
||||
|
||||
activeSSEConnection = null;
|
||||
};
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('[streams-ui] DOM content loaded, initializing streams UI');
|
||||
initStreamsUI();
|
||||
|
||||
// Also try to load streams immediately in case the page is already loaded
|
||||
setTimeout(() => {
|
||||
console.log('[streams-ui] Attempting initial stream load');
|
||||
loadAndRenderStreams();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
function loadAndRenderStreams() {
|
||||
console.log('[streams-ui] loadAndRenderStreams called');
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) {
|
||||
console.warn('[streams-ui] #stream-list not found in DOM');
|
||||
console.error('[STREAMS-UI] Stream list element not found');
|
||||
return;
|
||||
}
|
||||
console.log('[STREAMS-UI] loadAndRenderStreams called, shouldForceRefresh:', shouldForceRefresh);
|
||||
|
||||
// Don't start a new connection if one is already active and we're not forcing a refresh
|
||||
if (activeSSEConnection && !shouldForceRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're forcing a refresh, clean up the existing connection
|
||||
if (shouldForceRefresh && activeSSEConnection) {
|
||||
// Clean up any existing connections
|
||||
cleanupConnections();
|
||||
shouldForceRefresh = false; // Reset the flag after handling
|
||||
}
|
||||
|
||||
// Clear any existing error messages or retry buttons
|
||||
ul.innerHTML = '<li>Loading public streams...</li>';
|
||||
@ -59,36 +99,21 @@ function loadAndRenderStreams() {
|
||||
const baseUrl = window.location.origin;
|
||||
const sseUrl = `${baseUrl}/streams-sse?t=${timestamp}`;
|
||||
|
||||
console.log(`[streams-ui] Connecting to ${sseUrl}`);
|
||||
|
||||
let gotAny = false;
|
||||
let streams = [];
|
||||
let connectionTimeout = null;
|
||||
window.connectionTimeout = null;
|
||||
|
||||
// Clean up previous connection and timeouts
|
||||
if (window._streamsSSE) {
|
||||
console.group('[streams-ui] Cleaning up previous connection');
|
||||
console.log('Previous connection exists, aborting...');
|
||||
if (window._streamsSSE.abort) {
|
||||
window._streamsSSE.abort();
|
||||
console.log('Previous connection aborted');
|
||||
} else {
|
||||
console.log('No abort method on previous connection');
|
||||
}
|
||||
window._streamsSSE = null;
|
||||
console.groupEnd();
|
||||
// Clean up any existing connections
|
||||
cleanupConnections();
|
||||
|
||||
// Reset the retry count if we have a successful connection
|
||||
window.streamRetryCount = 0;
|
||||
|
||||
if (window.connectionTimeout) {
|
||||
clearTimeout(window.connectionTimeout);
|
||||
window.connectionTimeout = null;
|
||||
}
|
||||
|
||||
if (connectionTimeout) {
|
||||
console.log('[streams-ui] Clearing previous connection timeout');
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
} else {
|
||||
console.log('[streams-ui] No previous connection timeout to clear');
|
||||
}
|
||||
|
||||
console.log(`[streams-ui] Creating fetch-based SSE connection to ${sseUrl}`);
|
||||
|
||||
// Use fetch with ReadableStream for better CORS handling
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
@ -96,6 +121,9 @@ function loadAndRenderStreams() {
|
||||
// Store the controller for cleanup
|
||||
window._streamsSSE = controller;
|
||||
|
||||
// Track the active connection
|
||||
activeSSEConnection = controller;
|
||||
|
||||
// Set a connection timeout with debug info
|
||||
const connectionStartTime = Date.now();
|
||||
const connectionTimeoutId = setTimeout(() => {
|
||||
@ -123,20 +151,12 @@ function loadAndRenderStreams() {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}, 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);
|
||||
|
||||
console.log('[streams-ui] Creating fetch request with URL:', sseUrl);
|
||||
window.connectionTimeout = connectionTimeoutId;
|
||||
|
||||
// Make the fetch request with proper error handling
|
||||
fetch(sseUrl, {
|
||||
@ -151,25 +171,14 @@ function loadAndRenderStreams() {
|
||||
mode: 'cors',
|
||||
redirect: 'follow'
|
||||
})
|
||||
.then(response => {
|
||||
console.log('[streams-ui] Fetch response received, status:', response.status, response.statusText);
|
||||
console.log('[streams-ui] Response URL:', response.url);
|
||||
console.log('[streams-ui] Response type:', response.type);
|
||||
console.log('[streams-ui] Response redirected:', response.redirected);
|
||||
console.log('[streams-ui] Response headers:');
|
||||
response.headers.forEach((value, key) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
// Try to get the response text for error details
|
||||
return response.text().then(text => {
|
||||
console.error('[streams-ui] Error response body:', text);
|
||||
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
|
||||
error.response = { status: response.status, statusText: response.statusText, body: text };
|
||||
throw error;
|
||||
}).catch(textError => {
|
||||
console.error('[streams-ui] Could not read error response body:', textError);
|
||||
}).catch(() => {
|
||||
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
|
||||
error.response = { status: response.status, statusText: response.statusText };
|
||||
throw error;
|
||||
@ -177,13 +186,9 @@ function loadAndRenderStreams() {
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const error = new Error('Response body is null or undefined');
|
||||
console.error('[streams-ui] No response body:', error);
|
||||
throw error;
|
||||
throw new Error('Response body is null or undefined');
|
||||
}
|
||||
|
||||
console.log('[streams-ui] Response body is available, content-type:', response.headers.get('content-type'));
|
||||
|
||||
// Get the readable stream
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@ -191,15 +196,18 @@ function loadAndRenderStreams() {
|
||||
|
||||
// Process the stream
|
||||
function processStream({ done, value }) {
|
||||
console.log('[STREAMS-UI] processStream called with done:', done);
|
||||
if (done) {
|
||||
console.log('[streams-ui] Stream completed');
|
||||
console.log('[STREAMS-UI] Stream processing complete');
|
||||
// Process any remaining data in the buffer
|
||||
if (buffer.trim()) {
|
||||
console.log('[STREAMS-UI] Processing remaining buffer data');
|
||||
try {
|
||||
const data = JSON.parse(buffer);
|
||||
console.log('[STREAMS-UI] Parsed data from buffer:', data);
|
||||
processSSEEvent(data);
|
||||
} catch (e) {
|
||||
console.error('[streams-ui] Error parsing final data:', e);
|
||||
console.error('[STREAMS-UI] Error parsing buffer data:', e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@ -235,68 +243,63 @@ function loadAndRenderStreams() {
|
||||
return reader.read().then(processStream);
|
||||
})
|
||||
.catch(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
|
||||
if (error.name === 'TypeError') {
|
||||
console.error('[streams-ui] This is likely a network error or CORS issue');
|
||||
}
|
||||
|
||||
// Show a user-friendly error message
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (ul) {
|
||||
let errorMessage = 'Error loading streams. ';
|
||||
// Only handle the error if it's not an abort error
|
||||
if (error.name !== 'AbortError') {
|
||||
// Clean up the controller reference
|
||||
window._streamsSSE = null;
|
||||
activeSSEConnection = null;
|
||||
|
||||
if (error.message.includes('Failed to fetch')) {
|
||||
errorMessage += 'Unable to connect to the server. Please check your internet connection.';
|
||||
} else if (error.message.includes('CORS')) {
|
||||
errorMessage += 'A server configuration issue occurred. Please try again later.';
|
||||
} else {
|
||||
errorMessage += 'Please try again later.';
|
||||
// Clear the connection timeout
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = null;
|
||||
}
|
||||
|
||||
ul.innerHTML = `
|
||||
<li class="error">
|
||||
<p>${errorMessage}</p>
|
||||
<button id="retry-loading" class="retry-button">
|
||||
<span class="retry-icon">↻</span> Try Again
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
// Show a user-friendly error message
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (ul) {
|
||||
let errorMessage = 'Error loading streams. ';
|
||||
|
||||
// Add retry handler
|
||||
const retryButton = document.getElementById('retry-loading');
|
||||
if (retryButton) {
|
||||
retryButton.addEventListener('click', () => {
|
||||
ul.innerHTML = '<li>Loading streams...</li>';
|
||||
loadAndRenderStreams();
|
||||
});
|
||||
if (error.message && error.message.includes('Failed to fetch')) {
|
||||
errorMessage += 'Unable to connect to the server. Please check your internet connection.';
|
||||
} else if (error.message && error.message.includes('CORS')) {
|
||||
errorMessage += 'A server configuration issue occurred. Please try again later.';
|
||||
} else {
|
||||
errorMessage += 'Please try again later.';
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Function to process SSE events
|
||||
function processSSEEvent(data) {
|
||||
console.log('[streams-ui] Received SSE event:', data);
|
||||
|
||||
console.log('[STREAMS-UI] Processing SSE event:', data);
|
||||
if (data.end) {
|
||||
console.log('[streams-ui] Received end event, total streams:', streams.length);
|
||||
|
||||
if (streams.length === 0) {
|
||||
console.log('[streams-ui] No streams found, showing empty state');
|
||||
ul.innerHTML = '<li>No active streams.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort streams by mtime in descending order (newest first)
|
||||
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
|
||||
console.log('[streams-ui] Sorted streams:', streams);
|
||||
|
||||
// Clear the list
|
||||
ul.innerHTML = '';
|
||||
@ -307,8 +310,6 @@ function loadAndRenderStreams() {
|
||||
const sizeMb = stream.size ? (stream.size / (1024 * 1024)).toFixed(1) : '?';
|
||||
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
|
||||
|
||||
console.log(`[streams-ui] Rendering stream ${index + 1}/${streams.length}:`, { uid, sizeMb, mtime });
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className = 'stream-item';
|
||||
|
||||
@ -323,9 +324,7 @@ function loadAndRenderStreams() {
|
||||
</article>
|
||||
`;
|
||||
ul.appendChild(li);
|
||||
console.log(`[streams-ui] Successfully rendered stream: ${uid}`);
|
||||
} catch (error) {
|
||||
console.error(`[streams-ui] Error rendering stream ${uid}:`, error);
|
||||
const errorLi = document.createElement('li');
|
||||
errorLi.textContent = `Error loading stream: ${uid}`;
|
||||
errorLi.style.color = 'var(--error)';
|
||||
@ -379,10 +378,11 @@ function loadAndRenderStreams() {
|
||||
export function renderStreamList(streams) {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) {
|
||||
console.warn('[streams-ui] renderStreamList: #stream-list not found');
|
||||
console.warn('[STREAMS-UI] renderStreamList: #stream-list not found');
|
||||
return;
|
||||
}
|
||||
console.debug('[streams-ui] Rendering stream list:', streams);
|
||||
console.log('[STREAMS-UI] Rendering stream list with', streams.length, 'streams');
|
||||
console.debug('[STREAMS-UI] Streams data:', streams);
|
||||
if (Array.isArray(streams)) {
|
||||
if (streams.length) {
|
||||
// Sort by mtime descending (most recent first)
|
||||
@ -551,18 +551,14 @@ function stopPlayback() {
|
||||
|
||||
// Load and play audio using HTML5 Audio element for Opus
|
||||
async function loadAndPlayAudio(uid, playPauseBtn) {
|
||||
console.log(`[streams-ui] loadAndPlayAudio called for UID: ${uid}`);
|
||||
|
||||
// If trying to play the currently paused audio, just resume it
|
||||
if (audioElement && currentUid === uid) {
|
||||
console.log('[streams-ui] Resuming existing audio');
|
||||
// If we already have an audio element for this UID and it's paused, just resume it
|
||||
if (audioElement && currentUid === uid && audioElement.paused) {
|
||||
try {
|
||||
await audioElement.play();
|
||||
isPlaying = true;
|
||||
updatePlayPauseButton(playPauseBtn, true);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Error resuming audio:', error);
|
||||
// Fall through to reload if resume fails
|
||||
}
|
||||
}
|
||||
@ -576,11 +572,8 @@ async function loadAndPlayAudio(uid, playPauseBtn) {
|
||||
currentUid = uid;
|
||||
|
||||
try {
|
||||
console.log(`[streams-ui] Creating new audio element for ${uid}`);
|
||||
|
||||
// Create a new audio element with the correct MIME type
|
||||
const audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus`;
|
||||
console.log(`[streams-ui] Loading audio from: ${audioUrl}`);
|
||||
|
||||
// Create a new audio element with a small delay to prevent race conditions
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
@ -591,19 +584,16 @@ async function loadAndPlayAudio(uid, playPauseBtn) {
|
||||
|
||||
// Set up event handlers with proper binding
|
||||
const onPlay = () => {
|
||||
console.log('[streams-ui] Audio play event');
|
||||
isPlaying = true;
|
||||
updatePlayPauseButton(playPauseBtn, true);
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
console.log('[streams-ui] Audio pause event');
|
||||
isPlaying = false;
|
||||
updatePlayPauseButton(playPauseBtn, false);
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
console.log('[streams-ui] Audio ended event');
|
||||
isPlaying = false;
|
||||
cleanupAudio();
|
||||
};
|
||||
@ -611,18 +601,14 @@ async function loadAndPlayAudio(uid, playPauseBtn) {
|
||||
const onError = (e) => {
|
||||
// Ignore errors from previous audio elements that were cleaned up
|
||||
if (!audioElement || audioElement.readyState === 0) {
|
||||
console.log('[streams-ui] Ignoring error from cleaned up audio element');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[streams-ui] Audio error:', e);
|
||||
console.error('Error details:', audioElement.error);
|
||||
isPlaying = false;
|
||||
updatePlayPauseButton(playPauseBtn, false);
|
||||
|
||||
// Don't show error to user for aborted requests
|
||||
if (audioElement.error && audioElement.error.code === MediaError.MEDIA_ERR_ABORTED) {
|
||||
console.log('[streams-ui] Playback was aborted as expected');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -642,7 +628,6 @@ async function loadAndPlayAudio(uid, playPauseBtn) {
|
||||
audioElement._eventHandlers = { onPlay, onPause, onEnded, onError };
|
||||
|
||||
// Start playback with error handling
|
||||
console.log('[streams-ui] Starting audio playback');
|
||||
try {
|
||||
const playPromise = audioElement.play();
|
||||
|
||||
@ -650,10 +635,8 @@ async function loadAndPlayAudio(uid, playPauseBtn) {
|
||||
await playPromise.catch(error => {
|
||||
// Ignore abort errors when switching between streams
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('[streams-ui] Play failed:', error);
|
||||
throw error;
|
||||
}
|
||||
console.log('[streams-ui] Play was aborted as expected');
|
||||
});
|
||||
}
|
||||
|
||||
@ -759,27 +742,21 @@ if (streamList) {
|
||||
|
||||
const uid = playPauseBtn.dataset.uid;
|
||||
if (!uid) {
|
||||
console.error('No UID found for play button');
|
||||
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);
|
||||
}
|
||||
@ -788,7 +765,6 @@ if (streamList) {
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
@ -19,7 +19,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
const streamUrlEl = document.getElementById("streamUrl");
|
||||
const spinner = document.getElementById("spinner");
|
||||
const spinner = document.getElementById("spinner") || { style: { display: 'none' } };
|
||||
let abortController;
|
||||
|
||||
// Upload function
|
||||
@ -89,6 +89,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.fetchAndDisplayFiles) {
|
||||
await window.fetchAndDisplayFiles(uid);
|
||||
}
|
||||
|
||||
// Refresh the stream list to update the last update time
|
||||
if (window.refreshStreamList) {
|
||||
await window.refreshStreamList();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh:', e);
|
||||
}
|
||||
@ -96,8 +101,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
playBeep(432, 0.25, "sine");
|
||||
} else {
|
||||
streamInfo.hidden = true;
|
||||
spinner.style.display = "none";
|
||||
if (streamInfo) streamInfo.hidden = true;
|
||||
if (spinner) spinner.style.display = "none";
|
||||
if ((data.detail || data.error || "").includes("music")) {
|
||||
showToast("🎵 Upload rejected: singing or music detected.");
|
||||
} else {
|
||||
@ -190,10 +195,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const isRenamed = file.original_name && file.original_name !== file.name;
|
||||
return `
|
||||
<li class="file-item" data-filename="${file.name}">
|
||||
<div class="file-name" title="${displayName}">
|
||||
<div class="file-name" title="${isRenamed ? `Stored as: ${file.name}` : 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>` :
|
||||
`<button class="delete-file" data-filename="${file.name}" title="Delete file">🗑️</button>`}
|
||||
${isRenamed ? `<div class="stored-as"><button class="delete-file" data-filename="${file.name}" data-original-name="${file.original_name}" title="Delete file">🗑️</button></div>` :
|
||||
`<button class="delete-file" data-filename="${file.name}" data-original-name="${file.original_name}" title="Delete file">🗑️</button>`}
|
||||
</div>
|
||||
<span class="file-size">${sizeMB} MB</span>
|
||||
</li>
|
||||
@ -203,48 +208,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// Delete button handling is now managed by dashboard.js
|
||||
|
||||
// Update quota display if available
|
||||
if (data.quota !== undefined) {
|
||||
|
Reference in New Issue
Block a user