feat(social): profiles feature

This commit is contained in:
Samidy 2026-02-19 16:54:58 +03:00
parent 5663b841c9
commit 250ebb9f99
10 changed files with 1556 additions and 138 deletions

View file

@ -734,6 +734,108 @@
</div>
</div>
<div id="edit-profile-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<h3>Edit Profile</h3>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Username</label>
<input type="text" id="edit-profile-username" class="template-input" placeholder="username" />
<p id="username-error" style="color: #ef4444; font-size: 0.8rem; display: none; margin-top: 0.25rem;"></p>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Display Name</label>
<input type="text" id="edit-profile-display-name" class="template-input" placeholder="Display Name" />
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Avatar URL</label>
<div style="display: flex; gap: 0.5rem; align-items: stretch">
<input type="url" id="edit-profile-avatar" class="template-input" placeholder="Avatar URL" style="flex: 1; margin: 0; display: none" />
<input type="file" id="edit-profile-avatar-file" accept="image/*" style="display: none" />
<button type="button" id="edit-profile-avatar-upload-btn" class="template-btn" style="flex: 1; padding: 0.5rem; font-size: 0.85rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem; background: var(--input); border: 1px solid var(--border); color: var(--foreground); border-radius: var(--radius); cursor: pointer;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span>Upload</span>
</button>
<button type="button" id="edit-profile-avatar-toggle-btn" class="template-btn" style="padding: 0.5rem; font-size: 0.85rem; white-space: nowrap; background: var(--input); border: 1px solid var(--border); color: var(--foreground); border-radius: var(--radius); cursor: pointer;" title="Switch to URL input">or URL</button>
</div>
<div id="edit-profile-avatar-upload-status" style="display: none; margin-top: 0.25rem; font-size: 0.75rem; opacity: 0.8"></div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Banner URL</label>
<div style="display: flex; gap: 0.5rem; align-items: stretch">
<input type="url" id="edit-profile-banner" class="template-input" placeholder="Banner URL" style="flex: 1; margin: 0; display: none" />
<input type="file" id="edit-profile-banner-file" accept="image/*" style="display: none" />
<button type="button" id="edit-profile-banner-upload-btn" class="template-btn" style="flex: 1; padding: 0.5rem; font-size: 0.85rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem; background: var(--input); border: 1px solid var(--border); color: var(--foreground); border-radius: var(--radius); cursor: pointer;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span>Upload</span>
</button>
<button type="button" id="edit-profile-banner-toggle-btn" class="template-btn" style="padding: 0.5rem; font-size: 0.85rem; white-space: nowrap; background: var(--input); border: 1px solid var(--border); color: var(--foreground); border-radius: var(--radius); cursor: pointer;" title="Switch to URL input">or URL</button>
</div>
<div id="edit-profile-banner-upload-status" style="display: none; margin-top: 0.25rem; font-size: 0.75rem; opacity: 0.8"></div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Status (Listening to...)</label>
<div class="status-picker-container">
<input type="text" id="edit-profile-status-search" class="template-input" placeholder="Search for a song or album..." autocomplete="off" />
<div id="status-search-results" class="search-results-dropdown"></div>
<input type="hidden" id="edit-profile-status-json">
<div id="status-preview" style="display: none; margin-top: 0.5rem; padding: 0.5rem; background: var(--secondary); border-radius: var(--radius); align-items: center; gap: 0.5rem;">
<img id="status-preview-img" src="" style="width: 32px; height: 32px; border-radius: 4px; object-fit: cover;">
<div style="flex: 1; min-width: 0;">
<div id="status-preview-title" style="font-weight: 500; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
<div id="status-preview-subtitle" style="font-size: 0.8rem; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
</div>
<button id="clear-status-btn" class="btn-icon" style="width: 24px; height: 24px;">&times;</button>
</div>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Favorite Albums (Max 5)</label>
<div id="edit-favorite-albums-list" style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.5rem;"></div>
<div class="status-picker-container">
<input type="text" id="edit-favorite-albums-search" class="template-input" placeholder="Search for an album..." autocomplete="off" />
<div id="edit-favorite-albums-results" class="search-results-dropdown"></div>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">About Me</label>
<textarea id="edit-profile-about" class="template-input" style="resize: vertical; min-height: 80px;" placeholder="Tell us about yourself"></textarea>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Website</label>
<input type="url" id="edit-profile-website" class="template-input" placeholder="https://..." />
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Last.fm Username</label>
<input type="text" id="edit-profile-lastfm" class="template-input" placeholder="Last.fm Username" />
<p style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.5rem; line-height: 1.5;">Integrating Last.fm enables recent activity and top stats on your profile. Authorize it in <strong>Settings &gt; Scrobbling</strong>. Note: Last.fm authorization is stored locally and must be repeated on each device.</p>
</div>
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem; font-size: 1rem;">Privacy</h4>
<div class="setting-item" style="padding: 0.5rem 0; border: none;">
<div class="info"><span class="label">Public Playlists</span></div>
<label class="toggle-switch"><input type="checkbox" id="privacy-playlists-toggle" checked><span class="slider"></span></label>
</div>
<div class="setting-item" style="padding: 0.5rem 0; border: none;">
<div class="info"><span class="label">Show Last.fm Link & Stats</span></div>
<label class="toggle-switch"><input type="checkbox" id="privacy-lastfm-toggle" checked><span class="slider"></span></label>
</div>
<div class="modal-actions">
<button id="edit-profile-cancel" class="btn-secondary">Cancel</button>
<button id="edit-profile-save" class="btn-primary">Save Profile</button>
</div>
</div>
</div>
<div id="folder-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
@ -759,6 +861,27 @@
</div>
</div>
<div id="email-auth-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<h3 style="text-align: center; margin-bottom: 10px">Email Authentication</h3>
<input type="email" id="auth-email" class="template-input" placeholder="Email Address" style="margin-bottom: 0.5rem;" />
<input type="password" id="auth-password" class="template-input" placeholder="Password" style="margin-bottom: 1rem;" />
<div style="display: flex; gap: 10px; justify-content: center; margin-top: 10px">
<button id="email-signin-btn" class="btn-primary" style="flex: 1">Sign In</button>
<button id="email-signup-btn" class="btn-secondary" style="flex: 1">Sign Up</button>
</div>
<button
id="reset-password-btn"
class="btn-secondary"
style="width: 100%; margin-top: 10px; font-size: 0.9rem"
>
Forgot Password?
</button>
<button id="cancel-email-auth-btn" class="btn-secondary" style="width: 100%; margin-top: 10px">Cancel</button>
</div>
</div>
<div id="playlist-select-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
@ -1213,40 +1336,6 @@
<span>Settings</span>
</a>
</li>
<li class="nav-item" id="sidebar-nav-account">
<a href="/account">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<g stroke-width="0"></g>
<g stroke-linecap="round" stroke-linejoin="round"></g>
<g>
<defs>
<style>
.cls-1 {
fill: none;
stroke: currentColor;
stroke-miterlimit: 10;
stroke-width: 1.9200000000000004;
}
</style>
</defs>
<circle class="cls-1" cx="12" cy="7.25" r="5.73"></circle>
<path
class="cls-1"
d="M1.5,23.48l.37-2.05A10.3,10.3,0,0,1,12,13h0a10.3,10.3,0,0,1,10.13,8.45l.37,2.05"
></path>
</g>
</svg>
<span>Account</span>
</a>
</li>
</ul>
</nav>
<div class="sidebar-bottom-container">
@ -1386,6 +1475,27 @@
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="header-account-control" style="position: relative; margin-right: 1rem;">
<button id="header-account-btn" class="btn-icon" title="Account" style="width: 36px; height: 36px; border-radius: 50%; overflow: hidden; padding: 0; border: 1px solid var(--border);">
<svg
id="header-account-icon"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<img id="header-account-img" src="" style="width: 100%; height: 100%; object-fit: cover; display: none;">
</button>
<div id="header-account-dropdown" class="dropdown-menu"></div>
</div>
<form class="search-bar" id="search-form">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -2533,6 +2643,55 @@
</section>
</div>
<div id="page-profile" class="page">
<div class="profile-header-container">
<div class="profile-banner" id="profile-banner"></div>
<div class="profile-info-section">
<img id="profile-avatar" src="/assets/appicon.png" class="profile-avatar" alt="Avatar">
<div class="profile-details">
<h1 id="profile-display-name">User</h1>
<div id="profile-username" class="profile-username">@username</div>
<div id="profile-status" class="profile-status" style="display: none;"></div>
<div id="profile-about" class="profile-about"></div>
<div class="profile-links">
<a id="profile-website" href="#" target="_blank" class="profile-link" style="display: none;">Website</a>
<a id="profile-lastfm" href="#" target="_blank" class="profile-link" style="display: none;">Last.fm</a>
</div>
</div>
<div class="profile-actions">
<button id="profile-edit-btn" class="btn-secondary" style="display: none;">Edit Profile</button>
</div>
</div>
</div>
<div class="profile-content">
<h2 class="section-title">Public Playlists</h2>
<div class="card-grid" id="profile-playlists-container"></div>
<div id="profile-favorite-albums-section" style="display: none; margin-top: 3rem;">
<h2 class="section-title">Favorite Albums of All Time</h2>
<div id="profile-favorite-albums-container"></div>
</div>
<div id="profile-recent-scrobbles-section" style="display: none; margin-top: 3rem;">
<div style="display: flex; align-items: baseline; gap: 1rem; margin-bottom: 1rem;">
<h2 class="section-title" style="margin-bottom: 0;">Recent Scrobbling</h2>
<span style="font-size: 0.8rem; color: var(--muted-foreground);">Powered by Last.fm</span>
</div>
<div class="track-list" id="profile-recent-scrobbles-container"></div>
</div>
<div id="profile-top-artists-section" style="display: none; margin-top: 3rem;">
<h2 class="section-title">Top Artists</h2>
<div class="card-grid" id="profile-top-artists-container"></div>
</div>
<div id="profile-top-albums-section" style="display: none; margin-top: 3rem;">
<h2 class="section-title">Top Albums</h2>
<div class="card-grid" id="profile-top-albums-container"></div>
</div>
<div id="profile-top-tracks-section" style="display: none; margin-top: 3rem;">
<h2 class="section-title">Top Tracks</h2>
<div class="track-list" id="profile-top-tracks-container"></div>
</div>
</div>
</div>
<div id="page-settings" class="page">
<h2 class="section-title">Settings</h2>
<form
@ -3040,18 +3199,6 @@
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Show Account in Sidebar</span>
<span class="description"
>Display the Account link in the sidebar navigation</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="sidebar-show-account-toggle" checked />
<span class="slider"></span>
</label>
</div>
</div>
<div class="sidebar-settings-section sidebar-settings-bottom">
<span class="sidebar-settings-section-label">BOTTOM SECTION</span>
@ -4134,6 +4281,13 @@
</div>
<button id="reset-local-data-btn" class="btn-secondary danger">Reset</button>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Clear Cloud Data</span>
<span class="description">Delete all your data from the cloud (cannot be undone)</span>
</div>
<button id="firebase-clear-cloud-btn" class="btn-secondary danger">Clear Cloud Data</button>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Backup & Restore</span>
@ -4400,46 +4554,9 @@
>
<button id="firebase-connect-btn" class="btn-secondary">Connect with Google</button>
<button id="toggle-email-auth-btn" class="btn-secondary">Connect with Email</button>
<button id="firebase-clear-cloud-btn" class="btn-secondary danger">Clear Cloud Data</button>
<button id="view-my-profile-btn" class="btn-secondary" style="display: none;">View My Profile</button>
</div>
<div
id="email-auth-container"
style="
display: none;
flex-direction: column;
gap: 15px;
width: 100%;
max-width: 320px;
margin: 20px auto;
padding: 20px;
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
"
>
<h3 style="text-align: center; margin-bottom: 10px">Email Authentication</h3>
<input type="email" id="auth-email" class="template-input" placeholder="Email Address" />
<input type="password" id="auth-password" class="template-input" placeholder="Password" />
<div style="display: flex; gap: 10px; justify-content: center; margin-top: 10px">
<button id="email-signin-btn" class="btn-primary" style="flex: 1">Sign In</button>
<button id="email-signup-btn" class="btn-secondary" style="flex: 1">Sign Up</button>
</div>
<button
id="reset-password-btn"
class="btn-secondary"
style="width: 100%; margin-top: 10px; font-size: 0.9rem"
>
Forgot Password?
</button>
<button
id="cancel-email-auth-btn"
class="btn-secondary"
style="width: 100%; margin-top: 10px"
>
Cancel
</button>
</div>
<p id="firebase-status" style="text-align: center; padding-top: 15px; color: #8b8b93">
Sync your library across devices
</p>

View file

@ -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;

View file

@ -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 = `
<button class="btn-secondary" id="header-google-auth">Connect with Google</button>
<button class="btn-secondary" id="header-email-auth">Connect with Email</button>
`;
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 = `
<button class="btn-secondary" id="header-view-profile">My Profile</button>
<button class="btn-secondary danger" id="header-sign-out">Sign Out</button>
`;
document.getElementById('header-view-profile').onclick = () => {
navigate(`/user/@${data.profile.username}`);
headerAccountDropdown.classList.remove('active');
};
} else {
headerAccountDropdown.innerHTML = `
<button class="btn-primary" id="header-create-profile">Create Profile</button>
<button class="btn-secondary danger" id="header-sign-out">Sign Out</button>
`;
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) {

923
js/profile.js Normal file
View file

@ -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 = `
<span style="opacity: 0.7; margin-right: 0.25rem;">Listening to:</span>
<img src="${statusObj.image}" style="width: 20px; height: 20px; border-radius: 2px; vertical-align: middle; margin-right: 0.5rem;">
<a href="${statusObj.link}" class="status-link" style="color: inherit; text-decoration: none; font-weight: 500;">${statusObj.text}</a>
`;
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 `
<div class="favorite-album-item" style="display: flex; gap: 1rem; margin-bottom: 1rem; background: var(--card); padding: 1rem; border-radius: var(--radius); border: 1px solid var(--border);">
<div class="card" style="width: 120px; flex-shrink: 0; padding: 0; border: none; background: transparent; cursor: pointer;" onclick="window.location.hash='/album/${album.id}'">
<div class="card-image-wrapper" style="margin-bottom: 0.5rem;">
<img src="${image}" class="card-image" loading="lazy" style="border-radius: var(--radius);">
</div>
<div class="card-info">
<div class="card-title" style="font-size: 0.9rem;">${escapeHtml(album.title)}</div>
<div class="card-subtitle" style="font-size: 0.8rem;">${escapeHtml(album.artist)}</div>
</div>
</div>
<div class="favorite-album-description" style="flex: 1; display: flex; flex-direction: column;">
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: var(--muted-foreground); text-transform: uppercase; letter-spacing: 0.05em;">Why it's a favorite</h4>
<p style="margin: 0; line-height: 1.6; white-space: pre-wrap;">${escapeHtml(album.description || '')}</p>
</div>
</div>
`;
}).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 `
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(track.artist?.['#text'] || track.artist?.name || '')}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
<img id="${track._imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
<div class="track-item-info">
<div class="track-item-details">
<div class="title">${track.name}</div>
<div class="artist">${track.artist?.['#text'] || track.artist?.name || track.artist || 'Unknown Artist'}</div>
</div>
</div>
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${dateDisplay}</div>
</div>
`;
}).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 `
<div class="card artist lastfm-card" data-name="${escapeHtml(artist.name)}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
</div>
<div class="card-info">
<div class="card-title">${artist.name}</div>
<div class="card-subtitle">${parseInt(artist.playcount).toLocaleString()} plays</div>
</div>
</div>
`;
}).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 `
<div class="card lastfm-card" data-name="${escapeHtml(album.name)}" data-artist="${escapeHtml(artistName)}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
</div>
<div class="card-info">
<div class="card-title">${album.name}</div>
<div class="card-subtitle">${artistName}</div>
</div>
</div>
`;
}).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 `
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(artistName)}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
<img id="${imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
<div class="track-item-info">
<div class="track-item-details">
<div class="title">${track.name}</div>
<div class="artist">${artistName}</div>
</div>
</div>
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${parseInt(track.playcount).toLocaleString()} plays</div>
</div>
`;
}).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 = `
<div class="card-image-wrapper">
<img src="${playlist.cover || '/assets/appicon.png'}" class="card-image" loading="lazy" alt="${playlist.name}">
</div>
<div class="card-info">
<div class="card-title">${playlist.name}</div>
<div class="card-subtitle">${playlist.numberOfTracks || 0} tracks</div>
</div>
`;
card.onclick = () => {
window.location.hash = `/userplaylist/${playlist.id}`;
};
container.appendChild(card);
});
if (container.children.length === 0) {
container.innerHTML = '<p style="color: var(--muted-foreground); grid-column: 1/-1; text-align: center;">No public playlists.</p>';
}
}
}
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 = `
<img src="${image}">
<div class="search-result-info">
<div class="search-result-title">${title}</div>
<div class="search-result-subtitle">${type === 'track' ? 'Song' : 'Album'} ${subtitle}</div>
</div>
`;
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) => `
<div class="edit-favorite-album-item" style="background: var(--secondary); padding: 0.5rem; border-radius: var(--radius); border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<img src="${api.getCoverUrl(album.cover)}" style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(album.title)}</div>
<div style="font-size: 0.8rem; color: var(--muted-foreground);">${escapeHtml(album.artist)}</div>
</div>
<button class="btn-icon remove-album-btn" data-index="${index}" style="color: var(--danger);">&times;</button>
</div>
<textarea class="template-input album-description-input" data-index="${index}" placeholder="Why is this a favorite?" style="min-height: 60px; font-size: 0.85rem; resize: vertical;">${escapeHtml(album.description || '')}</textarea>
</div>
`).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 = `
<img src="${image}">
<div class="search-result-info">
<div class="search-result-title">${album.title}</div>
<div class="search-result-subtitle">${album.artist?.name || 'Unknown Artist'}</div>
</div>
`;
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 [];
}
}

View file

@ -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;

View file

@ -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();

View file

@ -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() },

15
package-lock.json generated
View file

@ -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"
},

View file

@ -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`

View file

@ -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;
}