Admin home: song library with coverage bar, need/have filters, artwork and paging; phone tab bar always rests on whole tabs
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#0a0c12" />
|
||||
<title>Lyrics Studio</title>
|
||||
<link rel="stylesheet" href="fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="download-actions.css" />
|
||||
<script>
|
||||
try {
|
||||
@@ -22,7 +23,8 @@
|
||||
--a1: #f06b53; --a2: #f06b53; --action: #b53625; --on-action: #fff; --grad: #b53625;
|
||||
--ok: #4ade80; --on-ok: #06210f; --warn: #ffcf70; --bad: #ff8585;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
||||
--ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--display: "Bricolage Grotesque", Georgia, serif;
|
||||
--nav-h: 62px;
|
||||
--safe: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
@@ -100,7 +102,7 @@
|
||||
.grow { flex: 1; }
|
||||
.ghost {
|
||||
min-height: 44px; padding: 0 13px; border-radius: 10px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04);
|
||||
color: var(--text-2); font-weight: 600; font-size: 13px; text-decoration: none; display: inline-flex; align-items: center; gap: 6px;
|
||||
color: var(--text-2); font-weight: 600; font-size: 13px; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none;
|
||||
}
|
||||
.ghost:hover { color: var(--text); border-color: rgba(255,255,255,.28); }
|
||||
|
||||
@@ -238,11 +240,52 @@
|
||||
#p-editor :is(input, textarea) { caret-color: var(--ed-accent); }
|
||||
#p-editor :is(input, textarea)::placeholder { color: var(--ed-dim); opacity: 1; }
|
||||
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 18px 0 4px; }
|
||||
.step { padding: 14px; border-radius: 16px; background: rgba(255,255,255,.035); border: 1px solid var(--line); }
|
||||
.step b { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--grad); color: #fff; font-size: 13px; margin-bottom: 8px; }
|
||||
.step h3 { margin: 0 0 2px; font-size: 14px; } .step p { margin: 0; font-size: 12.5px; color: var(--text-dim); }
|
||||
.songs { display: grid; gap: 6px; margin-top: 14px; max-height: 46vh; overflow-y: auto; }
|
||||
/* ---------- editor home ---------- */
|
||||
.brand, h1.page, .lib-head h2 { font-family: var(--display); }
|
||||
.pick-head h1.page { font-size: clamp(32px, 5vw, 46px); line-height: 1.04; letter-spacing: -.035em; margin: 2px 0 10px; text-wrap: balance; }
|
||||
.pick-head .lead { font-size: 16px; line-height: 1.6; max-width: 56ch; margin-bottom: 22px; }
|
||||
.open-bar { display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--bg-2); }
|
||||
.open-bar:focus-within { border-color: var(--a1); }
|
||||
#p-editor .open-bar .field { flex: 1 1 auto; height: 46px; padding: 0 4px; border: 0; background: transparent; outline: none; }
|
||||
.open-bar .btn { flex: none; min-width: 92px; }
|
||||
.open-ico { flex: none; width: 20px; height: 20px; fill: none; stroke: var(--text-dim); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||||
#loadMsg:empty { min-height: 0; margin: 0; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.chip span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.chip-ico { flex: none; width: 15px; height: 15px; fill: none; stroke: var(--a1); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.library { margin-top: 30px; }
|
||||
.lib-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.lib-head h2 { margin: 0; font-size: 24px; line-height: 1.15; letter-spacing: -.02em; }
|
||||
.lib-count, .lib-filter span, .dur { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.lib-count { color: var(--text-dim); font-size: 13px; }
|
||||
.lib-hint { margin: 6px 0 16px; color: var(--text-2); font-size: 14px; }
|
||||
.coverage { margin: 0 0 18px; }
|
||||
.cov-bar { height: 6px; border-radius: 99px; background: var(--bg-3); overflow: hidden; }
|
||||
.cov-bar i { display: block; width: 0; height: 100%; border-radius: inherit; background: var(--ok); transition: width .7s cubic-bezier(.16, 1, .3, 1); }
|
||||
.coverage p { margin: 8px 0 0; color: var(--text-2); font-size: 14px; }
|
||||
.coverage b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.lib-tools { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; margin-bottom: 14px; }
|
||||
.lib-filter { display: flex; gap: 4px; padding: 4px; border-radius: 14px; background: var(--bg-2); overflow-x: auto; scrollbar-width: none; }
|
||||
.lib-filter button { flex: 1 0 auto; min-height: 40px; padding: 0 13px; border: 0; border-radius: 10px; background: transparent; color: var(--text-2); font-weight: 650; font-size: 14px; white-space: nowrap; transition: background .15s, color .15s; }
|
||||
.lib-filter button:hover { color: var(--text); }
|
||||
.lib-filter button[aria-pressed="true"] { background: var(--bg-3); color: var(--text); box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.lib-filter span { margin-left: 3px; color: var(--text-dim); font-size: 12px; font-weight: 500; }
|
||||
.lib-search { position: relative; display: flex; min-width: 0; }
|
||||
.lib-search svg { position: absolute; left: 13px; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); fill: none; stroke: var(--text-dim); stroke-width: 1.9; stroke-linecap: round; pointer-events: none; }
|
||||
#p-editor .lib-search .field { width: 100%; height: 48px; padding-left: 40px; }
|
||||
.song-art { position: relative; flex: none; display: grid; place-items: center; width: 88px; aspect-ratio: 16 / 9; border-radius: 6px; overflow: hidden; background: var(--bg-3); }
|
||||
.song-art svg { width: 22px; height: 22px; fill: none; stroke: var(--text-dim); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.song-art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.song-meta { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
|
||||
.dur { color: var(--text-dim); font-size: 12px; }
|
||||
.song-row .st.none { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
|
||||
.songs-empty { padding: 28px 16px; border: 1px dashed var(--line); border-radius: 14px; color: var(--text-2); text-align: center; }
|
||||
.more-songs { width: 100%; margin-top: 12px; }
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 24px; margin: 34px 0 4px; padding: 0; list-style: none; }
|
||||
.step { display: flex; gap: 12px; align-items: flex-start; padding: 16px 0 0; border-top: 1px solid var(--line); }
|
||||
.step b { flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-3); color: var(--a1); font: 700 13px var(--mono); }
|
||||
.step h3 { margin: 2px 0 2px; font-size: 15px; } .step p { margin: 0; font-size: 13.5px; color: var(--text-2); }
|
||||
.songs { display: grid; gap: 8px; }
|
||||
.song-row { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 10px 12px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,.03); }
|
||||
.song-row:hover { border-color: var(--a1); }
|
||||
.song-row b { display: block; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -411,7 +454,7 @@
|
||||
.tabs { order: 2; width: 100%; margin-left: 0; overflow-x: auto; }
|
||||
.tabs .tab { flex: none; }
|
||||
}
|
||||
.nav { display: none; }
|
||||
.navbar { display: none; }
|
||||
.song-info { min-width: 0; flex: 1; }
|
||||
.song-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.song-edit { display: none; }
|
||||
@@ -425,33 +468,48 @@
|
||||
|
||||
/* Song cards: what it is on top, every action full width beneath it. */
|
||||
#songList { display: grid; gap: 12px; }
|
||||
.song-row { display: grid; gap: 14px; padding: 16px; border-radius: 16px; border: 0; background: var(--bg-2); }
|
||||
.song-row { display: grid; gap: 14px; padding: 14px; border-radius: 16px; border: 1px solid var(--bg-3); background: var(--bg-1); }
|
||||
.song-row:hover { border-color: transparent; }
|
||||
.song-open { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; min-height: 0; flex-basis: auto; }
|
||||
.song-row b { font-size: 17px; line-height: 1.3; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.song-row small { display: block; margin-top: 2px; font-size: 14px; color: var(--text-2); }
|
||||
.song-open { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 12px; align-items: start; min-height: 0; flex-basis: auto; }
|
||||
.song-art { width: 112px; }
|
||||
.song-row b { font-size: 16px; line-height: 1.3; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.song-row small { display: block; margin-top: 2px; font-size: 13.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.song-row .st { justify-self: start; font-size: 12px; padding: 4px 10px; }
|
||||
.song-actions { display: grid; grid-template-columns: 1fr; gap: 8px; }
|
||||
.song-actions .btn { width: 100%; min-height: 46px; justify-content: center; font-size: 15px; margin: 0; }
|
||||
.song-actions .btn:not(.pri) { border-color: transparent; background: var(--bg-3); }
|
||||
.pick-head h1.page { font-size: 34px; }
|
||||
.pick-head .lead { font-size: 15.5px; margin-bottom: 18px; }
|
||||
.lib-tools { grid-template-columns: minmax(0, 1fr); }
|
||||
.lib-filter button { min-height: 44px; }
|
||||
.steps { grid-template-columns: 1fr; gap: 0; }
|
||||
.step { padding: 14px 0; }
|
||||
.song-edit { display: inline-flex; }
|
||||
.m-only { display: inline; } .d-only { display: none; }
|
||||
|
||||
.nav {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; gap: 4px;
|
||||
height: calc(var(--nav-h) + var(--safe)); padding: 6px 6px var(--safe); overflow-x: auto; overflow-y: hidden;
|
||||
overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||||
scroll-snap-type: x proximity; scroll-padding-inline: 6px;
|
||||
padding-left: calc(6px + env(safe-area-inset-left, 0px)); padding-right: calc(6px + env(safe-area-inset-right, 0px));
|
||||
.navbar {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: block;
|
||||
padding: 0 env(safe-area-inset-right, 0px) var(--safe) env(safe-area-inset-left, 0px);
|
||||
background: var(--bg-1); border-top: 1px solid var(--line-2);
|
||||
}
|
||||
.nav-rail { position: absolute; top: -1px; left: env(safe-area-inset-left, 0px); right: env(safe-area-inset-right, 0px); height: 2px; pointer-events: none; }
|
||||
.nav-rail i { display: block; width: 0; height: 100%; border-radius: 2px; background: var(--a1); }
|
||||
/* No side padding on the scroller: tabs are exactly 1/n of its width and
|
||||
snapping is mandatory, so it always rests on whole tabs. */
|
||||
.nav {
|
||||
display: flex; height: var(--nav-h); padding: 0; gap: 0; overflow-x: auto; overflow-y: hidden;
|
||||
overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
/* A flex row drops its end padding when it scrolls; this spacer keeps the
|
||||
last tab clear of the right edge. */
|
||||
.nav::after { content: ""; flex: 0 0 6px; }
|
||||
.nav .tab { scroll-snap-align: start; }
|
||||
.nav .tab:last-of-type { scroll-snap-align: end; }
|
||||
.nav .tab { flex: 0 0 68px; min-width: 68px; min-height: 44px; flex-direction: column; justify-content: center; gap: 3px; height: auto; padding: 4px 3px; font-size: 10px; line-height: 1.05; text-align: center; white-space: normal; border-radius: 12px; }
|
||||
.nav .tab .badge { position: absolute; top: 2px; left: 55%; }
|
||||
.nav .tab {
|
||||
flex: 0 0 var(--tab-w, 20%); width: var(--tab-w, 20%); min-width: 0; scroll-snap-align: start;
|
||||
flex-direction: column; justify-content: center; gap: 3px; height: auto; min-height: 44px;
|
||||
padding: 2px; border: solid transparent; border-width: 6px 3px; background-clip: padding-box;
|
||||
font-size: 10.5px; line-height: 1.05; text-align: center; white-space: normal; border-radius: 15px;
|
||||
}
|
||||
.nav .tab span:not(.badge) { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.nav .tab .badge { position: absolute; top: 2px; left: 56%; }
|
||||
.steps { grid-template-columns: 1fr; }
|
||||
.ln-main { grid-template-columns: 82px minmax(0, 1fr) 44px; gap: 6px; padding: 7px; }
|
||||
.ln-text { grid-column: 2; }
|
||||
@@ -472,7 +530,7 @@
|
||||
main { padding-top: 16px; }
|
||||
#tapPad { grid-column: 1 / -1; width: 100%; min-height: 52px; height: 52px; }
|
||||
.editor-footer { align-items: flex-start; }
|
||||
#p-editor { padding: 12px; border-radius: 12px; }
|
||||
#p-editor { padding: 0; border: 0; border-radius: 0; }
|
||||
}
|
||||
@media (max-width: 370px) {
|
||||
.transport { grid-template-columns: 44px 52px 44px minmax(64px, 1fr); }
|
||||
@@ -523,25 +581,45 @@
|
||||
<!-- ================= EDITOR ================= -->
|
||||
<section id="p-editor" class="panel">
|
||||
<div id="pickBox">
|
||||
<h1 class="page">Time & fix lyrics</h1>
|
||||
<p class="lead">Play a song right here and stamp every line against what you actually hear. Everything is undoable and every save keeps the previous version.</p>
|
||||
<div class="card">
|
||||
<form id="loadForm" class="row">
|
||||
<input class="field" type="text" id="songId" placeholder="Paste a YouTube link, video id or upl_… id" autocomplete="off" />
|
||||
<button class="btn pri" type="submit">Open</button>
|
||||
</form>
|
||||
<div id="loadMsg" class="msg"></div>
|
||||
<div id="recentChips" class="chips"></div>
|
||||
<div class="row" style="margin-top:16px"><h2 style="margin:0;font-size:14px">Songs on the server</h2><span class="grow"></span>
|
||||
<input class="field" id="songFilter" type="search" placeholder="Filter…" style="flex:0 1 200px;height:36px" /></div>
|
||||
<p class="muted" style="margin:4px 0 0;font-size:12.5px">Downloaded videos and uploads. Songs that already have lyrics can be corrected here too.</p>
|
||||
<div id="songList" class="songs"><div class="muted" style="padding:10px">Loading…</div></div>
|
||||
<div class="steps">
|
||||
<div class="step"><b>1</b><h3>Open a song</h3><p>Paste a link or pick one from your library.</p></div>
|
||||
<div class="step"><b>2</b><h3>Play & tap</h3><p>Switch to Tap-sync and hit the big button on each line as it is sung.</p></div>
|
||||
<div class="step"><b>3</b><h3>Save</h3><p>Everyone gets the new timing instantly.</p></div>
|
||||
</div>
|
||||
<div class="pick-head">
|
||||
<h1 class="page">Time & fix lyrics</h1>
|
||||
<p class="lead">Play a song right here and stamp every line against what you actually hear. Everything is undoable and every save keeps the previous version.</p>
|
||||
</div>
|
||||
<form id="loadForm" class="open-bar">
|
||||
<svg class="open-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4.5 4.5 0 006.4 0l3-3a4.5 4.5 0 00-6.4-6.4l-1 1"/><path d="M14 10a4.5 4.5 0 00-6.4 0l-3 3a4.5 4.5 0 006.4 6.4l1-1"/></svg>
|
||||
<input class="field" type="text" id="songId" placeholder="Paste a YouTube link, video id or upl_… id" aria-label="YouTube link, video id or upload id" autocomplete="off" />
|
||||
<button class="btn pri" type="submit">Open</button>
|
||||
</form>
|
||||
<div id="loadMsg" class="msg"></div>
|
||||
<div id="recentChips" class="chips"></div>
|
||||
|
||||
<section class="library" aria-labelledby="libTitle">
|
||||
<div class="lib-head">
|
||||
<h2 id="libTitle">Songs on the server</h2>
|
||||
<span class="lib-count" id="songCount"></span>
|
||||
</div>
|
||||
<p class="lib-hint">Downloaded videos and uploads. Songs that already have lyrics can be corrected here too.</p>
|
||||
<div class="coverage" id="coverage" hidden>
|
||||
<div class="cov-bar"><i id="covFill"></i></div>
|
||||
<p id="covText"></p>
|
||||
</div>
|
||||
<div class="lib-tools">
|
||||
<div class="lib-filter" role="group" aria-label="Show songs">
|
||||
<button type="button" data-view="all" aria-pressed="true">All <span data-n="all"></span></button>
|
||||
<button type="button" data-view="need" aria-pressed="false">Need lyrics <span data-n="need"></span></button>
|
||||
<button type="button" data-view="has" aria-pressed="false">Have lyrics <span data-n="has"></span></button>
|
||||
</div>
|
||||
<label class="lib-search"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg><input class="field" id="songFilter" type="search" placeholder="Filter by title or artist" aria-label="Filter songs" /></label>
|
||||
</div>
|
||||
<div id="songList" class="songs"><div class="muted" style="padding:10px">Loading…</div></div>
|
||||
<button type="button" class="btn more-songs hidden" id="songMore">Show more songs</button>
|
||||
</section>
|
||||
|
||||
<ol class="steps" aria-label="How it works">
|
||||
<li class="step"><b>1</b><div><h3>Open a song</h3><p>Paste a link or pick one from your library.</p></div></li>
|
||||
<li class="step"><b>2</b><div><h3>Play & tap</h3><p>Switch to Tap-sync and hit the big button on each line as it is sung.</p></div></li>
|
||||
<li class="step"><b>3</b><div><h3>Save</h3><p>Everyone gets the new timing instantly.</p></div></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div id="studio" class="hidden">
|
||||
@@ -866,7 +944,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||
<div class="navbar"><span class="nav-rail" aria-hidden="true"><i id="navRail"></i></span><nav class="nav" id="tabsBottom" role="tablist"></nav></div>
|
||||
</div>
|
||||
|
||||
<script src="opfs.js"></script>
|
||||
@@ -924,11 +1002,37 @@
|
||||
$('tabsTop').innerHTML = html; $('tabsBottom').innerHTML = html;
|
||||
document.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
|
||||
}
|
||||
/* Phone tab strip: every tab is exactly 1/n of the strip and snapping is
|
||||
mandatory, so wherever it comes to rest it shows n whole tabs, never a
|
||||
sliver of the next one. */
|
||||
const navEl = () => $('tabsBottom');
|
||||
function sizeNav() {
|
||||
const nav = navEl(), w = nav.clientWidth; if (!w) return;
|
||||
const n = Math.max(4, Math.min(7, Math.floor(w / 78)));
|
||||
nav.style.setProperty('--tab-w', `${w / n}px`);
|
||||
nav.dataset.perPage = n; railNav();
|
||||
}
|
||||
function railNav() {
|
||||
const nav = navEl(), total = nav.scrollWidth, w = nav.clientWidth, rail = $('navRail');
|
||||
if (!rail || !total) return;
|
||||
rail.style.width = `${(w / total) * 100}%`;
|
||||
rail.style.transform = `translateX(${(nav.scrollLeft / w) * 100}%)`;
|
||||
}
|
||||
function revealTab(name) {
|
||||
const nav = navEl(), w = parseFloat(nav.style.getPropertyValue('--tab-w')); if (!w) return;
|
||||
const i = TABS.findIndex(([k]) => k === name), n = Number(nav.dataset.perPage) || 5;
|
||||
const first = Math.round(nav.scrollLeft / w);
|
||||
if (i < first) nav.scrollTo({ left: i * w });
|
||||
else if (i >= first + n) nav.scrollTo({ left: (i - n + 1) * w });
|
||||
}
|
||||
addEventListener('resize', sizeNav);
|
||||
navEl().addEventListener('scroll', railNav, { passive: true });
|
||||
function showTab(name, push = true) {
|
||||
if (!$('p-' + name)) name = 'editor';
|
||||
tab = name;
|
||||
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('on', p.id === 'p-' + name));
|
||||
document.querySelectorAll('[data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === name)));
|
||||
revealTab(name);
|
||||
if (push) history.replaceState(null, '', location.pathname + location.search + '#' + name);
|
||||
syncBars();
|
||||
if (name === 'reports') loadReports();
|
||||
@@ -966,6 +1070,7 @@
|
||||
$('loginBox').classList.add('hidden');
|
||||
$('app').classList.remove('hidden');
|
||||
if (!$('tabsTop').children.length) buildTabs();
|
||||
sizeNav();
|
||||
renderDocs();
|
||||
loadRecent();
|
||||
loadSongs();
|
||||
@@ -1640,32 +1745,57 @@
|
||||
});
|
||||
async function loadRecent() {
|
||||
const j = await api('/api/uploads?limit=10');
|
||||
$('recentChips').innerHTML = (j.uploads || []).map((u) => `<button type="button" class="chip" data-open="${esc(u.id)}">♪ ${esc(u.title)}</button>`).join('');
|
||||
$('recentChips').innerHTML = (j.uploads || []).map((u) => `<button type="button" class="chip" data-open="${esc(u.id)}"><svg class="chip-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg><span>${esc(u.title)}</span></button>`).join('');
|
||||
}
|
||||
let SONGS = [];
|
||||
async function loadSongs() {
|
||||
const [m, u] = await Promise.all([api('/api/admin/media'), api('/api/uploads?limit=200')]);
|
||||
const seen = new Set();
|
||||
SONGS = [];
|
||||
for (const r of (m.media || [])) { seen.add(r.id); SONGS.push({ id: r.id, title: r.title || r.id, sub: r.channel, lines: r.lyricsLines || 0, sourceUrl: r.sourceUrl }); }
|
||||
for (const r of (u.uploads || [])) if (!seen.has(r.id)) SONGS.push({ id: r.id, title: r.title, sub: r.artist || 'Upload', lines: -1 });
|
||||
for (const r of (m.media || [])) { seen.add(r.id); SONGS.push({ id: r.id, title: r.title || r.id, sub: r.channel, lines: r.lyricsLines || 0, sourceUrl: r.sourceUrl, dur: r.duration || 0 }); }
|
||||
for (const r of (u.uploads || [])) if (!seen.has(r.id)) SONGS.push({ id: r.id, title: r.title, sub: r.artist || 'Upload', lines: -1, dur: r.duration || 0 });
|
||||
paintSongs();
|
||||
}
|
||||
let songView = 'all', songLimit = 30;
|
||||
const SONG_ART = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg>';
|
||||
const songArt = (id) => /^[A-Za-z0-9_-]{11}$/.test(id) ? `https://i.ytimg.com/vi/${id}/mqdefault.jpg` : `/api/uploads/${encodeURIComponent(id)}/art`;
|
||||
const songDur = (s) => { s = Math.round(Number(s) || 0); return s > 0 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : ''; };
|
||||
function paintSongs() {
|
||||
const q = $('songFilter').value.trim().toLowerCase();
|
||||
const rows = SONGS.filter((r) => !q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q));
|
||||
$('songList').innerHTML = rows.length ? rows.map((r) => `<div class="song-row"><button type="button" class="song-open" data-open="${esc(r.id)}">
|
||||
<span class="song-info"><b>${esc(r.title)}</b><small>${esc(r.sub || '')}</small></span>
|
||||
<span class="st ${r.lines > 0 ? 'has' : ''}">${r.lines > 0 ? `${r.lines} lines` : r.lines === 0 ? 'no lyrics' : 'open'}</span></button>
|
||||
const has = SONGS.filter((r) => r.lines > 0).length, need = SONGS.filter((r) => r.lines === 0).length, known = has + need;
|
||||
const counts = { all: SONGS.length, need, has };
|
||||
document.querySelectorAll('[data-n]').forEach((el) => { el.textContent = counts[el.dataset.n].toLocaleString(); });
|
||||
document.querySelectorAll('[data-view]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.view === songView)));
|
||||
$('songCount').textContent = SONGS.length ? `${SONGS.length.toLocaleString()} songs` : '';
|
||||
$('coverage').hidden = !known;
|
||||
if (known) {
|
||||
$('covFill').style.width = `${(has / known) * 100}%`;
|
||||
$('covText').innerHTML = `<b>${has.toLocaleString()}</b> of ${known.toLocaleString()} downloaded songs have lyrics${need ? ` · <b>${need.toLocaleString()}</b> still need them` : ' · all done'}`;
|
||||
}
|
||||
const rows = SONGS.filter((r) => (songView === 'all' || (songView === 'has' ? r.lines > 0 : r.lines === 0))
|
||||
&& (!q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q)));
|
||||
const shown = rows.slice(0, songLimit);
|
||||
$('songList').innerHTML = shown.length ? shown.map((r) => `<div class="song-row"><button type="button" class="song-open" data-open="${esc(r.id)}">
|
||||
<span class="song-art">${SONG_ART}<img src="${esc(songArt(r.id))}" alt="" loading="lazy" decoding="async" onerror="this.remove()"></span>
|
||||
<span class="song-info"><b>${esc(r.title)}</b><small>${esc(r.sub || '')}</small>
|
||||
<span class="song-meta"><span class="st ${r.lines > 0 ? 'has' : r.lines === 0 ? 'none' : ''}">${r.lines > 0 ? `${r.lines} lines` : r.lines === 0 ? 'no lyrics' : 'open'}</span>${songDur(r.dur) ? `<span class="dur">${songDur(r.dur)}</span>` : ''}</span></span></button>
|
||||
<div class="song-actions">
|
||||
<button type="button" class="btn sm pri song-edit" data-open="${esc(r.id)}">${r.lines > 0 ? 'Edit lyrics' : 'Add lyrics'}</button>
|
||||
<button type="button" class="btn sm whisper-request" data-whisper="${esc(r.id)}" aria-label="Transcribe ${esc(r.title)} with Whisper">Whisper</button>
|
||||
${r.sourceUrl ? `<button type="button" class="btn sm" data-redownload="${esc(r.id)}">Re-download from source</button><a class="btn sm" href="${esc(r.sourceUrl)}" target="_blank" rel="noopener"><span class="d-only">Source</span><span class="m-only">Open source page</span></a>` : ''}
|
||||
</div></div>`).join('')
|
||||
: '<div class="muted" style="padding:10px">Nothing matches — paste a link above to open any YouTube song.</div>';
|
||||
: `<div class="songs-empty">${SONGS.length ? 'Nothing matches. Paste a link above to open any YouTube song.' : 'No songs on the server yet. Paste a link above to open any YouTube song.'}</div>`;
|
||||
const more = rows.length - shown.length;
|
||||
$('songMore').classList.toggle('hidden', more <= 0);
|
||||
$('songMore').textContent = `Show ${Math.min(more, 30)} more of ${more.toLocaleString()}`;
|
||||
paintWhisper();
|
||||
}
|
||||
$('songFilter').addEventListener('input', paintSongs);
|
||||
$('songMore').addEventListener('click', () => { songLimit += 30; paintSongs(); });
|
||||
document.querySelector('.lib-filter').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-view]'); if (!b) return;
|
||||
songView = b.dataset.view; songLimit = 30; paintSongs();
|
||||
});
|
||||
$('songFilter').addEventListener('input', () => { songLimit = 30; paintSongs(); });
|
||||
$('songList').addEventListener('click', (e) => {
|
||||
const whisper = e.target.closest('[data-whisper]'); if (whisper) { startWhisper(whisper.dataset.whisper); return; }
|
||||
const source = e.target.closest('[data-redownload]'); if (source) { startSourceRedownload(source); return; }
|
||||
|
||||
Reference in New Issue
Block a user