feat(social): profiles feature
This commit is contained in:
parent
5663b841c9
commit
250ebb9f99
10 changed files with 1556 additions and 138 deletions
285
index.html
285
index.html
|
|
@ -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;">×</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 > 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
78
js/app.js
78
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 = `
|
||||
<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
923
js/profile.js
Normal 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);">×</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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
15
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
267
styles.css
267
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue