From 250ebb9f992c04edfa778eba92886397f0fa02cc Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 19 Feb 2026 16:54:58 +0300 Subject: [PATCH] feat(social): profiles feature --- index.html | 285 ++++++++---- js/accounts/pocketbase.js | 58 ++- js/app.js | 78 ++++ js/profile.js | 923 ++++++++++++++++++++++++++++++++++++++ js/router.js | 6 + js/settings.js | 31 +- js/storage.js | 16 - package-lock.json | 15 - self-hosted-database.md | 15 +- styles.css | 267 +++++++++++ 10 files changed, 1556 insertions(+), 138 deletions(-) create mode 100644 js/profile.js diff --git a/index.html b/index.html index 5925d9a..7be8595 100644 --- a/index.html +++ b/index.html @@ -734,6 +734,108 @@ + + + + +
+
+ Clear Cloud Data + Delete all your data from the cloud (cannot be undone) +
+ +
Backup & Restore @@ -4400,46 +4554,9 @@ > - +
-
-

Email Authentication

- - -
- - -
- - -

Sync your library across devices

diff --git a/js/accounts/pocketbase.js b/js/accounts/pocketbase.js index 9056985..a217b09 100644 --- a/js/accounts/pocketbase.js +++ b/js/accounts/pocketbase.js @@ -64,8 +64,22 @@ const syncManager = { const history = this.safeParseInternal(record.history, 'history', []); const userPlaylists = this.safeParseInternal(record.user_playlists, 'user_playlists', {}); const userFolders = this.safeParseInternal(record.user_folders, 'user_folders', {}); + const favoriteAlbums = this.safeParseInternal(record.favorite_albums, 'favorite_albums', []); - return { library, history, userPlaylists, userFolders }; + const profile = { + username: record.username, + display_name: record.display_name, + avatar_url: record.avatar_url, + banner: record.banner, + status: record.status, + about: record.about, + website: record.website, + privacy: this.safeParseInternal(record.privacy, 'privacy', { playlists: 'public', lastfm: 'public' }), + lastfm_username: record.lastfm_username, + favorite_albums: favoriteAlbums, + }; + + return { library, history, userPlaylists, userFolders, profile }; }, async _updateUserJSON(uid, field, data) { @@ -434,6 +448,48 @@ const syncManager = { } }, + async getProfile(username) { + try { + const record = await this.pb.collection('DB_users').getFirstListItem(`username="${username}"`, { + fields: 'username,display_name,avatar_url,banner,status,about,website,lastfm_username,privacy,user_playlists,favorite_albums', + }); + return { + ...record, + privacy: this.safeParseInternal(record.privacy, 'privacy', { playlists: 'public', lastfm: 'public' }), + user_playlists: this.safeParseInternal(record.user_playlists, 'user_playlists', {}), + favorite_albums: this.safeParseInternal(record.favorite_albums, 'favorite_albums', []), + }; + } catch (error) { + return null; + } + }, + + async updateProfile(data) { + const user = authManager.user; + if (!user) return; + const record = await this._getUserRecord(user.uid); + if (!record) return; + + const updateData = { ...data }; + if (updateData.privacy) { + updateData.privacy = JSON.stringify(updateData.privacy); + } + + await this.pb.collection('DB_users').update(record.id, updateData, { f_id: user.uid }); + if (this._userRecordCache) { + this._userRecordCache = { ...this._userRecordCache, ...updateData }; + } + }, + + async isUsernameTaken(username) { + try { + const list = await this.pb.collection('DB_users').getList(1, 1, { filter: `username="${username}"` }); + return list.totalItems > 0; + } catch (e) { + return false; + } + }, + async clearCloudData() { const user = authManager.user; if (!user) return; diff --git a/js/app.js b/js/app.js index 984cd72..cf29cc9 100644 --- a/js/app.js +++ b/js/app.js @@ -20,8 +20,10 @@ import { debounce, SVG_PLAY, getShareUrl } from './utils.js'; import { sidePanelManager } from './side-panel.js'; import { db } from './db.js'; import { syncManager } from './accounts/pocketbase.js'; +import { authManager } from './accounts/auth.js'; import { registerSW } from 'virtual:pwa-register'; import './smooth-scrolling.js'; +import { openEditProfile } from './profile.js'; import { initTracker } from './tracker.js'; import { @@ -325,6 +327,7 @@ document.addEventListener('DOMContentLoaded', async () => { const currentQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS'; const player = new Player(audioPlayer, api, currentQuality); + window.monochromePlayer = player; // Initialize tracker initTracker(player); @@ -2248,6 +2251,81 @@ document.addEventListener('DOMContentLoaded', async () => { observer.observe(contextMenu, { attributes: true }); } + + const headerAccountBtn = document.getElementById('header-account-btn'); + const headerAccountDropdown = document.getElementById('header-account-dropdown'); + const headerAccountImg = document.getElementById('header-account-img'); + const headerAccountIcon = document.getElementById('header-account-icon'); + + if (headerAccountBtn && headerAccountDropdown) { + headerAccountBtn.addEventListener('click', (e) => { + e.stopPropagation(); + headerAccountDropdown.classList.toggle('active'); + updateAccountDropdown(); + }); + + document.addEventListener('click', (e) => { + if (!headerAccountBtn.contains(e.target) && !headerAccountDropdown.contains(e.target)) { + headerAccountDropdown.classList.remove('active'); + } + }); + + async function updateAccountDropdown() { + const user = authManager?.user; + headerAccountDropdown.innerHTML = ''; + + if (!user) { + headerAccountDropdown.innerHTML = ` + + + `; + document.getElementById('header-google-auth').onclick = () => authManager.signInWithGoogle(); + document.getElementById('header-email-auth').onclick = () => { + document.getElementById('email-auth-modal').classList.add('active'); + headerAccountDropdown.classList.remove('active'); + }; + } else { + const data = await syncManager.getUserData(); + const hasProfile = data && data.profile && data.profile.username; + + if (hasProfile) { + headerAccountDropdown.innerHTML = ` + + + `; + document.getElementById('header-view-profile').onclick = () => { + navigate(`/user/@${data.profile.username}`); + headerAccountDropdown.classList.remove('active'); + }; + } else { + headerAccountDropdown.innerHTML = ` + + + `; + document.getElementById('header-create-profile').onclick = () => { + openEditProfile(); + headerAccountDropdown.classList.remove('active'); + }; + } + + document.getElementById('header-sign-out').onclick = () => authManager.signOut(); + } + } + + authManager.onAuthStateChanged(async (user) => { + if (user) { + const data = await syncManager.getUserData(); + if (data && data.profile && data.profile.avatar_url) { + headerAccountImg.src = data.profile.avatar_url; + headerAccountImg.style.display = 'block'; + headerAccountIcon.style.display = 'none'; + return; + } + } + headerAccountImg.style.display = 'none'; + headerAccountIcon.style.display = 'block'; + }); + } }); function showUpdateNotification(updateCallback) { diff --git a/js/profile.js b/js/profile.js new file mode 100644 index 0000000..3a64b96 --- /dev/null +++ b/js/profile.js @@ -0,0 +1,923 @@ +import { syncManager } from './accounts/pocketbase.js'; +import { authManager } from './accounts/auth.js'; +import { navigate } from './router.js'; +import { MusicAPI } from './music-api.js'; +import { apiSettings } from './storage.js'; +import { debounce, escapeHtml } from './utils.js'; + + +// objects execution february 29th 2027 + +const profilePage = document.getElementById('page-profile'); +const editProfileModal = document.getElementById('edit-profile-modal'); +const editProfileBtn = document.getElementById('profile-edit-btn'); +const viewMyProfileBtn = document.getElementById('view-my-profile-btn'); + +const editUsername = document.getElementById('edit-profile-username'); +const editDisplayName = document.getElementById('edit-profile-display-name'); +const editAvatar = document.getElementById('edit-profile-avatar'); +const editBanner = document.getElementById('edit-profile-banner'); +const editStatusSearch = document.getElementById('edit-profile-status-search'); +const editStatusJson = document.getElementById('edit-profile-status-json'); +const statusSearchResults = document.getElementById('status-search-results'); +const statusPreview = document.getElementById('status-preview'); +const clearStatusBtn = document.getElementById('clear-status-btn'); +const editFavoriteAlbumsList = document.getElementById('edit-favorite-albums-list'); +const editFavoriteAlbumsSearch = document.getElementById('edit-favorite-albums-search'); +const editFavoriteAlbumsResults = document.getElementById('edit-favorite-albums-results'); +const editAbout = document.getElementById('edit-profile-about'); +const editWebsite = document.getElementById('edit-profile-website'); +const editLastfm = document.getElementById('edit-profile-lastfm'); +const privacyPlaylists = document.getElementById('privacy-playlists-toggle'); +const privacyLastfm = document.getElementById('privacy-lastfm-toggle'); +const saveProfileBtn = document.getElementById('edit-profile-save'); +const cancelProfileBtn = document.getElementById('edit-profile-cancel'); +const usernameError = document.getElementById('username-error'); + +let currentProfileUsername = null; +let currentFavoriteAlbums = []; +const api = new MusicAPI(apiSettings); + +async function uploadImage(file) { + try { + const formData = new FormData(); + formData.append('file', file); + const response = await fetch('/upload', { method: 'POST', body: formData }); + if (!response.ok) throw new Error(`Upload failed: ${response.status}`); + const data = await response.json(); + return data.url; + } catch (error) { + console.error('Upload error:', error); + throw error; + } +} + +function setupImageUploadControl(idPrefix) { + const urlInput = document.getElementById(idPrefix); + const fileInput = document.getElementById(idPrefix + '-file'); + const uploadBtn = document.getElementById(idPrefix + '-upload-btn'); + const toggleBtn = document.getElementById(idPrefix + '-toggle-btn'); + const statusEl = document.getElementById(idPrefix + '-upload-status'); + + if (!urlInput || !fileInput || !uploadBtn || !toggleBtn || !statusEl) return () => {}; + + let useUrl = false; + + function updateUI() { + if (useUrl) { + uploadBtn.style.display = 'none'; + urlInput.style.display = 'block'; + toggleBtn.textContent = 'Upload'; + } else { + uploadBtn.style.display = 'flex'; + urlInput.style.display = 'none'; + toggleBtn.textContent = 'or URL'; + } + } + + toggleBtn.addEventListener('click', () => { + useUrl = !useUrl; + updateUI(); + }); + + uploadBtn.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + + statusEl.style.display = 'block'; + statusEl.textContent = 'Uploading...'; + statusEl.style.color = 'var(--muted-foreground)'; + uploadBtn.disabled = true; + + try { + const url = await uploadImage(file); + urlInput.value = url; + statusEl.textContent = 'Done!'; + statusEl.style.color = '#10b981'; + setTimeout(() => { statusEl.style.display = 'none'; }, 2000); + } catch (error) { + statusEl.textContent = 'Failed - try URL'; + statusEl.style.color = '#ef4444'; + } finally { + uploadBtn.disabled = false; + fileInput.value = ''; + } + }); + + return (currentUrl) => { + urlInput.value = currentUrl || ''; + useUrl = !!currentUrl; + updateUI(); + statusEl.style.display = 'none'; + }; +} + +const resetAvatarControl = setupImageUploadControl('edit-profile-avatar'); +const resetBannerControl = setupImageUploadControl('edit-profile-banner'); + +export async function loadProfile(username) { + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + profilePage.classList.add('active'); + + document.getElementById('profile-banner').style.backgroundImage = ''; + document.getElementById('profile-avatar').src = '/assets/appicon.png'; + document.getElementById('profile-display-name').textContent = 'Loading...'; + document.getElementById('profile-username').textContent = '@' + username; + document.getElementById('profile-status').style.display = 'none'; + document.getElementById('profile-about').textContent = ''; + document.getElementById('profile-website').style.display = 'none'; + document.getElementById('profile-lastfm').style.display = 'none'; + document.getElementById('profile-playlists-container').innerHTML = ''; + + const favAlbumsSection = document.getElementById('profile-favorite-albums-section'); + const favAlbumsContainer = document.getElementById('profile-favorite-albums-container'); + if (favAlbumsSection) favAlbumsSection.style.display = 'none'; + if (favAlbumsContainer) favAlbumsContainer.innerHTML = ''; + + const recentSection = document.getElementById('profile-recent-scrobbles-section'); + const recentContainer = document.getElementById('profile-recent-scrobbles-container'); + if (recentSection) recentSection.style.display = 'none'; + if (recentContainer) recentContainer.innerHTML = ''; + + const topArtistsSection = document.getElementById('profile-top-artists-section'); + const topArtistsContainer = document.getElementById('profile-top-artists-container'); + const topAlbumsSection = document.getElementById('profile-top-albums-section'); + const topAlbumsContainer = document.getElementById('profile-top-albums-container'); + const topTracksSection = document.getElementById('profile-top-tracks-section'); + const topTracksContainer = document.getElementById('profile-top-tracks-container'); + + if (topArtistsSection) topArtistsSection.style.display = 'none'; + if (topArtistsContainer) topArtistsContainer.innerHTML = ''; + if (topAlbumsSection) topAlbumsSection.style.display = 'none'; + if (topAlbumsContainer) topAlbumsContainer.innerHTML = ''; + if (topTracksSection) topTracksSection.style.display = 'none'; + if (topTracksContainer) topTracksContainer.innerHTML = ''; + + editProfileBtn.style.display = 'none'; + + const profile = await syncManager.getProfile(username); + + if (!profile) { + document.getElementById('profile-display-name').textContent = 'User not found'; + return; + } + + currentProfileUsername = username; + + document.getElementById('profile-display-name').textContent = profile.display_name || username; + if (profile.banner) document.getElementById('profile-banner').style.backgroundImage = `url('${profile.banner}')`; + if (profile.avatar_url) document.getElementById('profile-avatar').src = profile.avatar_url; + + if (profile.status) { + const statusEl = document.getElementById('profile-status'); + try { + const statusObj = JSON.parse(profile.status); + statusEl.innerHTML = ` + Listening to: + + ${statusObj.text} + `; + statusEl.querySelector('.status-link').onclick = (e) => { e.preventDefault(); navigate(statusObj.link); }; + } catch { + statusEl.textContent = `Listening to: ${profile.status}`; + } + statusEl.style.display = 'inline-flex'; + } + + if (profile.about) { + document.getElementById('profile-about').textContent = profile.about; + } + + if (profile.website) { + const webEl = document.getElementById('profile-website'); + webEl.href = profile.website; + webEl.style.display = 'inline-block'; + } + + if (profile.favorite_albums && profile.favorite_albums.length > 0) { + if (favAlbumsSection && favAlbumsContainer) { + favAlbumsSection.style.display = 'block'; + favAlbumsContainer.innerHTML = profile.favorite_albums.map(album => { + const image = api.getCoverUrl(album.cover); + return ` +
+
+
+ +
+
+
${escapeHtml(album.title)}
+
${escapeHtml(album.artist)}
+
+
+
+

Why it's a favorite

+

${escapeHtml(album.description || '')}

+
+
+ `; + }).join(''); + } + } + + const dataSource = profile.profile_data_source || (profile.lastfm_username ? 'lastfm' : null); + + + if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') { + const lfmEl = document.getElementById('profile-lastfm'); + lfmEl.href = `https://last.fm/user/${profile.lastfm_username}`; + lfmEl.style.display = 'inline-block'; + } + + if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') { + fetchLastFmRecentTracks(profile.lastfm_username).then(async tracks => { + if (tracks.length > 0) { + recentSection.style.display = 'block'; + recentContainer.innerHTML = tracks.map((track, index) => { + const isNowPlaying = track['@attr']?.nowplaying === 'true'; + let image = getLastFmImage(track.image); + const hasImage = !!image; + if (!image) image = '/assets/appicon.png'; + + track._imgId = `scrobble-img-${index}`; + track._needsCover = !hasImage; + + let dateDisplay = ''; + if (isNowPlaying) dateDisplay = 'Scrobbling now'; + else if (track.date) { + const date = new Date(track.date.uts * 1000); + dateDisplay = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + } + + return ` +
+ +
+
+
${track.name}
+
${track.artist?.['#text'] || track.artist?.name || track.artist || 'Unknown Artist'}
+
+
+
${dateDisplay}
+
+ `; + }).join(''); + + recentContainer.querySelectorAll('.track-item').forEach(item => { + item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist)); + item.addEventListener('contextmenu', (e) => { e.preventDefault(); return false; }); + }); + + for (const track of tracks) { + if (track._needsCover) { + fetchFallbackCover(track.name, track.artist?.['#text'] || track.artist?.name, track._imgId); + } + } + } + }); + + fetchLastFmTopArtists(profile.lastfm_username).then(async artists => { + if (artists.length > 0 && topArtistsSection && topArtistsContainer) { + topArtistsSection.style.display = 'block'; + topArtistsContainer.innerHTML = artists.map((artist, index) => { + let image = getLastFmImage(artist.image); + const hasImage = !!image; + if (!image) image = '/assets/appicon.png'; + + const imgId = `top-artist-img-${index}`; + artist._imgId = imgId; + artist._needsCover = !hasImage; + + return ` +
+
+ +
+
+
${artist.name}
+
${parseInt(artist.playcount).toLocaleString()} plays
+
+
+ `; + }).join(''); + + topArtistsContainer.querySelectorAll('.card').forEach(card => { + card.addEventListener('click', () => handleArtistClick(card.dataset.name)); + card.addEventListener('contextmenu', (e) => { e.preventDefault(); return false; }); + }); + + for (const artist of artists) { + if (artist._needsCover) { + fetchFallbackArtistImage(artist.name, artist._imgId); + } + } + } + }); + + fetchLastFmTopAlbums(profile.lastfm_username).then(async albums => { + if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) { + topAlbumsSection.style.display = 'block'; + topAlbumsContainer.innerHTML = albums.map((album, index) => { + let image = getLastFmImage(album.image); + const hasImage = !!image; + if (!image) image = '/assets/appicon.png'; + + const imgId = `top-album-img-${index}`; + album._imgId = imgId; + album._needsCover = !hasImage; + + const artistName = album.artist?.name || album.artist?.['#text'] || (typeof album.artist === 'string' ? album.artist : 'Unknown Artist'); + album._artistName = artistName; + + return ` +
+
+ +
+
+
${album.name}
+
${artistName}
+
+
+ `; + }).join(''); + + topAlbumsContainer.querySelectorAll('.card').forEach(card => { + card.addEventListener('click', () => handleAlbumClick(card.dataset.name, card.dataset.artist)); + card.addEventListener('contextmenu', (e) => { e.preventDefault(); return false; }); + }); + + for (const album of albums) { + if (album._needsCover) { + fetchFallbackAlbumCover(album.name, album._artistName, album._imgId); + } + } + } + }); + + fetchLastFmTopTracks(profile.lastfm_username).then(async tracks => { + if (tracks.length > 0 && topTracksSection && topTracksContainer) { + topTracksSection.style.display = 'block'; + topTracksContainer.innerHTML = tracks.map((track, index) => { + let image = getLastFmImage(track.image); + const hasImage = !!image; + if (!image) image = '/assets/appicon.png'; + + const imgId = `top-track-img-${index}`; + track._imgId = imgId; + track._needsCover = !hasImage; + + const artistName = track.artist?.name || track.artist?.['#text'] || (typeof track.artist === 'string' ? track.artist : 'Unknown Artist'); + track._artistName = artistName; + + return ` +
+ +
+
+
${track.name}
+
${artistName}
+
+
+
${parseInt(track.playcount).toLocaleString()} plays
+
+ `; + }).join(''); + + topTracksContainer.querySelectorAll('.track-item').forEach(item => { + item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist)); + item.addEventListener('contextmenu', (e) => { e.preventDefault(); return false; }); + }); + + for (const track of tracks) { + if (track._needsCover) { + fetchFallbackCover(track.name, track._artistName, track._imgId); + } + } + } + }); + } + + const currentUser = await syncManager.getUserData(); + const isOwner = currentUser && currentUser.profile && currentUser.profile.username === username; + + if (isOwner) { + editProfileBtn.style.display = 'inline-flex'; + } + + if (profile.privacy?.playlists !== 'private' || isOwner) { + const container = document.getElementById('profile-playlists-container'); + const playlists = profile.user_playlists || {}; + + Object.values(playlists).forEach(playlist => { + if (!playlist.isPublic && !isOwner) return; + + const card = document.createElement('div'); + card.className = 'card'; + card.innerHTML = ` +
+ ${playlist.name} +
+
+
${playlist.name}
+
${playlist.numberOfTracks || 0} tracks
+
+ `; + card.onclick = () => { + window.location.hash = `/userplaylist/${playlist.id}`; + }; + container.appendChild(card); + }); + + if (container.children.length === 0) { + container.innerHTML = '

No public playlists.

'; + } + } +} + +export function openEditProfile() { + syncManager.getUserData().then(data => { + if (!data || !data.profile) return; + const p = data.profile; + + editUsername.value = p.username || ''; + editDisplayName.value = p.display_name || ''; + resetAvatarControl(p.avatar_url); + resetBannerControl(p.banner); + + editStatusJson.value = p.status || ''; + editStatusSearch.value = ''; + if (p.status) { + try { + const statusObj = JSON.parse(p.status); + showStatusPreview(statusObj); + } catch { + if (p.status.trim()) { + editStatusSearch.value = p.status; + hideStatusPreview(); + } + } + } else { + hideStatusPreview(); + } + + currentFavoriteAlbums = p.favorite_albums || []; + renderEditFavoriteAlbums(); + editFavoriteAlbumsSearch.value = ''; + editFavoriteAlbumsResults.style.display = 'none'; + + editAbout.value = p.about || ''; + editWebsite.value = p.website || ''; + editLastfm.value = p.lastfm_username || ''; + + privacyPlaylists.checked = p.privacy?.playlists !== 'private'; + privacyLastfm.checked = p.privacy?.lastfm !== 'private'; + + editProfileModal.classList.add('active'); + }); +} + +async function saveProfile() { + const newUsername = editUsername.value.trim(); + if (!newUsername) { + usernameError.textContent = "Username cannot be empty"; + usernameError.style.display = 'block'; + return; + } + + const currentUser = await syncManager.getUserData(); + if (currentUser.profile.username !== newUsername) { + const taken = await syncManager.isUsernameTaken(newUsername); + if (taken) { + usernameError.textContent = "Username is already taken"; + usernameError.style.display = 'block'; + return; + } + } + + usernameError.style.display = 'none'; + saveProfileBtn.disabled = true; + saveProfileBtn.textContent = 'Saving...'; + + const data = { + username: newUsername, + display_name: editDisplayName.value.trim(), + avatar_url: editAvatar.value.trim(), + banner: editBanner.value.trim(), + status: editStatusJson.value.trim() || (editStatusSearch.value.trim() ? editStatusSearch.value.trim() : ''), + about: editAbout.value.trim(), + website: editWebsite.value.trim(), + favorite_albums: currentFavoriteAlbums, + lastfm_username: editLastfm.value.trim(), + privacy: { + playlists: privacyPlaylists.checked ? 'public' : 'private', + lastfm: privacyLastfm.checked ? 'public' : 'private' + } + }; + + try { + await syncManager.updateProfile(data); + editProfileModal.classList.remove('active'); + loadProfile(newUsername); + + if (window.location.pathname.includes('/user/@')) { + window.history.replaceState(null, '', `/user/@${newUsername}`); + } + } catch (e) { + alert('Failed to save profile. See console.'); + console.error(e); + } finally { + saveProfileBtn.disabled = false; + saveProfileBtn.textContent = 'Save Profile'; + } +} + +editProfileBtn.addEventListener('click', openEditProfile); +cancelProfileBtn.addEventListener('click', () => editProfileModal.classList.remove('active')); +saveProfileBtn.addEventListener('click', saveProfile); + +viewMyProfileBtn.addEventListener('click', async () => { + const data = await syncManager.getUserData(); + if (data && data.profile && data.profile.username) { + navigate(`/user/@${data.profile.username}`); + } else { + openEditProfile(); + } +}); + +authManager.onAuthStateChanged(user => { + viewMyProfileBtn.style.display = user ? 'inline-block' : 'none'; +}); + +function showStatusPreview(data) { + document.getElementById('status-preview-img').src = data.image; + document.getElementById('status-preview-title').textContent = data.title; + document.getElementById('status-preview-subtitle').textContent = data.subtitle; + statusPreview.style.display = 'flex'; + editStatusSearch.style.display = 'none'; +} + +function hideStatusPreview() { + statusPreview.style.display = 'none'; + editStatusSearch.style.display = 'block'; + editStatusJson.value = ''; +} + +clearStatusBtn.addEventListener('click', () => { + hideStatusPreview(); + editStatusSearch.value = ''; + editStatusSearch.focus(); +}); + +const performStatusSearch = debounce(async (query) => { + if (!query) { + statusSearchResults.style.display = 'none'; + return; + } + + try { + const [tracks, albums] = await Promise.all([ + api.searchTracks(query, { limit: 3 }), + api.searchAlbums(query, { limit: 3 }) + ]); + + statusSearchResults.innerHTML = ''; + + const createItem = (item, type) => { + const div = document.createElement('div'); + div.className = 'search-result-item'; + const title = item.title; + const subtitle = type === 'track' ? (item.artist?.name || 'Unknown Artist') : (item.artist?.name || 'Unknown Artist'); + const image = api.getCoverUrl(item.album?.cover || item.cover); + + div.innerHTML = ` + +
+
${title}
+
${type === 'track' ? 'Song' : 'Album'} • ${subtitle}
+
+ `; + + div.onclick = () => { + const data = { + type: type, + id: item.id, + text: `${title} - ${subtitle}`, + title: title, + subtitle: subtitle, + image: image, + link: `/${type}/${item.id}` + }; + editStatusJson.value = JSON.stringify(data); + showStatusPreview(data); + statusSearchResults.style.display = 'none'; + }; + return div; + }; + + tracks.items.forEach(t => statusSearchResults.appendChild(createItem(t, 'track'))); + albums.items.forEach(a => statusSearchResults.appendChild(createItem(a, 'album'))); + + statusSearchResults.style.display = (tracks.items.length || albums.items.length) ? 'block' : 'none'; + } catch (e) { + console.error('Status search failed', e); + } +}, 300); + +editStatusSearch.addEventListener('input', (e) => performStatusSearch(e.target.value.trim())); +document.addEventListener('click', (e) => { + if (!e.target.closest('.status-picker-container')) { + statusSearchResults.style.display = 'none'; + } +}); + +function renderEditFavoriteAlbums() { + editFavoriteAlbumsList.innerHTML = currentFavoriteAlbums.map((album, index) => ` +
+
+ +
+
${escapeHtml(album.title)}
+
${escapeHtml(album.artist)}
+
+ +
+ +
+ `).join(''); + + editFavoriteAlbumsList.querySelectorAll('.remove-album-btn').forEach(btn => { + btn.onclick = () => { + const idx = parseInt(btn.dataset.index); + currentFavoriteAlbums.splice(idx, 1); + renderEditFavoriteAlbums(); + }; + }); + + editFavoriteAlbumsList.querySelectorAll('.album-description-input').forEach(input => { + input.oninput = () => { + const idx = parseInt(input.dataset.index); + currentFavoriteAlbums[idx].description = input.value; + }; + }); + + if (currentFavoriteAlbums.length >= 5) { + editFavoriteAlbumsSearch.disabled = true; + editFavoriteAlbumsSearch.placeholder = "Max 5 albums reached"; + } else { + editFavoriteAlbumsSearch.disabled = false; + editFavoriteAlbumsSearch.placeholder = "Search for an album..."; + } +} + +const performFavoriteAlbumSearch = debounce(async (query) => { + if (!query || currentFavoriteAlbums.length >= 5) { + editFavoriteAlbumsResults.style.display = 'none'; + return; + } + + try { + const results = await api.searchAlbums(query, { limit: 5 }); + editFavoriteAlbumsResults.innerHTML = ''; + + if (results.items.length === 0) { + editFavoriteAlbumsResults.style.display = 'none'; + return; + } + + results.items.forEach(album => { + const div = document.createElement('div'); + div.className = 'search-result-item'; + const image = api.getCoverUrl(album.cover); + + div.innerHTML = ` + +
+
${album.title}
+
${album.artist?.name || 'Unknown Artist'}
+
+ `; + + div.onclick = () => { + currentFavoriteAlbums.push({ + id: album.id, + title: album.title, + artist: album.artist?.name || 'Unknown Artist', + cover: album.cover, + description: '' + }); + renderEditFavoriteAlbums(); + editFavoriteAlbumsSearch.value = ''; + editFavoriteAlbumsResults.style.display = 'none'; + }; + editFavoriteAlbumsResults.appendChild(div); + }); + + editFavoriteAlbumsResults.style.display = 'block'; + } catch (e) { + console.error('Album search failed', e); + } +}, 300); + +editFavoriteAlbumsSearch.addEventListener('input', (e) => performFavoriteAlbumSearch(e.target.value.trim())); + + +function getLastFmImage(images) { + if (!images) return null; + const imgArray = Array.isArray(images) ? images : [images]; + const sizes = ['extralarge', 'large', 'medium', 'small']; + + const placeholders = [ + '2a96cbd8b46e442fc41c2b86b821562f', + 'c6f59c1e5e7240a4c0d427abd71f3dbb' + ]; + + const isValidUrl = (url) => { + if (!url) return false; + return !placeholders.some(ph => url.includes(ph)); + }; + + for (const size of sizes) { + const img = imgArray.find(i => i.size === size); + if (img && img['#text'] && isValidUrl(img['#text'])) return img['#text']; + } + const anyImg = imgArray.find(i => i['#text'] && isValidUrl(i['#text'])); + if (anyImg) return anyImg['#text']; + return null; +} + +async function handleArtistClick(name) { + try { + const results = await api.searchArtists(name, { limit: 1 }); + if (results.items.length > 0) { + navigate(`/artist/${results.items[0].id}`); + } else { + alert('Artist not found in library'); + } + } catch (e) { + console.error(e); + } +} + +async function handleAlbumClick(name, artist) { + try { + const query = `${name} ${artist}`; + const results = await api.searchAlbums(query, { limit: 1 }); + if (results.items.length > 0) { + navigate(`/album/${results.items[0].id}`); + } else { + alert('Album not found in library'); + } + } catch (e) { + console.error(e); + } +} + +async function handleTrackClick(title, artist) { + try { + const query = `${title} ${artist}`; + const results = await api.searchTracks(query, { limit: 1 }); + if (results.items.length > 0) { + const track = results.items[0]; + if (window.monochromePlayer) { + window.monochromePlayer.setQueue([track], 0); + window.monochromePlayer.playTrackFromQueue(); + } + } else { + alert('Track not found'); + } + } catch (e) { + console.error(e); + } +} + +async function fetchFallbackCover(title, artist, imgId) { + try { + const query = `${title} ${artist}`; + await new Promise(r => setTimeout(r, 100)); + const results = await api.searchTracks(query, { limit: 5 }); + let foundCover = false; + + if (results.items && results.items.length > 0) { + const found = results.items.find(item => item.album?.cover); + if (found) { + const newUrl = api.getCoverUrl(found.album.cover); + const imgEl = document.getElementById(imgId); + if (imgEl) { + imgEl.src = newUrl; + foundCover = true; + } + } + } + + if (!foundCover) { + await fetchFallbackArtistImage(artist, imgId); + } + } catch (e) { + await fetchFallbackArtistImage(artist, imgId); + } +} + +async function fetchFallbackAlbumCover(title, artist, imgId) { + try { + const query = `${title} ${artist}`; + await new Promise(r => setTimeout(r, 100)); + const results = await api.searchAlbums(query, { limit: 5 }); + let foundCover = false; + + if (results.items && results.items.length > 0) { + const found = results.items.find(item => item.cover); + if (found) { + const newUrl = api.getCoverUrl(found.cover); + const imgEl = document.getElementById(imgId); + if (imgEl) { + imgEl.src = newUrl; + foundCover = true; + } + } + } + + if (!foundCover) { + await fetchFallbackArtistImage(artist, imgId); + } + } catch (e) { + await fetchFallbackArtistImage(artist, imgId); + } +} + +async function fetchFallbackArtistImage(artistName, imgId) { + try { + await new Promise(r => setTimeout(r, 100)); + const results = await api.searchArtists(artistName, { limit: 3 }); + if (results.items && results.items.length > 0) { + const found = results.items.find(item => item.picture); + if (found) { + const newUrl = api.getArtistPictureUrl(found.picture); + const imgEl = document.getElementById(imgId); + if (imgEl) imgEl.src = newUrl; + } + } + } catch (e) { + } +} + +async function fetchLastFmRecentTracks(username) { + const apiKey = '85214f5abbc730e78770f27784b9bdf7'; + const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=5`; + try { + const res = await fetch(url); + const data = await res.json(); + const tracks = data.recenttracks?.track; + if (!tracks) return []; + return Array.isArray(tracks) ? tracks : [tracks]; + } catch (e) { + console.error('Failed to fetch Last.fm recent tracks', e); + return []; + } +} + +async function fetchLastFmTopArtists(username) { + const apiKey = '85214f5abbc730e78770f27784b9bdf7'; + const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=6`; + try { + const res = await fetch(url); + const data = await res.json(); + return data.topartists?.artist || []; + } catch (e) { + console.error('Failed to fetch Last.fm top artists', e); + return []; + } +} + +async function fetchLastFmTopAlbums(username) { + const apiKey = '85214f5abbc730e78770f27784b9bdf7'; + const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=6`; + try { + const res = await fetch(url); + const data = await res.json(); + return data.topalbums?.album || []; + } catch (e) { + console.error('Failed to fetch Last.fm top albums', e); + return []; + } +} + +async function fetchLastFmTopTracks(username) { + const apiKey = '85214f5abbc730e78770f27784b9bdf7'; + const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=5`; + try { + const res = await fetch(url); + const data = await res.json(); + return data.toptracks?.track || []; + } catch (e) { + console.error('Failed to fetch Last.fm top tracks', e); + return []; + } +} \ No newline at end of file diff --git a/js/router.js b/js/router.js index edf5092..708557f 100644 --- a/js/router.js +++ b/js/router.js @@ -1,5 +1,6 @@ //router.js import { getTrackArtists } from './utils.js'; +import { loadProfile } from './profile.js'; export function navigate(path) { if (path === window.location.pathname) { @@ -103,6 +104,11 @@ export function createRouter(ui) { case 'home': await ui.renderHomePage(); break; + case 'user': + if (param && param.startsWith('@') && !param.includes('/')) { + await loadProfile(decodeURIComponent(param.slice(1))); + } + break; default: ui.showPage(page); break; diff --git a/js/settings.js b/js/settings.js index b4832b3..9f2b252 100644 --- a/js/settings.js +++ b/js/settings.js @@ -58,7 +58,7 @@ export function initializeSettings(scrobbler, player, api, ui) { // Email Auth UI Logic const toggleEmailBtn = document.getElementById('toggle-email-auth-btn'); const cancelEmailBtn = document.getElementById('cancel-email-auth-btn'); - const authContainer = document.getElementById('email-auth-container'); + const authModal = document.getElementById('email-auth-modal'); const authButtonsContainer = document.getElementById('auth-buttons-container'); const emailInput = document.getElementById('auth-email'); const passwordInput = document.getElementById('auth-password'); @@ -66,17 +66,19 @@ export function initializeSettings(scrobbler, player, api, ui) { const signUpBtn = document.getElementById('email-signup-btn'); const resetPasswordBtn = document.getElementById('reset-password-btn'); - if (toggleEmailBtn && authContainer && authButtonsContainer) { + if (toggleEmailBtn && authModal) { toggleEmailBtn.addEventListener('click', () => { - authContainer.style.display = 'flex'; - authButtonsContainer.style.display = 'none'; + authModal.classList.add('active'); }); } - if (cancelEmailBtn && authContainer && authButtonsContainer) { + if (cancelEmailBtn && authModal) { cancelEmailBtn.addEventListener('click', () => { - authContainer.style.display = 'none'; - authButtonsContainer.style.display = 'flex'; + authModal.classList.remove('active'); + }); + + authModal.querySelector('.modal-overlay').addEventListener('click', () => { + authModal.classList.remove('active'); }); } @@ -90,8 +92,7 @@ export function initializeSettings(scrobbler, player, api, ui) { } try { await authManager.signInWithEmail(email, password); - authContainer.style.display = 'none'; - authButtonsContainer.style.display = 'flex'; + authModal.classList.remove('active'); emailInput.value = ''; passwordInput.value = ''; } catch { @@ -110,8 +111,7 @@ export function initializeSettings(scrobbler, player, api, ui) { } try { await authManager.signUpWithEmail(email, password); - authContainer.style.display = 'none'; - authButtonsContainer.style.display = 'flex'; + authModal.classList.remove('active'); emailInput.value = ''; passwordInput.value = ''; } catch { @@ -1942,15 +1942,6 @@ export function initializeSettings(scrobbler, player, api, ui) { sidebarSectionSettings.setShowSettings(true); } - const sidebarShowAccountToggle = document.getElementById('sidebar-show-account-toggle'); - if (sidebarShowAccountToggle) { - sidebarShowAccountToggle.checked = sidebarSectionSettings.shouldShowAccount(); - sidebarShowAccountToggle.addEventListener('change', (e) => { - sidebarSectionSettings.setShowAccount(e.target.checked); - sidebarSectionSettings.applySidebarVisibility(); - }); - } - const sidebarShowAboutToggle = document.getElementById('sidebar-show-about-bottom-toggle'); if (sidebarShowAboutToggle) { sidebarShowAboutToggle.checked = sidebarSectionSettings.shouldShowAbout(); diff --git a/js/storage.js b/js/storage.js index 60548fe..10ff337 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1505,7 +1505,6 @@ export const sidebarSectionSettings = { SHOW_UNRELEASED_KEY: 'sidebar-show-unreleased', SHOW_DONATE_KEY: 'sidebar-show-donate', SHOW_SETTINGS_KEY: 'sidebar-show-settings', - SHOW_ACCOUNT_KEY: 'sidebar-show-account', SHOW_ABOUT_KEY: 'sidebar-show-about', SHOW_DOWNLOAD_KEY: 'sidebar-show-download', SHOW_DISCORD_KEY: 'sidebar-show-discord', @@ -1517,7 +1516,6 @@ export const sidebarSectionSettings = { 'sidebar-nav-unreleased', 'sidebar-nav-donate', 'sidebar-nav-settings', - 'sidebar-nav-account', 'sidebar-nav-about-bottom', 'sidebar-nav-download-bottom', 'sidebar-nav-discordbtn', @@ -1606,19 +1604,6 @@ export const sidebarSectionSettings = { } }, - shouldShowAccount() { - try { - const val = localStorage.getItem(this.SHOW_ACCOUNT_KEY); - return val === null ? true : val === 'true'; - } catch { - return true; - } - }, - - setShowAccount(enabled) { - localStorage.setItem(this.SHOW_ACCOUNT_KEY, enabled ? 'true' : 'false'); - }, - shouldShowAbout() { try { const val = localStorage.getItem(this.SHOW_ABOUT_KEY); @@ -1715,7 +1700,6 @@ export const sidebarSectionSettings = { { id: 'sidebar-nav-unreleased', check: this.shouldShowUnreleased() }, { id: 'sidebar-nav-donate', check: this.shouldShowDonate() }, { id: 'sidebar-nav-settings', check: this.shouldShowSettings() }, - { id: 'sidebar-nav-account', check: this.shouldShowAccount() }, { id: 'sidebar-nav-about-bottom', check: this.shouldShowAbout() }, { id: 'sidebar-nav-download-bottom', check: this.shouldShowDownload() }, { id: 'sidebar-nav-discordbtn', check: this.shouldShowDiscord() }, diff --git a/package-lock.json b/package-lock.json index 088afe7..2152da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1610,7 +1609,6 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1652,7 +1650,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1696,7 +1693,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3274,7 +3270,6 @@ "resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz", "integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=20" }, @@ -3323,7 +3318,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3347,7 +3341,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3645,7 +3638,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4683,7 +4675,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7305,7 +7296,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7389,7 +7379,6 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8495,7 +8484,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -8946,7 +8934,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9321,7 +9308,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9758,7 +9744,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/self-hosted-database.md b/self-hosted-database.md index 40cc70c..515d54a 100644 --- a/self-hosted-database.md +++ b/self-hosted-database.md @@ -83,6 +83,17 @@ Create two collections: `DB_users` and `public_playlists` (do NOT use the defaul | `user_playlists` | JSON | User's custom playlists | | `user_folders` | JSON | User's playlist folders | | `deleted_playlists` | JSON | Soft-deleted playlists | +| `username` | Plain Text | Unique username | +| `display_name` | Plain Text | Profile display name | +| `avatar_url` | URL | Profile avatar URL | +| `banner` | URL | Profile banner URL | +| `status` | Plain Text | User status | +| `about` | Plain Text | About me bio | +| `website` | URL | Personal website URL | +| `lastfm_username` | Plain Text | Last.fm username | +| `privacy` | JSON | Privacy settings | +| `profile_data_source` | Select (lastfm) | Preferred data source for profile | +| `favorite_albums` | JSON | User's favorite albums | #### public_playlists Fields @@ -105,8 +116,8 @@ Set the API rules for both collections to allow read/write access: **DB_users API Rules:** -- List/Search Rule: `firebase_id = @request.query.f_id` -- View Rule: `firebase_id = @request.query.f_id` +- List/Search Rule: `firebase_id = @request.query.f_id || username != ""` +- View Rule: `firebase_id = @request.query.f_id || username != ""` - Create Rule: `firebase_id = @request.query.f_id` - Update Rule: `firebase_id = @request.query.f_id` - Delete Rule: `firebase_id = @request.query.f_id` diff --git a/styles.css b/styles.css index 86d160e..ebd4065 100644 --- a/styles.css +++ b/styles.css @@ -7127,3 +7127,270 @@ textarea:focus { #custom-tooltip.visible { opacity: 1; } + +/* profile CSS :eyes: */ +.profile-header-container { + position: relative; + margin-bottom: 4rem; + display: flex; + flex-direction: column; + align-items: center; +} + +.profile-banner { + width: 100%; + height: 400px; + background-color: var(--secondary); + background-size: cover; + background-position: center; + border-radius: 0 0 var(--radius-xl) var(--radius-xl); + position: absolute; + top: 0; + left: 0; + z-index: 0; + mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 50%, rgba(0,0,0,0)); + -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 50%, rgba(0,0,0,0)); +} + +.profile-info-section { + padding: 0 2rem; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + position: relative; + z-index: 1; + margin-top: 250px; + width: 100%; + max-width: 800px; +} + +.profile-avatar { + width: 180px; + height: 180px; + border-radius: 50%; + border: 4px solid var(--background); + background-color: var(--card); + object-fit: cover; + flex-shrink: 0; + box-shadow: var(--shadow-2xl); + margin-bottom: 1.5rem; +} + +.profile-details { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +#profile-display-name { + font-size: 3rem; + font-weight: 800; + margin: 0 0 0.5rem 0; + line-height: 1.1; + text-shadow: 0 4px 12px rgba(0,0,0,0.5); +} + +.profile-username { + color: var(--muted-foreground); + font-size: 1.2rem; + margin-bottom: 1rem; + background: rgba(0,0,0,0.3); + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + backdrop-filter: blur(4px); +} + +.profile-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--secondary); + padding: 0.5rem 1rem; + border-radius: var(--radius-full); + font-size: 0.95rem; + margin-bottom: 1.5rem; + color: var(--foreground); + border: 1px solid var(--border); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-about { + margin-top: 0.5rem; + max-width: 600px; + line-height: 1.6; + font-size: 1.05rem; + color: var(--foreground); +} + +.profile-links { + display: flex; + gap: 1.5rem; + margin-top: 1.5rem; + justify-content: center; +} + +.profile-link { + color: var(--primary); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; +} + +.profile-link:hover { + text-decoration: underline; +} + +@media (max-width: 768px) { + .profile-info-section { + flex-direction: column; + align-items: center; + text-align: center; + padding: 0 1rem; + gap: 1rem; + } + + .profile-banner { + height: 150px; + margin-bottom: -50px; + } + + .profile-avatar { + width: 100px; + height: 100px; + } + + #profile-display-name { + font-size: 2rem; + } + + .profile-actions { + width: 100%; + display: flex; + justify-content: center; + } +} + +.status-picker-container { + position: relative; +} + +.search-results-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + max-height: 200px; + overflow-y: auto; + z-index: 100; + display: none; + box-shadow: var(--shadow-lg); +} + +.search-result-item { + padding: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + border-bottom: 1px solid var(--border); +} + +.search-result-item:last-child { + border-bottom: none; +} + +.search-result-item:hover { + background: var(--secondary); +} + +.search-result-item img { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + object-fit: cover; +} + +.search-result-info { + flex: 1; + min-width: 0; +} + +.search-result-title { + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-result-subtitle { + font-size: 0.8rem; + color: var(--muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dropdown-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem; + z-index: 2000; + min-width: 200px; + margin-top: 0.5rem; + box-shadow: var(--shadow-lg); + flex-direction: column; + gap: 0.25rem; +} + +.dropdown-menu.active { + display: flex; + animation: scale-in 0.1s ease-out; +} + +.dropdown-menu button { + width: 100%; + text-align: left; + justify-content: flex-start; +} + +.dropdown-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem; + z-index: 2000; + min-width: 200px; + margin-top: 0.5rem; + box-shadow: var(--shadow-lg); + flex-direction: column; + gap: 0.25rem; +} + +.dropdown-menu.active { + display: flex; + animation: scale-in 0.1s ease-out; +} + +.dropdown-menu button { + width: 100%; + text-align: left; + justify-content: flex-start; +}