Artist detail: "DB Record" inspector — everything the DB knows about an artist
A small glowing button at the bottom-right of the artist hero (library artists only) opens a programmer-style modal showing the COMPLETE artists DB row — every source id + match status, cached bios / tags / similar / urls, soul_id, timestamps, the lot (62 columns) — plus owned album/track counts. - Backend: GET /api/artist/<id>/record returns the full row with JSON-text columns (genres, aliases, lastfm_tags/similar, discogs_urls, …) decoded into real arrays/objects, + album/track counts. 404 for non-library artists. - Frontend: editor-themed modal (Tokyo-night tokens) with a Fields tab (copyable, filterable key/value rows) and a syntax-highlighted JSON tab. Copy-all-as-JSON, per-value copy (HTTP/Docker clipboard fallback), and Save .json. Esc / click-out to close. Helpers namespaced (_arecEsc) so they can't clobber the shared globals. Tests: endpoint returns the full row with decoded JSON + counts; 404 for a missing artist. 64 script-split integrity tests still green; ruff clean.
This commit is contained in:
parent
ee4d514d60
commit
123eb6139f
4 changed files with 534 additions and 0 deletions
59
tests/test_artist_db_record_endpoint.py
Normal file
59
tests/test_artist_db_record_endpoint.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""GET /api/artist/<id>/record — the artist-detail "DB Record" inspector source.
|
||||||
|
Returns the full artists row (JSON text columns decoded) + owned counts, 404 if
|
||||||
|
the artist isn't in the library."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-arec-')
|
||||||
|
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'a.db')
|
||||||
|
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
||||||
|
|
||||||
|
web_server = pytest.importorskip('web_server')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return web_server.app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_artist():
|
||||||
|
db = web_server.get_database()
|
||||||
|
conn = db._get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO artists (id, name, genres, musicbrainz_id, "
|
||||||
|
"musicbrainz_match_status, lastfm_listeners) VALUES (?,?,?,?,?,?)",
|
||||||
|
('99001', 'Test Artist', '["rock", "metal"]',
|
||||||
|
'mbid-123', 'matched', 4242),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_returns_full_row_with_decoded_json(client):
|
||||||
|
_insert_artist()
|
||||||
|
r = client.get('/api/artist/99001/record')
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.get_json()
|
||||||
|
assert body['success'] is True
|
||||||
|
rec = body['record']
|
||||||
|
assert rec['name'] == 'Test Artist'
|
||||||
|
assert rec['genres'] == ['rock', 'metal'] # JSON text decoded to a list
|
||||||
|
assert rec['musicbrainz_id'] == 'mbid-123'
|
||||||
|
assert rec['musicbrainz_match_status'] == 'matched'
|
||||||
|
assert rec['lastfm_listeners'] == 4242
|
||||||
|
assert 'counts' in body and 'albums' in body['counts'] and 'tracks' in body['counts']
|
||||||
|
assert body['artist_id'] == '99001'
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_artist_is_404(client):
|
||||||
|
r = client.get('/api/artist/does-not-exist-77777/record')
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.get_json()['success'] is False
|
||||||
|
|
@ -9316,6 +9316,59 @@ def get_album_tracks(album_id):
|
||||||
logger.exception("Error fetching album tracks for album %s", album_id)
|
logger.exception("Error fetching album tracks for album %s", album_id)
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/artist/<artist_id>/record', methods=['GET'])
|
||||||
|
def get_artist_db_record(artist_id):
|
||||||
|
"""Return the COMPLETE database record for a library artist — every column of
|
||||||
|
the ``artists`` row (all source IDs + match statuses, cached bios / tags /
|
||||||
|
similar / urls, timestamps, soul_id, etc.) plus owned album/track counts.
|
||||||
|
|
||||||
|
Powers the artist-detail "DB Record" inspector. JSON-encoded text columns
|
||||||
|
(genres, aliases, lastfm_tags/similar, discogs_urls, …) are decoded into real
|
||||||
|
arrays/objects so the dump is clean rather than escaped strings.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
conn = database._get_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT * FROM artists WHERE id = ?", (str(artist_id),))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return jsonify({"success": False, "error": "Artist not found in library"}), 404
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
for key in row.keys():
|
||||||
|
val = row[key]
|
||||||
|
if isinstance(val, str):
|
||||||
|
s = val.strip()
|
||||||
|
if s and s[0] in '[{':
|
||||||
|
try:
|
||||||
|
val = json.loads(s)
|
||||||
|
except Exception: # noqa: S110 — leave non-JSON text as-is
|
||||||
|
pass
|
||||||
|
record[key] = val
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
for label, table in (('albums', 'albums'), ('tracks', 'tracks')):
|
||||||
|
try:
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM {table} WHERE artist_id = ?", (str(artist_id),))
|
||||||
|
counts[label] = cur.fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
counts[label] = None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"artist_id": str(artist_id),
|
||||||
|
"counts": counts,
|
||||||
|
"record": record,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Artist DB record fetch failed for {artist_id}: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/artist/<artist_id>/download-discography', methods=['POST'])
|
@app.route('/api/artist/<artist_id>/download-discography', methods=['POST'])
|
||||||
def download_discography(artist_id):
|
def download_discography(artist_id):
|
||||||
"""Add selected albums from an artist's discography to the wishlist.
|
"""Add selected albums from an artist's discography to the wishlist.
|
||||||
|
|
|
||||||
|
|
@ -1226,6 +1226,9 @@ function populateArtistDetailPage(data) {
|
||||||
// Update hero section with image, name, and stats
|
// Update hero section with image, name, and stats
|
||||||
updateArtistHeroSection(artist, discography);
|
updateArtistHeroSection(artist, discography);
|
||||||
|
|
||||||
|
// "DB Record" inspector button (library artists only)
|
||||||
|
setupArtistRecordButton(artist);
|
||||||
|
|
||||||
// Update genres (if element exists)
|
// Update genres (if element exists)
|
||||||
updateArtistGenres(artist.genres);
|
updateArtistGenres(artist.genres);
|
||||||
|
|
||||||
|
|
@ -8788,3 +8791,222 @@ async function _mlmDeleteMatch(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================
|
// =================================
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Artist "DB Record" inspector — everything the database knows about an artist.
|
||||||
|
// A small glowing button at the bottom-right of the hero opens a programmer-style
|
||||||
|
// modal: a copyable field table + syntax-highlighted raw JSON, with copy-all and
|
||||||
|
// save-as-JSON. Library artists only (source artists have no DB row).
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let _artistRecordData = null; // last-fetched { artist_id, counts, record }
|
||||||
|
|
||||||
|
function setupArtistRecordButton(artist) {
|
||||||
|
const hero = document.getElementById('artist-hero-section');
|
||||||
|
if (!hero) return;
|
||||||
|
let btn = document.getElementById('artist-db-record-btn');
|
||||||
|
|
||||||
|
const isLibrary = !!(artist && artist.id && document.body.dataset.artistSource === 'library');
|
||||||
|
if (!isLibrary) { if (btn) btn.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
if (!btn) {
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.id = 'artist-db-record-btn';
|
||||||
|
btn.className = 'artist-db-record-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.title = 'Inspect everything the database knows about this artist';
|
||||||
|
btn.innerHTML =
|
||||||
|
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||||
|
'<ellipse cx="12" cy="5" rx="8" ry="3"></ellipse>' +
|
||||||
|
'<path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5"></path>' +
|
||||||
|
'<path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"></path>' +
|
||||||
|
'</svg><span>DB Record</span>';
|
||||||
|
hero.appendChild(btn);
|
||||||
|
}
|
||||||
|
btn.style.display = '';
|
||||||
|
btn.onclick = () => openArtistRecordModal(artist.id, artist.name || 'Artist');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openArtistRecordModal(artistId, artistName) {
|
||||||
|
// Clean any prior instance
|
||||||
|
const existing = document.getElementById('artist-record-overlay');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'artist-record-overlay';
|
||||||
|
overlay.className = 'arec-overlay';
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="arec-card" role="dialog" aria-label="Artist database record">' +
|
||||||
|
'<div class="arec-header">' +
|
||||||
|
'<div class="arec-title-wrap">' +
|
||||||
|
'<div class="arec-title"><span class="arec-dot"></span>Artist DB Record</div>' +
|
||||||
|
'<div class="arec-sub" id="arec-sub">' + _arecEsc(artistName) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button class="arec-close" id="arec-close" title="Close (Esc)">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="arec-toolbar">' +
|
||||||
|
'<div class="arec-tabs">' +
|
||||||
|
'<button class="arec-tab active" data-tab="fields">Fields</button>' +
|
||||||
|
'<button class="arec-tab" data-tab="json">JSON</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<input id="arec-filter" class="arec-filter" type="text" placeholder="filter fields…" autocomplete="off" spellcheck="false">' +
|
||||||
|
'<div class="arec-actions">' +
|
||||||
|
'<button class="arec-btn" id="arec-copy"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>Copy JSON</button>' +
|
||||||
|
'<button class="arec-btn" id="arec-download"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>Save .json</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="arec-body" id="arec-body">' +
|
||||||
|
'<div class="arec-loading">Loading record…</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="arec-footer" id="arec-footer"></div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
setTimeout(() => overlay.remove(), 220);
|
||||||
|
};
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelector('#arec-close').onclick = close;
|
||||||
|
|
||||||
|
// Fetch the record
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/artist/${encodeURIComponent(artistId)}/record`);
|
||||||
|
payload = await res.json();
|
||||||
|
if (!payload || !payload.success) throw new Error((payload && payload.error) || 'Request failed');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('arec-body').innerHTML =
|
||||||
|
'<div class="arec-error">Could not load record: ' + _arecEsc(err.message || String(err)) + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_artistRecordData = payload;
|
||||||
|
const record = payload.record || {};
|
||||||
|
const counts = payload.counts || {};
|
||||||
|
|
||||||
|
// Footer stat line
|
||||||
|
const fieldCount = Object.keys(record).length;
|
||||||
|
const matched = Object.entries(record).filter(([k, v]) => /match_status$/.test(k) && v === 'matched').length;
|
||||||
|
document.getElementById('arec-footer').innerHTML =
|
||||||
|
'<span><b>' + fieldCount + '</b> fields</span>' +
|
||||||
|
'<span><b>' + (counts.albums != null ? counts.albums : '–') + '</b> albums</span>' +
|
||||||
|
'<span><b>' + (counts.tracks != null ? counts.tracks : '–') + '</b> tracks</span>' +
|
||||||
|
'<span><b>' + matched + '</b> sources matched</span>' +
|
||||||
|
'<span class="arec-id">id ' + _arecEsc(String(payload.artist_id)) + '</span>';
|
||||||
|
|
||||||
|
_arecRenderFields(record);
|
||||||
|
|
||||||
|
// Toolbar wiring
|
||||||
|
overlay.querySelectorAll('.arec-tab').forEach(tab => {
|
||||||
|
tab.onclick = () => {
|
||||||
|
overlay.querySelectorAll('.arec-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
const filterEl = document.getElementById('arec-filter');
|
||||||
|
if (tab.dataset.tab === 'json') { _arecRenderJson(record); filterEl.style.visibility = 'hidden'; }
|
||||||
|
else { _arecRenderFields(record); filterEl.style.visibility = ''; _arecApplyFilter(filterEl.value); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.getElementById('arec-filter').addEventListener('input', (e) => _arecApplyFilter(e.target.value));
|
||||||
|
document.getElementById('arec-copy').onclick = () =>
|
||||||
|
_arecCopy(JSON.stringify(record, null, 2), 'Full record copied as JSON');
|
||||||
|
document.getElementById('arec-download').onclick = () => _arecDownload(record, artistName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _arecRenderFields(record) {
|
||||||
|
const body = document.getElementById('arec-body');
|
||||||
|
if (!body) return;
|
||||||
|
const rows = Object.entries(record).map(([key, val]) => {
|
||||||
|
const isEmpty = val === null || val === undefined || val === '';
|
||||||
|
let display, copyVal;
|
||||||
|
if (isEmpty) { display = '<span class="arec-null">null</span>'; copyVal = ''; }
|
||||||
|
else if (typeof val === 'object') {
|
||||||
|
copyVal = JSON.stringify(val);
|
||||||
|
display = '<span class="arec-json">' + _arecEsc(JSON.stringify(val)) + '</span>';
|
||||||
|
} else {
|
||||||
|
copyVal = String(val);
|
||||||
|
display = _arecEsc(String(val));
|
||||||
|
}
|
||||||
|
return '<div class="arec-row' + (isEmpty ? ' is-empty' : '') + '" data-field="' + _arecEscAttr(key.toLowerCase()) +
|
||||||
|
' ' + _arecEscAttr(copyVal.toLowerCase()) + '">' +
|
||||||
|
'<span class="arec-key">' + _arecEsc(key) + '</span>' +
|
||||||
|
'<span class="arec-val">' + display + '</span>' +
|
||||||
|
'<button class="arec-rowcopy" title="Copy value" data-copy="' + _arecEscAttr(copyVal) + '">⧉</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
body.innerHTML = '<div class="arec-fields">' + rows + '</div>';
|
||||||
|
body.querySelectorAll('.arec-rowcopy').forEach(b => {
|
||||||
|
b.onclick = () => _arecCopy(b.getAttribute('data-copy'), 'Value copied');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _arecRenderJson(record) {
|
||||||
|
const body = document.getElementById('arec-body');
|
||||||
|
if (!body) return;
|
||||||
|
body.innerHTML = '<pre class="arec-code">' + _jsonSyntaxHighlight(record) + '</pre>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _arecApplyFilter(q) {
|
||||||
|
q = (q || '').trim().toLowerCase();
|
||||||
|
document.querySelectorAll('#arec-body .arec-row').forEach(row => {
|
||||||
|
row.style.display = (!q || row.dataset.field.includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _jsonSyntaxHighlight(obj) {
|
||||||
|
let json = JSON.stringify(obj, null, 2);
|
||||||
|
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => {
|
||||||
|
let cls = 'tok-num';
|
||||||
|
if (/^"/.test(m)) cls = /:$/.test(m) ? 'tok-key' : 'tok-str';
|
||||||
|
else if (/true|false/.test(m)) cls = 'tok-bool';
|
||||||
|
else if (/null/.test(m)) cls = 'tok-null';
|
||||||
|
return '<span class="' + cls + '">' + m + '</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _arecCopy(text, label) {
|
||||||
|
text = text == null ? '' : String(text);
|
||||||
|
const done = () => (typeof showToast === 'function') && showToast(label || 'Copied', 'success');
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(done).catch(() => _arecCopyFallback(text, done));
|
||||||
|
} else { _arecCopyFallback(text, done); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _arecCopyFallback(text, done) {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _arecDownload(record, artistName) {
|
||||||
|
const safe = String(artistName || 'artist').replace(/[^a-z0-9._-]+/gi, '_').slice(0, 60) || 'artist';
|
||||||
|
const blob = new Blob([JSON.stringify(record, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = safe + '_db_record.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
if (typeof showToast === 'function') showToast('Saved ' + a.download, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _arecEsc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
function _arecEscAttr(s) {
|
||||||
|
return _arecEsc(s).replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67977,3 +67977,203 @@ body.em-scroll-lock { overflow: hidden; }
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
Artist "DB Record" inspector — button + programmer-style modal
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
#artist-hero-section { position: relative; }
|
||||||
|
|
||||||
|
.artist-db-record-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 14px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 6;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 13px;
|
||||||
|
font: 600 11.5px/1 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #cdd6f4;
|
||||||
|
background: linear-gradient(135deg, rgba(20,24,34,0.72), rgba(14,17,23,0.82));
|
||||||
|
border: 1px solid rgba(122,162,247,0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 4px 18px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.03),
|
||||||
|
0 0 0 0 rgba(122,162,247,0.0);
|
||||||
|
opacity: 0.82;
|
||||||
|
transition: transform .2s cubic-bezier(.4,0,.2,1), box-shadow .25s ease,
|
||||||
|
opacity .2s ease, border-color .25s ease, color .2s ease;
|
||||||
|
}
|
||||||
|
.artist-db-record-btn svg { color: #7aa2f7; transition: color .2s ease, transform .3s ease; }
|
||||||
|
.artist-db-record-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(122,162,247,0.7);
|
||||||
|
box-shadow: 0 8px 26px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.05),
|
||||||
|
0 0 18px rgba(122,162,247,0.35);
|
||||||
|
}
|
||||||
|
.artist-db-record-btn:hover svg { color: #9ece6a; transform: rotate(-6deg) scale(1.08); }
|
||||||
|
.artist-db-record-btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* ── Overlay + card ── */
|
||||||
|
.arec-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 10050;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(8,10,14,0.7);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
opacity: 0; transition: opacity .22s ease;
|
||||||
|
}
|
||||||
|
.arec-overlay.visible { opacity: 1; }
|
||||||
|
|
||||||
|
.arec-card {
|
||||||
|
width: min(760px, 96vw);
|
||||||
|
max-height: min(82vh, 820px);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, #11151c, #0d1017);
|
||||||
|
border: 1px solid rgba(122,162,247,0.22);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.02),
|
||||||
|
0 0 60px rgba(122,162,247,0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(10px) scale(0.985);
|
||||||
|
transition: transform .26s cubic-bezier(.34,1.3,.5,1);
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.arec-overlay.visible .arec-card { transform: translateY(0) scale(1); }
|
||||||
|
|
||||||
|
.arec-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
background: linear-gradient(180deg, rgba(122,162,247,0.06), transparent);
|
||||||
|
}
|
||||||
|
.arec-title {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font: 700 14px/1 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||||
|
color: #e6ebff; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.arec-dot {
|
||||||
|
width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
background: #9ece6a; box-shadow: 0 0 10px #9ece6a;
|
||||||
|
animation: arecPulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes arecPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
.arec-sub {
|
||||||
|
margin-top: 4px; font-size: 12px; color: #8b93b0;
|
||||||
|
font-family: ui-monospace, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.arec-close {
|
||||||
|
background: none; border: none; color: #8b93b0; font-size: 24px;
|
||||||
|
line-height: 1; cursor: pointer; padding: 0 4px; transition: color .15s ease, transform .15s ease;
|
||||||
|
}
|
||||||
|
.arec-close:hover { color: #ff6b6b; transform: scale(1.12); }
|
||||||
|
|
||||||
|
/* ── Toolbar ── */
|
||||||
|
.arec-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.arec-tabs { display: inline-flex; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 2px; }
|
||||||
|
.arec-tab {
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
padding: 5px 12px; border-radius: 6px;
|
||||||
|
font: 600 11.5px/1 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
color: #8b93b0; transition: all .15s ease;
|
||||||
|
}
|
||||||
|
.arec-tab.active { background: rgba(122,162,247,0.22); color: #cdd6f4; }
|
||||||
|
.arec-tab:not(.active):hover { color: #cdd6f4; }
|
||||||
|
.arec-filter {
|
||||||
|
flex: 1; min-width: 120px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font: 12px/1 ui-monospace, Menlo, monospace;
|
||||||
|
outline: none; transition: border-color .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
.arec-filter:focus { border-color: rgba(122,162,247,0.5); box-shadow: 0 0 0 3px rgba(122,162,247,0.12); }
|
||||||
|
.arec-actions { display: inline-flex; gap: 8px; }
|
||||||
|
.arec-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: rgba(122,162,247,0.1);
|
||||||
|
border: 1px solid rgba(122,162,247,0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #c0caf5; cursor: pointer;
|
||||||
|
font: 600 11.5px/1 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
.arec-btn:hover { background: rgba(122,162,247,0.2); border-color: rgba(122,162,247,0.55); color: #fff; }
|
||||||
|
.arec-btn svg { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.arec-body {
|
||||||
|
flex: 1; overflow: auto; padding: 8px 6px 8px 14px;
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.arec-loading, .arec-error { padding: 28px; text-align: center; color: #8b93b0; font-size: 13px; }
|
||||||
|
.arec-error { color: #ff8585; }
|
||||||
|
|
||||||
|
.arec-fields { display: flex; flex-direction: column; }
|
||||||
|
.arec-row {
|
||||||
|
display: grid; grid-template-columns: minmax(140px, 210px) 1fr auto;
|
||||||
|
gap: 10px; align-items: start;
|
||||||
|
padding: 6px 8px; border-radius: 7px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.035);
|
||||||
|
transition: background .12s ease;
|
||||||
|
}
|
||||||
|
.arec-row:hover { background: rgba(122,162,247,0.07); }
|
||||||
|
.arec-row.is-empty { opacity: 0.5; }
|
||||||
|
.arec-key { color: #7aa2f7; font-size: 12px; font-weight: 600; word-break: break-all; }
|
||||||
|
.arec-val { color: #c8d0e8; font-size: 12px; word-break: break-word; white-space: pre-wrap; }
|
||||||
|
.arec-val .arec-json { color: #9ece6a; }
|
||||||
|
.arec-val .arec-null, .arec-null { color: #565f89; font-style: italic; }
|
||||||
|
.arec-rowcopy {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: #565f89; font-size: 13px; padding: 1px 5px; border-radius: 5px;
|
||||||
|
opacity: 0; transition: opacity .12s ease, color .12s ease, background .12s ease;
|
||||||
|
}
|
||||||
|
.arec-row:hover .arec-rowcopy { opacity: 1; }
|
||||||
|
.arec-rowcopy:hover { color: #9ece6a; background: rgba(255,255,255,0.06); }
|
||||||
|
|
||||||
|
.arec-code {
|
||||||
|
margin: 0; padding: 6px 8px; font-size: 12px; line-height: 1.55;
|
||||||
|
color: #c0caf5; white-space: pre; tab-size: 2;
|
||||||
|
}
|
||||||
|
.arec-code .tok-key { color: #7aa2f7; }
|
||||||
|
.arec-code .tok-str { color: #9ece6a; }
|
||||||
|
.arec-code .tok-num { color: #ff9e64; }
|
||||||
|
.arec-code .tok-bool { color: #bb9af7; }
|
||||||
|
.arec-code .tok-null { color: #565f89; font-style: italic; }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.arec-footer {
|
||||||
|
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
font: 11px/1 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
color: #8b93b0;
|
||||||
|
}
|
||||||
|
.arec-footer b { color: #cdd6f4; }
|
||||||
|
.arec-footer .arec-id { margin-left: auto; color: #565f89; }
|
||||||
|
|
||||||
|
/* scrollbar */
|
||||||
|
.arec-body::-webkit-scrollbar { width: 10px; }
|
||||||
|
.arec-body::-webkit-scrollbar-thumb { background: rgba(122,162,247,0.25); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
|
||||||
|
.arec-body::-webkit-scrollbar-thumb:hover { background: rgba(122,162,247,0.45); background-clip: padding-box; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.artist-db-record-btn span { display: none; }
|
||||||
|
.artist-db-record-btn { padding: 8px; }
|
||||||
|
.arec-row { grid-template-columns: 1fr; gap: 2px; }
|
||||||
|
.arec-rowcopy { opacity: 1; justify-self: end; margin-top: -22px; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue