1046 lines
30 KiB
JavaScript
1046 lines
30 KiB
JavaScript
/**
|
||
* Global Profile App
|
||
* Handles profile management UI with new SDK
|
||
*/
|
||
|
||
const API_BASE = '/api';
|
||
|
||
// State
|
||
let currentProfile = null;
|
||
let peerId = null;
|
||
|
||
// Profile list state management
|
||
let allProfiles = [];
|
||
let filteredProfiles = [];
|
||
let displayedCount = 0;
|
||
let searchQuery = '';
|
||
let scrollObserver = null;
|
||
const BATCH_SIZE = 25; // Number of profiles to load per batch
|
||
|
||
// Connection status elements
|
||
let statusIndicator = null;
|
||
let statusText = null;
|
||
let profilesCountValue = null;
|
||
|
||
// WebSocket connection
|
||
let ws = null;
|
||
let reconnectTimeout = null;
|
||
let reconnectDelay = 1000;
|
||
const MAX_RECONNECT_DELAY = 30000;
|
||
|
||
/**
|
||
* Get avatar URL for a peer
|
||
*/
|
||
function getAvatarUrl(peerId, size = 64) {
|
||
if (!peerId) return '';
|
||
return `${API_BASE}/profile/avatar/${encodeURIComponent(peerId)}/${size}`;
|
||
}
|
||
|
||
/**
|
||
* Initialize the app
|
||
*/
|
||
async function init() {
|
||
try {
|
||
// Get connection status elements
|
||
statusIndicator = document.getElementById('statusIndicator');
|
||
statusText = document.getElementById('statusText');
|
||
profilesCountValue = document.getElementById('profilesCountValue');
|
||
|
||
// Load current profile
|
||
await loadMyProfile();
|
||
|
||
// Load all profiles
|
||
await loadProfiles();
|
||
|
||
// Set up event listeners
|
||
setupEventListeners();
|
||
|
||
// Initialize tag editor
|
||
initTagEditor();
|
||
|
||
// Connect to WebSocket for real-time updates
|
||
connectWebSocket();
|
||
} catch (err) {
|
||
console.error('Error initializing app:', err);
|
||
showToast('Failed to initialize app', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Connect to WebSocket server
|
||
*/
|
||
function connectWebSocket() {
|
||
// Don't connect if already connected or connecting
|
||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||
return;
|
||
}
|
||
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||
|
||
try {
|
||
ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = () => {
|
||
console.log('WebSocket connected');
|
||
reconnectDelay = 1000; // Reset delay on successful connection
|
||
updateStatus(true);
|
||
// WebSocket is now only used for real-time updates
|
||
// Initial profile data is loaded via REST API
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
handleWebSocketMessage(data);
|
||
} catch (err) {
|
||
console.error('Error parsing WebSocket message:', err);
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
console.log('WebSocket disconnected');
|
||
updateStatus(false);
|
||
ws = null;
|
||
|
||
// Attempt to reconnect with exponential backoff
|
||
reconnectTimeout = setTimeout(() => {
|
||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||
connectWebSocket();
|
||
}, reconnectDelay);
|
||
};
|
||
|
||
ws.onerror = (err) => {
|
||
console.error('WebSocket error:', err);
|
||
};
|
||
} catch (err) {
|
||
console.error('Error creating WebSocket:', err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle WebSocket messages
|
||
*/
|
||
function handleWebSocketMessage(data) {
|
||
console.log('Received WebSocket message:', data.type, data);
|
||
switch (data.type) {
|
||
case 'profile-update':
|
||
// Profile was updated
|
||
if (data.profile) {
|
||
const index = allProfiles.findIndex(p => p.peerId === data.profile.peerId);
|
||
if (index >= 0) {
|
||
// Update existing profile
|
||
allProfiles[index] = data.profile;
|
||
} else {
|
||
// Add new profile
|
||
allProfiles.push(data.profile);
|
||
}
|
||
|
||
// If it's the current user's profile, reload it
|
||
if (data.profile.peerId === peerId) {
|
||
loadMyProfile();
|
||
}
|
||
|
||
// Refresh the display
|
||
updateProfilesCount();
|
||
applyFilter();
|
||
}
|
||
break;
|
||
|
||
case 'profile-deleted':
|
||
// Profile was deleted
|
||
if (data.peerId) {
|
||
allProfiles = allProfiles.filter(p => p.peerId !== data.peerId);
|
||
|
||
// If it's the current user's profile, reload it
|
||
if (data.peerId === peerId) {
|
||
loadMyProfile();
|
||
}
|
||
|
||
// Refresh the display
|
||
updateProfilesCount();
|
||
applyFilter();
|
||
}
|
||
break;
|
||
|
||
default:
|
||
console.log('Unknown WebSocket message type:', data.type);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load current user's profile
|
||
*/
|
||
async function loadMyProfile() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/profile`);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load profile');
|
||
}
|
||
|
||
currentProfile = await response.json();
|
||
peerId = currentProfile.peerId;
|
||
|
||
// Populate form
|
||
document.getElementById('display-name').value = currentProfile.displayName || '';
|
||
document.getElementById('bio').value = currentProfile.bio || '';
|
||
document.getElementById('peer-id').value = currentProfile.peerId || '';
|
||
|
||
// Populate additional details with toggle state
|
||
// Store enabled state in customFields if not present
|
||
const emailEnabled = currentProfile.customFields?.emailEnabled ?? (currentProfile.email ? true : false);
|
||
const websiteEnabled = currentProfile.customFields?.websiteEnabled ?? (currentProfile.website ? true : false);
|
||
const xHandleEnabled = currentProfile.customFields?.xHandleEnabled ?? (currentProfile.xUsername ? true : false);
|
||
|
||
populateField('email', currentProfile.email || '', emailEnabled);
|
||
populateField('website', currentProfile.website || '', websiteEnabled);
|
||
populateField('xUsername', currentProfile.xUsername || '', xHandleEnabled);
|
||
|
||
// Handle tags - populate tag editor
|
||
let tagsArray = [];
|
||
if (Array.isArray(currentProfile.tags)) {
|
||
tagsArray = currentProfile.tags;
|
||
} else if (typeof currentProfile.tags === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(currentProfile.tags);
|
||
tagsArray = Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
// If it's a comma-separated string, split it
|
||
if (currentProfile.tags.trim()) {
|
||
tagsArray = currentProfile.tags.split(',').map(t => t.trim()).filter(t => t);
|
||
}
|
||
}
|
||
}
|
||
updateTagEditor(tagsArray);
|
||
|
||
// Update avatar (use 128x128 for preview)
|
||
if (peerId) {
|
||
const avatarImg = document.getElementById('avatar-preview');
|
||
avatarImg.src = getAvatarUrl(peerId, 128) + '?t=' + Date.now();
|
||
avatarImg.style.display = 'block';
|
||
document.getElementById('avatar-placeholder').style.display = 'none';
|
||
} else {
|
||
document.getElementById('avatar-preview').style.display = 'none';
|
||
document.getElementById('avatar-placeholder').style.display = 'block';
|
||
}
|
||
} catch (err) {
|
||
console.error('Error loading profile:', err);
|
||
showToast('Failed to load profile', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load all profiles from network
|
||
*/
|
||
async function loadProfiles() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/profiles`);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
let errorMessage = 'Failed to load profiles';
|
||
try {
|
||
const errorJson = JSON.parse(errorText);
|
||
errorMessage = errorJson.error || errorJson.message || errorMessage;
|
||
} catch (e) {
|
||
errorMessage = errorText || errorMessage;
|
||
}
|
||
console.error('Error loading profiles:', errorMessage, 'Status:', response.status);
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('Profiles response:', data);
|
||
|
||
// Handle both old format (data.profiles) and new format (data.profiles with pagination)
|
||
if (data.profiles && Array.isArray(data.profiles)) {
|
||
allProfiles = data.profiles;
|
||
} else if (Array.isArray(data)) {
|
||
// Fallback: if response is directly an array
|
||
allProfiles = data;
|
||
} else {
|
||
console.warn('Unexpected response format:', data);
|
||
allProfiles = [];
|
||
}
|
||
|
||
// Update profiles count
|
||
updateProfilesCount();
|
||
|
||
// Initial filter with empty query (show all)
|
||
searchQuery = '';
|
||
applyFilter();
|
||
} catch (err) {
|
||
console.error('Error loading profiles:', err);
|
||
const profilesList = document.getElementById('profiles-list');
|
||
profilesList.innerHTML = `<div class="loading">Failed to load profiles: ${err.message || 'Unknown error'}</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the profiles count display
|
||
*/
|
||
function updateProfilesCount() {
|
||
if (profilesCountValue) {
|
||
profilesCountValue.textContent = allProfiles.length;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update connection status indicator
|
||
* @param {boolean} connected - Whether connected
|
||
*/
|
||
function updateStatus(connected) {
|
||
if (!statusIndicator || !statusText) return;
|
||
|
||
if (connected) {
|
||
statusIndicator.classList.remove('disconnected');
|
||
statusText.textContent = 'Connected';
|
||
} else {
|
||
statusIndicator.classList.add('disconnected');
|
||
statusText.textContent = 'Disconnected - Reconnecting...';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Filter profiles based on search query
|
||
* @param {string} query - Search query string
|
||
* @param {Array} profiles - Array of profiles to filter
|
||
* @returns {Array} Filtered profiles
|
||
*/
|
||
function filterProfiles(query, profiles) {
|
||
if (!query || query.trim() === '') {
|
||
return profiles;
|
||
}
|
||
|
||
const searchTerm = query.toLowerCase().trim();
|
||
|
||
return profiles.filter(profile => {
|
||
// Search in displayName
|
||
if (profile.displayName && profile.displayName.toLowerCase().includes(searchTerm)) {
|
||
return true;
|
||
}
|
||
|
||
// Search in bio
|
||
if (profile.bio && profile.bio.toLowerCase().includes(searchTerm)) {
|
||
return true;
|
||
}
|
||
|
||
// Search in email
|
||
if (profile.email && profile.email.toLowerCase().includes(searchTerm)) {
|
||
return true;
|
||
}
|
||
|
||
// Search in website
|
||
if (profile.website && profile.website.toLowerCase().includes(searchTerm)) {
|
||
return true;
|
||
}
|
||
|
||
// Search in xUsername
|
||
if (profile.xUsername && profile.xUsername.toLowerCase().includes(searchTerm)) {
|
||
return true;
|
||
}
|
||
|
||
// Search in tags
|
||
if (profile.tags) {
|
||
const tagsArray = Array.isArray(profile.tags) ? profile.tags :
|
||
(typeof profile.tags === 'string' ? JSON.parse(profile.tags || '[]') : []);
|
||
if (tagsArray.some(tag => tag.toLowerCase().includes(searchTerm))) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Search in peerId
|
||
if (profile.peerId && profile.peerId.toLowerCase().includes(searchTerm)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply filter and reset display
|
||
*/
|
||
function applyFilter() {
|
||
filteredProfiles = filterProfiles(searchQuery, allProfiles);
|
||
displayedCount = 0;
|
||
|
||
// Clear the list and reset scroll
|
||
const profilesList = document.getElementById('profiles-list');
|
||
profilesList.innerHTML = '';
|
||
profilesList.scrollTop = 0;
|
||
|
||
// Clean up existing observer
|
||
if (scrollObserver) {
|
||
scrollObserver.disconnect();
|
||
scrollObserver = null;
|
||
}
|
||
|
||
// Display first batch
|
||
loadMoreProfiles();
|
||
}
|
||
|
||
/**
|
||
* Load more profiles into the display (incremental loading)
|
||
*/
|
||
function loadMoreProfiles() {
|
||
const profilesList = document.getElementById('profiles-list');
|
||
|
||
// Check if we've displayed all filtered profiles
|
||
if (displayedCount >= filteredProfiles.length) {
|
||
// Remove sentinel if it exists
|
||
const sentinel = document.getElementById('scroll-sentinel');
|
||
if (sentinel) {
|
||
sentinel.remove();
|
||
}
|
||
|
||
// Show message if no profiles match
|
||
if (filteredProfiles.length === 0) {
|
||
const noResultsMsg = searchQuery.trim() !== ''
|
||
? '<div class="loading">No profiles match your search</div>'
|
||
: '<div class="loading">No profiles found</div>';
|
||
|
||
if (profilesList.children.length === 0) {
|
||
profilesList.innerHTML = noResultsMsg;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Calculate how many profiles to display in this batch
|
||
const nextBatch = filteredProfiles.slice(displayedCount, displayedCount + BATCH_SIZE);
|
||
const renderTimestamp = Date.now();
|
||
|
||
// Generate HTML for this batch
|
||
const html = nextBatch.map((profile, index) => {
|
||
const displayName = profile.displayName || 'Anonymous';
|
||
const isBot = profile.customFields && (profile.customFields.isBot === true || profile.customFields.botTag === 'BOT');
|
||
const botTag = isBot ? '<span class="bot-badge">BOT</span>' : '';
|
||
const bio = profile.bio || 'No bio';
|
||
|
||
// Use 64x64 for profile list items
|
||
const avatarUrl = getAvatarUrl(profile.peerId, 64);
|
||
const cacheBuster = profile.lastUpdated || (renderTimestamp + index);
|
||
|
||
// Build additional details (email, website, xUsername)
|
||
const details = [];
|
||
if (profile.email) {
|
||
details.push(`📧 ${escapeHtml(profile.email)}`);
|
||
}
|
||
if (profile.website) {
|
||
details.push(`🌐 <a href="${escapeHtml(profile.website)}" target="_blank" rel="noopener noreferrer">${escapeHtml(profile.website)}</a>`);
|
||
}
|
||
if (profile.xUsername) {
|
||
const xUrl = `https://x.com/${profile.xUsername.replace('@', '')}`;
|
||
details.push(`🐦 <a href="${xUrl}" target="_blank" rel="noopener noreferrer">${escapeHtml(profile.xUsername)}</a>`);
|
||
}
|
||
|
||
// Handle tags - render as actual tag badges
|
||
let tagsArray = [];
|
||
if (profile.tags) {
|
||
if (Array.isArray(profile.tags)) {
|
||
tagsArray = profile.tags;
|
||
} else if (typeof profile.tags === 'string') {
|
||
try {
|
||
tagsArray = JSON.parse(profile.tags || '[]');
|
||
} catch {
|
||
tagsArray = [];
|
||
}
|
||
}
|
||
}
|
||
|
||
const tagsHtml = tagsArray.length > 0
|
||
? `<div class="profile-item-tags">${tagsArray.map(tag => `<span class="tag-badge">${escapeHtml(tag)}</span>`).join('')}</div>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="profile-item">
|
||
<img src="${avatarUrl}?t=${cacheBuster}" alt="${displayName}" class="profile-item-avatar" onerror="this.style.display='none'" onload="this.style.display='block'">
|
||
<div class="profile-item-info">
|
||
<div class="profile-item-name">${escapeHtml(displayName)} ${botTag}</div>
|
||
<div class="profile-item-bio">${escapeHtml(bio)}</div>
|
||
${details.length > 0 ? `<div class="profile-item-details">${details.join(' • ')}</div>` : ''}
|
||
${tagsHtml}
|
||
<div class="profile-item-peerid">${profile.peerId}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Remove any existing sentinel
|
||
const existingSentinel = document.getElementById('scroll-sentinel');
|
||
if (existingSentinel) {
|
||
existingSentinel.remove();
|
||
}
|
||
|
||
// Append new profiles
|
||
profilesList.insertAdjacentHTML('beforeend', html);
|
||
|
||
// Update displayed count
|
||
displayedCount += nextBatch.length;
|
||
|
||
// Add sentinel if there are more profiles to load
|
||
if (displayedCount < filteredProfiles.length) {
|
||
profilesList.insertAdjacentHTML('beforeend', '<div id="scroll-sentinel"></div>');
|
||
setupInfiniteScroll();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set up infinite scroll using Intersection Observer
|
||
*/
|
||
function setupInfiniteScroll() {
|
||
// Clean up existing observer
|
||
if (scrollObserver) {
|
||
scrollObserver.disconnect();
|
||
}
|
||
|
||
const sentinel = document.getElementById('scroll-sentinel');
|
||
if (!sentinel) {
|
||
return;
|
||
}
|
||
|
||
// Create new observer
|
||
scrollObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
loadMoreProfiles();
|
||
}
|
||
});
|
||
}, {
|
||
root: null,
|
||
rootMargin: '100px', // Start loading 100px before reaching the bottom
|
||
threshold: 0.1
|
||
});
|
||
|
||
scrollObserver.observe(sentinel);
|
||
}
|
||
|
||
/**
|
||
* Populate a field with value and enabled state
|
||
* @param {string} dataField - Data field name (email, website, xUsername)
|
||
* @param {string} value - Field value
|
||
* @param {boolean} enabled - Whether field is enabled
|
||
*/
|
||
function populateField(dataField, value, enabled) {
|
||
// Map data field names to HTML IDs
|
||
const fieldMap = {
|
||
'email': 'email',
|
||
'website': 'website',
|
||
'xUsername': 'x-handle'
|
||
};
|
||
|
||
const htmlId = fieldMap[dataField] || dataField;
|
||
const input = document.getElementById(htmlId);
|
||
const toggle = document.getElementById(`${htmlId}-toggle`);
|
||
|
||
if (input && toggle) {
|
||
input.value = value || '';
|
||
setFieldEnabled(dataField, enabled === true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set field enabled/disabled state
|
||
* @param {string} dataField - Data field name (email, website, xUsername)
|
||
* @param {boolean} enabled - Whether field should be enabled
|
||
*/
|
||
function setFieldEnabled(dataField, enabled) {
|
||
// Map data field names to HTML IDs
|
||
const fieldMap = {
|
||
'email': 'email',
|
||
'website': 'website',
|
||
'xUsername': 'x-handle'
|
||
};
|
||
|
||
const htmlId = fieldMap[dataField] || dataField;
|
||
const input = document.getElementById(htmlId);
|
||
const toggle = document.getElementById(`${htmlId}-toggle`);
|
||
|
||
if (input && toggle) {
|
||
input.disabled = !enabled;
|
||
|
||
if (enabled) {
|
||
toggle.classList.add('enabled');
|
||
toggle.innerHTML = '<span class="toggle-icon">✓</span><span class="toggle-label">Disable</span>';
|
||
} else {
|
||
toggle.classList.remove('enabled');
|
||
toggle.innerHTML = '<span class="toggle-icon">+</span><span class="toggle-label">Enable</span>';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Debounce function to limit how often a function is called
|
||
* @param {Function} func - Function to debounce
|
||
* @param {number} wait - Wait time in milliseconds
|
||
* @returns {Function} Debounced function
|
||
*/
|
||
function debounce(func, wait) {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Handle search input changes
|
||
*/
|
||
const handleSearchInput = debounce((e) => {
|
||
searchQuery = e.target.value;
|
||
applyFilter();
|
||
}, 300);
|
||
|
||
/**
|
||
* Set up event listeners
|
||
*/
|
||
function setupEventListeners() {
|
||
// Profile form submission
|
||
const profileForm = document.getElementById('profile-form');
|
||
profileForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
await saveProfile();
|
||
});
|
||
|
||
// Avatar upload
|
||
const avatarUpload = document.getElementById('avatar-upload');
|
||
avatarUpload.addEventListener('change', async (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
await uploadAvatar(file);
|
||
}
|
||
});
|
||
|
||
// Search input
|
||
const searchInput = document.getElementById('profile-search');
|
||
if (searchInput) {
|
||
searchInput.addEventListener('input', handleSearchInput);
|
||
}
|
||
|
||
// Toggle buttons for additional fields
|
||
const toggleButtons = [
|
||
{ htmlId: 'email', dataField: 'email' },
|
||
{ htmlId: 'website', dataField: 'website' },
|
||
{ htmlId: 'x-handle', dataField: 'xUsername' }
|
||
];
|
||
|
||
toggleButtons.forEach(({ htmlId, dataField }) => {
|
||
const toggle = document.getElementById(`${htmlId}-toggle`);
|
||
if (toggle) {
|
||
toggle.addEventListener('click', () => {
|
||
const input = document.getElementById(htmlId);
|
||
const isCurrentlyEnabled = !input.disabled;
|
||
setFieldEnabled(dataField, !isCurrentlyEnabled);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Delete profile button
|
||
const deleteBtn = document.getElementById('delete-btn');
|
||
if (deleteBtn) {
|
||
deleteBtn.addEventListener('click', deleteProfile);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleanup WebSocket connection
|
||
*/
|
||
function cleanupWebSocket() {
|
||
if (reconnectTimeout) {
|
||
clearTimeout(reconnectTimeout);
|
||
reconnectTimeout = null;
|
||
}
|
||
|
||
if (ws) {
|
||
ws.close();
|
||
ws = null;
|
||
}
|
||
}
|
||
|
||
// Cleanup on page unload
|
||
window.addEventListener('beforeunload', cleanupWebSocket);
|
||
|
||
/**
|
||
* Save profile
|
||
*/
|
||
async function saveProfile() {
|
||
const saveBtn = document.getElementById('save-btn');
|
||
const btnText = saveBtn.querySelector('.btn-text');
|
||
const btnLoader = saveBtn.querySelector('.btn-loader');
|
||
|
||
console.log('[SaveProfile] Starting save operation...');
|
||
const startTime = Date.now();
|
||
|
||
try {
|
||
saveBtn.disabled = true;
|
||
btnText.style.display = 'none';
|
||
btnLoader.style.display = 'inline';
|
||
|
||
const displayName = document.getElementById('display-name').value;
|
||
const bio = document.getElementById('bio').value;
|
||
|
||
// Get additional details with enabled state
|
||
const emailInput = document.getElementById('email');
|
||
const websiteInput = document.getElementById('website');
|
||
const xHandleInput = document.getElementById('x-handle');
|
||
|
||
const emailEnabled = !emailInput.disabled;
|
||
const websiteEnabled = !websiteInput.disabled;
|
||
const xHandleEnabled = !xHandleInput.disabled;
|
||
|
||
// Get tags from tag editor
|
||
const tags = getTagsFromEditor();
|
||
console.log('[SaveProfile] Tags to save:', tags);
|
||
|
||
// Build customFields to store enabled states
|
||
const customFields = currentProfile?.customFields || {};
|
||
customFields.emailEnabled = emailEnabled;
|
||
customFields.websiteEnabled = websiteEnabled;
|
||
customFields.xHandleEnabled = xHandleEnabled;
|
||
|
||
const requestBody = {
|
||
displayName,
|
||
bio,
|
||
email: emailEnabled ? emailInput.value.trim() : '',
|
||
website: websiteEnabled ? websiteInput.value.trim() : '',
|
||
xUsername: xHandleEnabled ? xHandleInput.value.trim() : '',
|
||
tags,
|
||
customFields
|
||
};
|
||
|
||
console.log('[SaveProfile] Request body:', JSON.stringify(requestBody, null, 2));
|
||
console.log('[SaveProfile] Sending PUT request to', `${API_BASE}/profile`);
|
||
|
||
const fetchStartTime = Date.now();
|
||
const response = await authenticatedFetch(`${API_BASE}/profile`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(requestBody)
|
||
});
|
||
|
||
console.log('[SaveProfile] Response received after', Date.now() - fetchStartTime, 'ms, status:', response.status);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error('[SaveProfile] Error response:', errorText);
|
||
throw new Error('Failed to save profile: ' + errorText);
|
||
}
|
||
|
||
console.log('[SaveProfile] Parsing response JSON...');
|
||
const updatedProfile = await response.json();
|
||
console.log('[SaveProfile] Updated profile received:', updatedProfile);
|
||
currentProfile = updatedProfile;
|
||
|
||
console.log('[SaveProfile] Save completed in', Date.now() - startTime, 'ms');
|
||
showToast('Profile saved successfully', 'success');
|
||
|
||
// Profile update will be handled via WebSocket broadcast
|
||
} catch (err) {
|
||
console.error('[SaveProfile] Error saving profile after', Date.now() - startTime, 'ms:', err);
|
||
showToast('Failed to save profile', 'error');
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
btnText.style.display = 'inline';
|
||
btnLoader.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Upload avatar
|
||
*/
|
||
async function uploadAvatar(file) {
|
||
const avatarUpload = document.getElementById('avatar-upload');
|
||
const UPLOAD_TIMEOUT_MS = 120000;
|
||
|
||
try {
|
||
// Validate file type
|
||
if (!file.type.startsWith('image/')) {
|
||
showToast('Please select an image file', 'error');
|
||
return;
|
||
}
|
||
|
||
// Validate file size (max 5MB)
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
showToast('Image size must be less than 5MB', 'error');
|
||
return;
|
||
}
|
||
|
||
showToast('Uploading avatar...', 'success');
|
||
if (avatarUpload) {
|
||
avatarUpload.disabled = true;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('avatar', file);
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||
|
||
let response;
|
||
try {
|
||
response = await authenticatedFetch(`${API_BASE}/profile/avatar`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
signal: controller.signal
|
||
});
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
let errorMessage = 'Failed to upload avatar';
|
||
try {
|
||
const errorJson = JSON.parse(errorText);
|
||
errorMessage = errorJson.error || errorJson.message || errorMessage;
|
||
} catch (e) {
|
||
errorMessage = errorText || errorMessage;
|
||
}
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Update avatar preview (use 128x128 for preview)
|
||
const avatarImg = document.getElementById('avatar-preview');
|
||
if (peerId) {
|
||
avatarImg.src = getAvatarUrl(peerId, 128) + '?t=' + Date.now();
|
||
avatarImg.style.display = 'block';
|
||
document.getElementById('avatar-placeholder').style.display = 'none';
|
||
}
|
||
|
||
// Update current profile
|
||
if (currentProfile) {
|
||
currentProfile.avatarHash = result.avatarHash;
|
||
}
|
||
|
||
showToast('Avatar uploaded successfully', 'success');
|
||
|
||
// Avatar update will be handled via WebSocket broadcast
|
||
} catch (err) {
|
||
console.error('Error uploading avatar:', err);
|
||
if (err.name === 'AbortError') {
|
||
showToast('Avatar upload timed out. Try a smaller image or retry in a moment.', 'error');
|
||
} else {
|
||
showToast(err.message || 'Failed to upload avatar', 'error');
|
||
}
|
||
} finally {
|
||
if (avatarUpload) {
|
||
avatarUpload.disabled = false;
|
||
avatarUpload.value = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show toast notification
|
||
*/
|
||
function showToast(message, type = 'success') {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.className = `toast ${type} show`;
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
}, 3000);
|
||
}
|
||
|
||
/**
|
||
* Escape HTML to prevent XSS
|
||
*/
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Tag Editor Functions
|
||
*/
|
||
let currentTags = [];
|
||
|
||
/**
|
||
* Update the tag editor display
|
||
*/
|
||
function updateTagEditor(tags) {
|
||
currentTags = [...tags];
|
||
const tagList = document.getElementById('tag-list');
|
||
if (!tagList) return;
|
||
|
||
tagList.innerHTML = currentTags.map(tag => `
|
||
<span class="tag-editor-badge">
|
||
${escapeHtml(tag)}
|
||
<button type="button" class="tag-remove" data-tag="${escapeHtml(tag)}" aria-label="Remove tag">×</button>
|
||
</span>
|
||
`).join('');
|
||
|
||
// Add event listeners to remove buttons
|
||
tagList.querySelectorAll('.tag-remove').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tagToRemove = btn.getAttribute('data-tag');
|
||
removeTag(tagToRemove);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Add a tag to the editor
|
||
*/
|
||
function addTag(tag) {
|
||
const trimmedTag = tag.trim();
|
||
if (!trimmedTag) return false;
|
||
|
||
// Check if tag already exists (case-insensitive)
|
||
if (currentTags.some(t => t.toLowerCase() === trimmedTag.toLowerCase())) {
|
||
return false;
|
||
}
|
||
|
||
// Limit tag length
|
||
if (trimmedTag.length > 30) {
|
||
showToast('Tag is too long (max 30 characters)', 'error');
|
||
return false;
|
||
}
|
||
|
||
// Limit number of tags
|
||
if (currentTags.length >= 10) {
|
||
showToast('Maximum 10 tags allowed', 'error');
|
||
return false;
|
||
}
|
||
|
||
currentTags.push(trimmedTag);
|
||
updateTagEditor(currentTags);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Remove a tag from the editor
|
||
*/
|
||
function removeTag(tag) {
|
||
currentTags = currentTags.filter(t => t !== tag);
|
||
updateTagEditor(currentTags);
|
||
}
|
||
|
||
/**
|
||
* Get tags array from editor
|
||
*/
|
||
function getTagsFromEditor() {
|
||
return [...currentTags];
|
||
}
|
||
|
||
/**
|
||
* Initialize tag editor
|
||
*/
|
||
function initTagEditor() {
|
||
const tagsInput = document.getElementById('tags-input');
|
||
if (!tagsInput) return;
|
||
|
||
// Handle Enter key to add tag
|
||
tagsInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const tag = tagsInput.value.trim();
|
||
if (tag) {
|
||
if (addTag(tag)) {
|
||
tagsInput.value = '';
|
||
} else {
|
||
showToast('Tag already exists', 'error');
|
||
}
|
||
}
|
||
} else if (e.key === 'Backspace' && tagsInput.value === '' && currentTags.length > 0) {
|
||
// Remove last tag if input is empty and backspace is pressed
|
||
removeTag(currentTags[currentTags.length - 1]);
|
||
}
|
||
});
|
||
|
||
// Handle paste to add multiple tags
|
||
tagsInput.addEventListener('paste', (e) => {
|
||
e.preventDefault();
|
||
const pastedText = (e.clipboardData || window.clipboardData).getData('text');
|
||
const tags = pastedText.split(/[,\n]/).map(t => t.trim()).filter(t => t);
|
||
let added = 0;
|
||
tags.forEach(tag => {
|
||
if (addTag(tag)) {
|
||
added++;
|
||
}
|
||
});
|
||
if (added > 0) {
|
||
tagsInput.value = '';
|
||
if (added < tags.length) {
|
||
showToast(`Added ${added} tag(s), some were duplicates`, 'success');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Delete profile
|
||
*/
|
||
async function deleteProfile() {
|
||
// Confirm deletion
|
||
const confirmed = confirm('Are you sure you want to delete your profile? This action cannot be undone. Your display name, avatar, and all profile information will be permanently removed.');
|
||
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
const deleteBtn = document.getElementById('delete-btn');
|
||
const originalText = deleteBtn.textContent;
|
||
|
||
try {
|
||
deleteBtn.disabled = true;
|
||
deleteBtn.textContent = 'Deleting...';
|
||
|
||
const response = await authenticatedFetch(`${API_BASE}/profile`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to delete profile');
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
showToast('Profile deleted successfully', 'success');
|
||
|
||
// Reset form to default state
|
||
currentProfile = null;
|
||
document.getElementById('display-name').value = '';
|
||
document.getElementById('bio').value = '';
|
||
document.getElementById('peer-id').value = '';
|
||
|
||
// Reset additional fields
|
||
populateField('email', null, false);
|
||
populateField('website', null, false);
|
||
populateField('xUsername', null, false);
|
||
|
||
// Reset tags
|
||
updateTagEditor([]);
|
||
|
||
// Reset avatar
|
||
document.getElementById('avatar-preview').style.display = 'none';
|
||
document.getElementById('avatar-placeholder').style.display = 'block';
|
||
|
||
// Profile deletion will be handled via WebSocket broadcast
|
||
} catch (err) {
|
||
console.error('Error deleting profile:', err);
|
||
showToast('Failed to delete profile', 'error');
|
||
} finally {
|
||
deleteBtn.disabled = false;
|
||
deleteBtn.textContent = originalText;
|
||
}
|
||
}
|
||
|
||
// Initialize app when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|