Created
May 14, 2026 11:30
-
-
Save sunmeat/9599b9d0f7bf20b8258a874034f352b3 to your computer and use it in GitHub Desktop.
простий html-клієнт для перегляду даних з БД та відправки запитів
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!-- | |
| 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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| 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