Skip to content

Instantly share code, notes, and snippets.

@sunmeat
Created May 14, 2026 11:30
Show Gist options
  • Select an option

  • Save sunmeat/9599b9d0f7bf20b8258a874034f352b3 to your computer and use it in GitHub Desktop.

Select an option

Save sunmeat/9599b9d0f7bf20b8258a874034f352b3 to your computer and use it in GitHub Desktop.
простий html-клієнт для перегляду даних з БД та відправки запитів
<!--
1) pip install django-cors-headers
2) INSTALLED_APPS = [
'corsheaders',
3) MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
4) CORS_ALLOW_ALL_ORIGINS = True в кінець settings.py
5) змінити порт на 503 рядку
-->
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Музичний браузер</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0a;
--surface: #111111;
--surface2: #1a1a1a;
--border: #2a2a2a;
--accent: #c8f53e;
--accent2: #ff4d6d;
--muted: #555;
--text: #e8e8e8;
--text-dim: #888;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
header {
padding: 2rem 2.5rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-end;
gap: 2rem;
position: sticky;
top: 0;
background: var(--bg);
z-index: 100;
}
.logo {
font-family: 'Bebas Neue', sans-serif;
font-size: 2.8rem;
letter-spacing: 0.08em;
color: var(--accent);
line-height: 1;
}
.logo span { color: var(--text-dim); }
.api-base {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
color: var(--muted);
padding-bottom: 0.3rem;
}
nav {
padding: 0 2.5rem;
border-bottom: 1px solid var(--border);
display: flex;
gap: 0;
}
.tab {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 1rem 1.5rem;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
transition: all 0.2s;
background: none;
border-top: none;
border-left: none;
border-right: none;
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 115px);
}
.sidebar {
border-right: 1px solid var(--border);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.sidebar-header {
padding: 1.2rem 1.5rem 0.8rem;
font-family: 'DM Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.15em;
color: var(--muted);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.count-badge {
background: var(--surface2);
color: var(--text-dim);
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.6rem;
}
.list-item {
padding: 0.9rem 1.5rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
display: flex;
align-items: center;
gap: 0.8rem;
}
.list-item:hover { background: var(--surface); }
.list-item.selected { background: var(--surface2); border-left: 2px solid var(--accent); }
.item-avatar {
width: 36px;
height: 36px;
border-radius: 4px;
background: var(--surface2);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
flex-shrink: 0;
overflow: hidden;
}
.item-avatar img { width: 100%; height: 100%; object-fit: cover; }
.item-info { flex: 1; min-width: 0; }
.item-name {
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-sub {
font-size: 0.72rem;
color: var(--text-dim);
margin-top: 0.1rem;
font-family: 'DM Mono', monospace;
}
.detail-pane {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--muted);
}
.empty-icon {
font-size: 3rem;
opacity: 0.3;
}
.empty-text {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.1em;
}
.detail-hero {
padding: 2rem 2.5rem;
border-bottom: 1px solid var(--border);
display: flex;
gap: 2rem;
align-items: flex-start;
}
.detail-cover {
width: 100px;
height: 100px;
border-radius: 6px;
background: var(--surface2);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--border);
}
.detail-cover img { width: 100%; height: 100%; object-fit: cover; }
.detail-title-block { flex: 1; }
.detail-type {
font-family: 'DM Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.4rem;
}
.detail-title {
font-family: 'Bebas Neue', sans-serif;
font-size: 2.4rem;
letter-spacing: 0.04em;
line-height: 1;
margin-bottom: 0.5rem;
}
.detail-meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.meta-chip {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 0.3rem;
}
.meta-chip .dot {
width: 4px; height: 4px;
border-radius: 50%;
background: var(--muted);
}
.action-row {
padding: 1rem 2.5rem;
border-bottom: 1px solid var(--border);
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.btn {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
}
.btn:hover { background: var(--surface2); color: var(--text); border-color: var(--muted); }
.btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
font-weight: 500;
}
.sub-content { padding: 1.5rem 2.5rem; }
.sub-section-title {
font-family: 'DM Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 1rem;
}
.fields-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.field-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
}
.field-label {
font-family: 'DM Mono', monospace;
font-size: 0.6rem;
letter-spacing: 0.15em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 0.3rem;
}
.field-value {
font-size: 0.88rem;
color: var(--text);
word-break: break-word;
}
.field-block.full { grid-column: 1 / -1; }
.track-row {
display: grid;
grid-template-columns: 30px 1fr auto auto;
align-items: center;
gap: 1rem;
padding: 0.7rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.83rem;
}
.track-row:last-child { border-bottom: none; }
.track-num {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
color: var(--muted);
text-align: right;
}
.track-name { font-weight: 400; }
.track-artist {
font-size: 0.72rem;
color: var(--text-dim);
font-family: 'DM Mono', monospace;
}
.track-dur {
font-family: 'DM Mono', monospace;
font-size: 0.72rem;
color: var(--text-dim);
}
.explicit-tag {
background: var(--accent2);
color: #fff;
font-size: 0.55rem;
font-family: 'DM Mono', monospace;
padding: 0.1rem 0.3rem;
border-radius: 2px;
letter-spacing: 0.05em;
display: inline-block;
vertical-align: middle;
margin-left: 0.3rem;
}
.loader {
text-align: center;
padding: 3rem;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--muted);
letter-spacing: 0.1em;
}
.loader::after {
content: '';
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
margin-left: 0.5rem;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
.error-msg {
padding: 1rem 1.5rem;
background: #1a0a0d;
border: 1px solid #3a1520;
border-radius: 6px;
color: var(--accent2);
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
margin: 1rem 0;
}
.bio-text {
font-size: 0.85rem;
line-height: 1.7;
color: var(--text-dim);
white-space: pre-wrap;
}
.lyrics-block {
font-family: 'DM Mono', monospace;
font-size: 0.78rem;
line-height: 1.9;
color: var(--text-dim);
white-space: pre-wrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1.5rem;
max-height: 300px;
overflow-y: auto;
}
.search-box {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0;
padding: 0.7rem 1.5rem;
color: var(--text);
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
outline: none;
transition: border-color 0.2s;
}
.search-box:focus { border-color: var(--accent); }
.search-box::placeholder { color: var(--muted); }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
@media (max-width: 768px) {
.layout { grid-template-columns: 1fr; }
.sidebar { height: 40vh; border-right: none; border-bottom: 1px solid var(--border); }
.fields-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<div class="logo">Alex<span>Flow</span></div>
<div class="api-base" id="apiBase">BASE: http://localhost:8000/api</div>
</header>
<nav>
<button class="tab active" onclick="switchTab('artists')">Виконавці</button>
<button class="tab" onclick="switchTab('albums')">Альбоми</button>
<button class="tab" onclick="switchTab('tracks')">Треки</button>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-header">
<span id="sidebarLabel">Виконавці</span>
<span class="count-badge" id="countBadge">0</span>
</div>
<input class="search-box" type="text" placeholder="Пошук..." id="searchBox" oninput="filterList()">
<div id="listContainer"><div class="loader">Loading</div></div>
</div>
<div class="detail-pane" id="detailPane">
<div class="empty-state">
<div class="empty-icon">◈</div>
<div class="empty-text">Оберіть елемент для перегляду</div>
</div>
</div>
</div>
<script>
const PORT = 63830; // !!!
const HOST = `http://localhost:${PORT}`;
const API = `${HOST}/api/v1`;
const TAB_LABELS = { artists: 'Виконавці', albums: 'Альбоми', tracks: 'Треки' };
let currentTab = 'artists';
let allItems = [];
let selectedId = null;
let currentSubView = 'info';
document.getElementById('apiBase').textContent = `БАЗА: ${API}`;
async function get(url) {
const r = await fetch(API + url);
if (!r.ok) throw new Error(`${r.status} ${r.statusText} — ${API + url}`);
return r.json();
}
function switchTab(tab) {
currentTab = tab;
selectedId = null;
currentSubView = 'info';
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', ['artists','albums','tracks'][i] === tab);
});
document.getElementById('sidebarLabel').textContent = TAB_LABELS[tab];
document.getElementById('searchBox').value = '';
document.getElementById('detailPane').innerHTML = `<div class="empty-state"><div class="empty-icon">◈</div><div class="empty-text">Оберіть елемент для перегляду</div></div>`;
loadList();
}
async function loadList() {
document.getElementById('listContainer').innerHTML = '<div class="loader">Завантаження</div>';
try {
allItems = await get(`/${currentTab}/`);
document.getElementById('countBadge').textContent = allItems.length;
renderList(allItems);
} catch(e) {
document.getElementById('listContainer').innerHTML = `<div class="error-msg">${e.message}</div>`;
}
}
function filterList() {
const q = document.getElementById('searchBox').value.toLowerCase();
const filtered = allItems.filter(item => {
const name = item.name || item.title || '';
return name.toLowerCase().includes(q);
});
renderList(filtered);
}
function renderList(items) {
const cont = document.getElementById('listContainer');
if (!items.length) { cont.innerHTML = '<div class="loader">Нічого не знайдено</div>'; return; }
cont.innerHTML = items.map(item => {
const name = item.name || item.title || '?';
let sub = '';
let emoji = '🎵';
if (currentTab === 'artists') { sub = item.country || '—'; emoji = '🎤'; }
if (currentTab === 'albums') { sub = item.artist || '—'; emoji = '💿'; }
if (currentTab === 'tracks') { sub = (item.artists || []).join(', ') || '—'; emoji = '🎵'; }
const sel = selectedId === item.id ? 'selected' : '';
return `<div class="list-item ${sel}" onclick="selectItem(${item.id})">
<div class="item-avatar">${emoji}</div>
<div class="item-info">
<div class="item-name">${esc(name)}</div>
<div class="item-sub">${esc(sub)}</div>
</div>
</div>`;
}).join('');
}
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
async function selectItem(id) {
selectedId = id;
currentSubView = 'info';
renderList(allItems.filter(i => {
const q = document.getElementById('searchBox').value.toLowerCase();
return (i.name || i.title || '').toLowerCase().includes(q);
}));
const pane = document.getElementById('detailPane');
pane.innerHTML = '<div class="loader">Завантаження</div>';
try {
if (currentTab === 'artists') await renderArtist(id);
if (currentTab === 'albums') await renderAlbum(id);
if (currentTab === 'tracks') await renderTrack(id);
} catch(e) {
pane.innerHTML = `<div style="padding:2rem"><div class="error-msg">${e.message}</div></div>`;
}
}
async function renderArtist(id) {
const a = await get(`/artists/${id}/`);
const pane = document.getElementById('detailPane');
pane.innerHTML = `
<div class="detail-hero">
<div class="detail-cover">${a.image ? `<img src="${HOST}${a.image}">` : '🎤'}</div>
<div class="detail-title-block">
<div class="detail-type">Виконавець</div>
<div class="detail-title">${esc(a.name)}</div>
<div class="detail-meta">
${a.country ? `<span class="meta-chip"><span class="dot"></span>${esc(a.country)}</span>` : ''}
<span class="meta-chip"><span class="dot"></span>${esc(a.slug)}</span>
</div>
</div>
</div>
<div class="action-row">
<button class="btn active" onclick="showArtistView('info',${id})">Інфо</button>
<button class="btn" onclick="showArtistView('albums',${id})">Альбоми</button>
<button class="btn" onclick="showArtistView('tracks',${id})">Треки</button>
</div>
<div class="sub-content" id="subContent">
<div class="sub-section-title">Біографія</div>
${a.bio ? `<p class="bio-text">${esc(a.bio)}</p>` : '<p style="color:var(--muted);font-size:.8rem;font-family:\'DM Mono\',monospace">Біографія відсутня.</p>'}
<br>
<div class="fields-grid" style="margin-top:.5rem">
<div class="field-block"><div class="field-label">Країна</div><div class="field-value">${esc(a.country || '—')}</div></div>
<div class="field-block"><div class="field-label">Додано</div><div class="field-value">${a.created_at ? new Date(a.created_at).toLocaleDateString('uk') : '—'}</div></div>
<div class="field-block"><div class="field-label">Slug</div><div class="field-value" style="font-family:'DM Mono',monospace">${esc(a.slug)}</div></div>
</div>
</div>`;
}
async function showArtistView(view, id) {
currentSubView = view;
document.querySelectorAll('.action-row .btn').forEach((b,i) => {
b.classList.toggle('active', ['info','albums','tracks'][i] === view);
});
const sub = document.getElementById('subContent');
sub.innerHTML = '<div class="loader">Завантаження</div>';
if (view === 'info') {
const a = await get(`/artists/${id}/`);
sub.innerHTML = `
<div class="sub-section-title">Біографія</div>
${a.bio ? `<p class="bio-text">${esc(a.bio)}</p>` : '<p style="color:var(--muted);font-size:.8rem;font-family:\'DM Mono\',monospace">Біографія відсутня.</p>'}
<br>
<div class="fields-grid" style="margin-top:.5rem">
<div class="field-block"><div class="field-label">Країна</div><div class="field-value">${esc(a.country || '—')}</div></div>
<div class="field-block"><div class="field-label">Додано</div><div class="field-value">${a.created_at ? new Date(a.created_at).toLocaleDateString('uk') : '—'}</div></div>
<div class="field-block"><div class="field-label">Slug</div><div class="field-value" style="font-family:'DM Mono',monospace">${esc(a.slug)}</div></div>
</div>`;
}
if (view === 'albums') {
const albums = await get(`/artists/${id}/albums/`);
if (!albums.length) { sub.innerHTML = '<p style="color:var(--muted);font-size:.8rem;font-family:\'DM Mono\',monospace;padding:.5rem 0">Альбоми відсутні.</p>'; return; }
sub.innerHTML = `<div class="sub-section-title">Альбоми (${albums.length})</div>` +
albums.map(al => `<div class="track-row" style="grid-template-columns:1fr auto">
<div><div class="track-name">${esc(al.title)}</div></div>
<div class="track-dur">${al.release_date || '—'}</div>
</div>`).join('');
}
if (view === 'tracks') {
const tracks = await get(`/artists/${id}/tracks/`);
if (!tracks.length) { sub.innerHTML = '<p style="color:var(--muted);font-size:.8rem;font-family:\'DM Mono\',monospace;padding:.5rem 0">Треки відсутні.</p>'; return; }
sub.innerHTML = `<div class="sub-section-title">Треки (${tracks.length})</div>` +
tracks.map((t, i) => `<div class="track-row">
<div class="track-num">${i+1}</div>
<div class="track-name">${esc(t.title)}</div>
<div></div>
<div class="track-dur">${esc(t.duration || '—')}</div>
</div>`).join('');
}
}
async function renderAlbum(id) {
const a = await get(`/albums/${id}/`);
const pane = document.getElementById('detailPane');
pane.innerHTML = `
<div class="detail-hero">
<div class="detail-cover">${a.cover ? `<img src="${HOST}${a.cover}">` : '💿'}</div>
<div class="detail-title-block">
<div class="detail-type">Альбом</div>
<div class="detail-title">${esc(a.title)}</div>
<div class="detail-meta">
<span class="meta-chip"><span class="dot"></span>${esc(a.artist)}</span>
${a.release_date ? `<span class="meta-chip"><span class="dot"></span>${esc(a.release_date)}</span>` : ''}
</div>
</div>
</div>
<div class="action-row">
<button class="btn active" onclick="showAlbumView('info',${id})">Інфо</button>
<button class="btn" onclick="showAlbumView('tracks',${id})">Треки</button>
</div>
<div class="sub-content" id="subContent">
<div class="fields-grid">
<div class="field-block"><div class="field-label">Виконавець</div><div class="field-value">${esc(a.artist)}</div></div>
<div class="field-block"><div class="field-label">Дата виходу</div><div class="field-value">${esc(a.release_date || '—')}</div></div>
</div>
</div>`;
}
async function showAlbumView(view, id) {
document.querySelectorAll('.action-row .btn').forEach((b,i) => {
b.classList.toggle('active', ['info','tracks'][i] === view);
});
const sub = document.getElementById('subContent');
sub.innerHTML = '<div class="loader">Завантаження</div>';
if (view === 'info') {
const a = await get(`/albums/${id}/`);
sub.innerHTML = `<div class="fields-grid">
<div class="field-block"><div class="field-label">Виконавець</div><div class="field-value">${esc(a.artist)}</div></div>
<div class="field-block"><div class="field-label">Дата виходу</div><div class="field-value">${esc(a.release_date || '—')}</div></div>
</div>`;
}
if (view === 'tracks') {
const tracks = await get(`/albums/${id}/tracks/`);
if (!tracks.length) { sub.innerHTML = '<p style="color:var(--muted);font-size:.8rem;font-family:\'DM Mono\',monospace">Треки відсутні.</p>'; return; }
sub.innerHTML = `<div class="sub-section-title">Треки (${tracks.length})</div>` +
tracks.map(t => `<div class="track-row">
<div class="track-num">${t.track_number || '—'}</div>
<div class="track-name">${esc(t.title)}</div>
<div></div>
<div class="track-dur">${esc(t.duration || '—')}</div>
</div>`).join('');
}
}
async function renderTrack(id) {
const t = await get(`/tracks/${id}/`);
const pane = document.getElementById('detailPane');
pane.innerHTML = `
<div class="detail-hero">
<div class="detail-cover">🎵</div>
<div class="detail-title-block">
<div class="detail-type">Трек${t.is_explicit ? '<span class="explicit-tag">E</span>' : ''}</div>
<div class="detail-title">${esc(t.title)}</div>
<div class="detail-meta">
<span class="meta-chip"><span class="dot"></span>${esc((t.artists || []).join(', '))}</span>
${t.duration ? `<span class="meta-chip"><span class="dot"></span>${esc(t.duration)}</span>` : ''}
${t.album ? `<span class="meta-chip"><span class="dot"></span>${esc(t.album)}</span>` : ''}
</div>
</div>
</div>
<div class="action-row">
<button class="btn active" onclick="showTrackView('info',${id})">Інфо</button>
${t.lyrics ? `<button class="btn" onclick="showTrackView('lyrics',${id})">Текст пісні</button>` : ''}
</div>
<div class="sub-content" id="subContent">
<div class="fields-grid">
<div class="field-block"><div class="field-label">Виконавці</div><div class="field-value">${esc((t.artists || []).join(', ') || '—')}</div></div>
<div class="field-block"><div class="field-label">Альбом</div><div class="field-value">${esc(t.album || '—')}</div></div>
<div class="field-block"><div class="field-label">Тривалість</div><div class="field-value" style="font-family:'DM Mono',monospace">${esc(t.duration || '—')}</div></div>
<div class="field-block"><div class="field-label">Ненормативний</div><div class="field-value">${t.is_explicit ? '<span style="color:var(--accent2)">Так</span>' : 'Ні'}</div></div>
</div>
</div>`;
}
async function showTrackView(view, id) {
document.querySelectorAll('.action-row .btn').forEach((b,i) => {
b.classList.toggle('active', ['info','lyrics'][i] === view);
});
const sub = document.getElementById('subContent');
sub.innerHTML = '<div class="loader">Завантаження</div>';
if (view === 'info') {
const t = await get(`/tracks/${id}/`);
sub.innerHTML = `<div class="fields-grid">
<div class="field-block"><div class="field-label">Виконавці</div><div class="field-value">${esc((t.artists || []).join(', ') || '—')}</div></div>
<div class="field-block"><div class="field-label">Альбом</div><div class="field-value">${esc(t.album || '—')}</div></div>
<div class="field-block"><div class="field-label">Тривалість</div><div class="field-value" style="font-family:'DM Mono',monospace">${esc(t.duration || '—')}</div></div>
<div class="field-block"><div class="field-label">Ненормативний</div><div class="field-value">${t.is_explicit ? '<span style="color:var(--accent2)">Так</span>' : 'Ні'}</div></div>
</div>`;
}
if (view === 'lyrics') {
const t = await get(`/tracks/${id}/`);
sub.innerHTML = `<div class="sub-section-title">Текст пісні</div>
${t.lyrics ? `<div class="lyrics-block">${esc(t.lyrics)}</div>` : '<p style="color:var(--muted);font-size:.8rem;font-family:\'DM Mono\',monospace">Текст відсутній.</p>'}`;
}
}
loadList();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment