diff --git a/index.html b/index.html
index 5925d9a..7be8595 100644
--- a/index.html
+++ b/index.html
@@ -734,6 +734,108 @@
+
Backup & Restore
@@ -4400,46 +4554,9 @@
>
Connect with Google
Connect with Email
- Clear Cloud Data
+ View My Profile
-
-
Email Authentication
-
-
-
- Sign In
- Sign Up
-
-
- Forgot Password?
-
-
- Cancel
-
-
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.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;
+}