Add shareable profile link to settings and sidebar
This commit is contained in:
150
frontend/app.js
150
frontend/app.js
@@ -411,12 +411,125 @@ async function pullProfileIfNewer() {
|
|||||||
} catch { /* offline — stay on local data */ }
|
} catch { /* offline — stay on local data */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the Settings row without a full re-render (no-op on other views).
|
// The full URL that links a fresh device to this profile. Opening it runs
|
||||||
|
// adoptProfileFromUrl() on the other device. Null when nothing is linked, or
|
||||||
|
// outside WEB mode where there is no origin worth sharing.
|
||||||
|
function profileShareUrl() {
|
||||||
|
if (!WEB || !data.profile || !data.profile.name) return null;
|
||||||
|
return `${location.origin}/?profile=${encodeURIComponent(data.profile.name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyProfileLink() {
|
||||||
|
const url = profileShareUrl();
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast('Profile link copied ✓');
|
||||||
|
} catch {
|
||||||
|
// Clipboard blocked (insecure context / permission) — fall back to a
|
||||||
|
// selectable modal so the link is still recoverable by hand.
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px">Copy this link:</p>
|
||||||
|
<input id="profileLinkInput" type="text" readonly value="${escapeHtml(url)}" />`;
|
||||||
|
showModal('🔗 Profile link', body, [{ label: 'Done', primary: true, onClick: closeModal }]);
|
||||||
|
const inp = $('profileLinkInput');
|
||||||
|
inp.focus();
|
||||||
|
inp.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareProfileLink() {
|
||||||
|
const url = profileShareUrl();
|
||||||
|
if (!url) return;
|
||||||
|
if (!navigator.share) return copyProfileLink(); // desktop / no Web Share API
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'YT Player profile',
|
||||||
|
text: `Open my YT Player profile “${data.profile.name}”`,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// User dismissed the share sheet, or the target rejected it — nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the Settings rows and the sidebar block without a full re-render.
|
||||||
function updateProfileStatus() {
|
function updateProfileStatus() {
|
||||||
|
const linked = !!(data.profile && data.profile.name);
|
||||||
|
const url = profileShareUrl();
|
||||||
|
|
||||||
const el = document.getElementById('profileStatus');
|
const el = document.getElementById('profileStatus');
|
||||||
if (el) el.textContent = (data.profile && data.profile.name) || 'Not linked';
|
if (el) el.textContent = (linked && data.profile.name) || 'Not linked';
|
||||||
const unlink = document.getElementById('profileUnlinkBtn');
|
const unlink = document.getElementById('profileUnlinkBtn');
|
||||||
if (unlink) unlink.style.display = data.profile && data.profile.name ? '' : 'none';
|
if (unlink) unlink.style.display = linked ? '' : 'none';
|
||||||
|
|
||||||
|
// Settings → share-link row (only rendered on the settings view)
|
||||||
|
const linkRow = document.getElementById('profileLinkRow');
|
||||||
|
if (linkRow) linkRow.classList.toggle('hidden', !url);
|
||||||
|
const linkVal = document.getElementById('profileLinkValue');
|
||||||
|
if (linkVal) linkVal.textContent = url || '';
|
||||||
|
['profileCopyLinkBtn', 'profileShareBtn'].forEach((id) => {
|
||||||
|
const b = document.getElementById(id);
|
||||||
|
if (b) b.style.display = url ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar footer block
|
||||||
|
const sb = document.getElementById('sidebarProfile');
|
||||||
|
if (sb) sb.classList.toggle('hidden', !url);
|
||||||
|
const sbName = document.getElementById('sidebarProfileName');
|
||||||
|
if (sbName) sbName.textContent = (linked && data.profile.name) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A shared link (?profile=name) arriving on this device. Adopting replaces the
|
||||||
|
// synced slice of local state, so anything already on the device gets a
|
||||||
|
// confirm first; a link for the profile already linked here is a no-op.
|
||||||
|
async function adoptProfileFromUrl() {
|
||||||
|
if (!WEB) return;
|
||||||
|
let name = '';
|
||||||
|
try {
|
||||||
|
name = (new URLSearchParams(location.search).get('profile') || '').trim();
|
||||||
|
} catch { return; }
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
// Drop the param either way so a reload can't re-trigger the prompt.
|
||||||
|
try { history.replaceState(null, '', location.pathname + location.hash); } catch {}
|
||||||
|
|
||||||
|
if (data.profile && data.profile.name &&
|
||||||
|
data.profile.name.toLowerCase() === name.toLowerCase()) return;
|
||||||
|
|
||||||
|
const hasLocalData = (data.playlists && data.playlists.length) ||
|
||||||
|
(data.history && data.history.length) ||
|
||||||
|
!!(data.profile && data.profile.name);
|
||||||
|
if (hasLocalData) {
|
||||||
|
const ok = await new Promise((resolve) => {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||||
|
This link opens the online profile <strong>${escapeHtml(name)}</strong>.
|
||||||
|
Loading it replaces the playlists, history and settings on this device,
|
||||||
|
and future changes here sync to that profile.
|
||||||
|
</p>`;
|
||||||
|
showModal('⬇ Open shared profile', body, [
|
||||||
|
{ label: 'Cancel', onClick: () => { closeModal(); resolve(false); } },
|
||||||
|
{ label: 'Load profile', primary: true, onClick: () => { closeModal(); resolve(true); } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`);
|
||||||
|
const j = await res.json().catch(() => null);
|
||||||
|
if (!j || !j.ok) {
|
||||||
|
toast('⚠ ' + ((j && j.error) || 'Profile not found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyProfileData(j.name, j.data, j.updatedAt);
|
||||||
|
toast(`Profile “${j.name}” loaded ✓ — this device now syncs to it`, { duration: 4000 });
|
||||||
|
} catch {
|
||||||
|
toast('⚠ Network error — could not open that profile');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProfileFlow() {
|
function createProfileFlow() {
|
||||||
@@ -511,6 +624,14 @@ function loadProfileFlow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
// ---------- Helpers ----------
|
||||||
|
// Only for values interpolated into innerHTML/attribute strings (profile names
|
||||||
|
// off a URL are attacker-controlled).
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTime(sec) {
|
function fmtTime(sec) {
|
||||||
if (!sec || !isFinite(sec)) return '0:00';
|
if (!sec || !isFinite(sec)) return '0:00';
|
||||||
sec = Math.floor(sec);
|
sec = Math.floor(sec);
|
||||||
@@ -2378,9 +2499,18 @@ async function renderSettings() {
|
|||||||
</span>
|
</span>
|
||||||
<span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
|
<span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row${profileShareUrl() ? '' : ' hidden'}" id="profileLinkRow">
|
||||||
|
<span>
|
||||||
|
Profile link
|
||||||
|
<small>Open this URL on another device to load the profile there. Anyone with the link gets full access to this data — share it only with your own devices or people you trust.</small>
|
||||||
|
</span>
|
||||||
|
<span id="profileLinkValue" class="set-stat" style="word-break:break-all;text-align:right">${escapeHtml(profileShareUrl() || '')}</span>
|
||||||
|
</div>
|
||||||
<div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
|
<div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
<button id="profileCreateBtn" class="btn">Create profile</button>
|
<button id="profileCreateBtn" class="btn">Create profile</button>
|
||||||
<button id="profileLoadBtn" class="btn">Load profile</button>
|
<button id="profileLoadBtn" class="btn">Load profile</button>
|
||||||
|
<button id="profileCopyLinkBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>⧉ Copy link</button>
|
||||||
|
<button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button>
|
||||||
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
|
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2516,6 +2646,8 @@ async function renderSettings() {
|
|||||||
// ---- Online profile ----
|
// ---- Online profile ----
|
||||||
$('profileCreateBtn').addEventListener('click', createProfileFlow);
|
$('profileCreateBtn').addEventListener('click', createProfileFlow);
|
||||||
$('profileLoadBtn').addEventListener('click', loadProfileFlow);
|
$('profileLoadBtn').addEventListener('click', loadProfileFlow);
|
||||||
|
$('profileCopyLinkBtn').addEventListener('click', copyProfileLink);
|
||||||
|
$('profileShareBtn').addEventListener('click', shareProfileLink);
|
||||||
$('profileUnlinkBtn').addEventListener('click', () => {
|
$('profileUnlinkBtn').addEventListener('click', () => {
|
||||||
data.profile = null;
|
data.profile = null;
|
||||||
persist();
|
persist();
|
||||||
@@ -3126,6 +3258,11 @@ function setupSidebarDrawer() {
|
|||||||
|
|
||||||
function wireUI() {
|
function wireUI() {
|
||||||
setupSidebarDrawer();
|
setupSidebarDrawer();
|
||||||
|
|
||||||
|
// ---- Sidebar: linked-profile share block (static markup, wired once) ----
|
||||||
|
$('sidebarProfileCopy').addEventListener('click', copyProfileLink);
|
||||||
|
$('sidebarProfileShare').addEventListener('click', shareProfileLink);
|
||||||
|
|
||||||
els.searchForm.addEventListener('submit', async (e) => {
|
els.searchForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const q = els.searchInput.value.trim();
|
const q = els.searchInput.value.trim();
|
||||||
@@ -3954,10 +4091,13 @@ async function boot() {
|
|||||||
} catch {
|
} catch {
|
||||||
// first run / bridge not ready — start with defaults
|
// first run / bridge not ready — start with defaults
|
||||||
}
|
}
|
||||||
// Linked online profile: adopt the server copy if another device pushed a
|
// A ?profile=… share link wins over whatever this device had linked; then
|
||||||
// newer one. Runs before any rendering so no re-render pass is needed.
|
// adopt the server copy if another device pushed a newer one. Both run
|
||||||
|
// before any rendering so no re-render pass is needed.
|
||||||
|
await adoptProfileFromUrl();
|
||||||
await pullProfileIfNewer();
|
await pullProfileIfNewer();
|
||||||
applyAppearance();
|
applyAppearance();
|
||||||
|
updateProfileStatus();
|
||||||
updateLoopRepeatButtons();
|
updateLoopRepeatButtons();
|
||||||
updateQueueBadge();
|
updateQueueBadge();
|
||||||
els.volume.value = String(data.settings.volume ?? 1);
|
els.volume.value = String(data.settings.volume ?? 1);
|
||||||
|
|||||||
@@ -51,6 +51,17 @@
|
|||||||
<div id="smartPlaylistList" class="playlist-list smart-pl-list"></div>
|
<div id="smartPlaylistList" class="playlist-list smart-pl-list"></div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
<!-- Shown only when an online profile is linked (see updateProfileStatus) -->
|
||||||
|
<div id="sidebarProfile" class="sb-profile hidden">
|
||||||
|
<div class="sb-profile-id">
|
||||||
|
<span class="sb-profile-mark">👤</span>
|
||||||
|
<span id="sidebarProfileName" class="sb-profile-name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="sb-profile-actions">
|
||||||
|
<button id="sidebarProfileCopy" class="sb-profile-btn" type="button" title="Copy profile link">⧉ Copy link</button>
|
||||||
|
<button id="sidebarProfileShare" class="sb-profile-btn" type="button" title="Share profile link">↗ Share</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox" id="audioOnlyToggle" />
|
<input type="checkbox" id="audioOnlyToggle" />
|
||||||
<span>Audio-only mode</span>
|
<span>Audio-only mode</span>
|
||||||
|
|||||||
@@ -206,6 +206,28 @@ body::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer { border-top: 1px solid var(--line-soft); padding-top: 12px; margin-top: auto; flex-shrink: 0; }
|
.sidebar-footer { border-top: 1px solid var(--line-soft); padding-top: 12px; margin-top: auto; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Linked online profile — share/copy the full URL that opens this profile */
|
||||||
|
.sb-profile {
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
padding: 10px; margin-bottom: 8px;
|
||||||
|
border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.sb-profile-id { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.sb-profile-mark { flex-shrink: 0; font-size: 13px; }
|
||||||
|
.sb-profile-name {
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sb-profile-actions { display: flex; gap: 6px; }
|
||||||
|
.sb-profile-btn {
|
||||||
|
flex: 1; padding: 6px 8px; font-size: 11px; cursor: pointer;
|
||||||
|
color: var(--text-2); background: var(--bg-1);
|
||||||
|
border: 1px solid var(--line-soft); border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.16s, color 0.16s, border-color 0.16s;
|
||||||
|
}
|
||||||
|
.sb-profile-btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
.switch {
|
.switch {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
color: var(--text-2); cursor: pointer; padding: 8px 10px;
|
color: var(--text-2); cursor: pointer; padding: 8px 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user