Video download history: API + beautiful timeline modal (phase 2)

GET /api/video/downloads/history (paged, ?kind/search/outcome) + /history/<id>, both
returning the live tab counts. New self-contained modal (video-download-history.js,
.vdh-* styles) opened from a History button on the Downloads page: day-grouped
timeline of every grab with poster, title, S/E, quality/resolution/codec/size and an
outcome badge; rows expand in place to reveal the full detail (release, source/uploader,
codecs, dest path, grabbed/finished times, error). Tabs (All/Movies/TV), search,
load-more, and a live count badge on the button.
This commit is contained in:
BoulderBadgeDad 2026-06-21 14:15:37 -07:00
parent 6fe82b2a78
commit ed2fbf2ae4
5 changed files with 424 additions and 0 deletions

View file

@ -122,6 +122,32 @@ def register_routes(bp):
save_source(db, body) # download_mode + hybrid_order (validated)
return jsonify({"status": "saved"})
@bp.route("/downloads/history", methods=["GET"])
def video_downloads_history():
"""Paged permanent history of grabs (movies + episodes). ?kind=movie|show,
?search=, ?outcome=, ?page=, ?limit=. Always returns counts for the tabs."""
from . import get_video_db
try:
db = get_video_db()
kind = request.args.get("kind")
res = db.query_download_history(
kind=kind if kind in ("movie", "show") else None,
search=request.args.get("search", ""),
outcome=request.args.get("outcome") or None,
page=request.args.get("page", 1), limit=request.args.get("limit", 40))
return jsonify({"success": True, "counts": db.download_history_counts(), **res})
except Exception:
logger.exception("Failed to list video download history")
return jsonify({"success": False, "error": "Failed to load history"}), 500
@bp.route("/downloads/history/<int:history_id>", methods=["GET"])
def video_downloads_history_detail(history_id):
from . import get_video_db
d = get_video_db().download_history_detail(history_id)
if not d:
return jsonify({"success": False, "error": "not found"}), 404
return jsonify({"success": True, "item": d})
@bp.route("/downloads/quality", methods=["GET"])
def video_quality_profile():
from . import get_video_db

View file

@ -1081,3 +1081,38 @@ def test_enrichment_youtube_status_route(tmp_path):
assert client.post("/api/video/enrichment/youtube/resume").get_json()["status"] == "running"
# unknown service still 404s
assert client.get("/api/video/enrichment/bogus/status").status_code == 404
def test_download_history_endpoints(tmp_path):
client, videoapi = _make_client(tmp_path)
db = videoapi._video_db
db.record_download_history({
"id": 1, "kind": "movie", "title": "Dune", "year": 2024, "status": "completed",
"release_title": "Dune.2024.2160p.x265", "dest_path": "/m/Dune.mkv",
"size_bytes": 9_000_000_000, "completed_at": "2026-06-20 10:30:00"})
# list + counts
r = client.get("/api/video/downloads/history")
assert r.status_code == 200
body = r.get_json()
assert body["success"] and body["counts"]["movie"] == 1
assert body["items"][0]["title"] == "Dune"
assert body["items"][0]["resolution"] == "2160p"
# kind filter
assert client.get("/api/video/downloads/history?kind=show").get_json()["pagination"]["total_count"] == 0
# detail
hid = body["items"][0]["id"]
d = client.get(f"/api/video/downloads/history/{hid}").get_json()
assert d["success"] and d["item"]["dest_path"] == "/m/Dune.mkv"
assert client.get("/api/video/downloads/history/99999").status_code == 404
def test_download_history_routes_registered():
from api.video import create_video_blueprint
app = Flask(__name__)
app.register_blueprint(create_video_blueprint(), url_prefix="/api/video")
rules = {r.rule for r in app.url_map.iter_rules()}
assert "/api/video/downloads/history" in rules
assert "/api/video/downloads/history/<int:history_id>" in rules

View file

@ -1331,6 +1331,10 @@
</div>
<div class="adl-controls-right">
<span class="adl-count" data-vdpg-sub></span>
<button class="adl-history-btn" type="button" data-vdh-open title="Download history">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>
History <span class="adl-history-n" data-vdh-count hidden>0</span>
</button>
<button class="adl-cancel-all-btn" type="button" data-vdpg-cancel-all style="display:none">Cancel All</button>
<button class="adl-clear-btn" type="button" data-vdpg-clear style="display:none">Clear Finished</button>
</div>
@ -10432,6 +10436,7 @@
<!-- Download VIEW renderer (movie / show / youtube): per-source search + quality verdict;
rendered inline inside the get-modal (not its own modal) -->
<script src="{{ url_for('static', filename='video/video-download-view.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='video/video-download-history.js', v=static_v) }}"></script>
<!-- Downloads page (isolated): live status of every grab from the video side -->
<script src="{{ url_for('static', filename='video/video-downloads-page.js', v=static_v) }}"></script>
<!-- Automations page (shared system automations, video view) -->

View file

@ -0,0 +1,253 @@
/*
* SoulSync Video Download History modal (isolated).
*
* A permanent, beautiful timeline of every grab SoulSync completed (movies +
* episodes), backed by /api/video/downloads/history. Self-contained: builds its
* own overlay DOM, no shell in index.html. Opened by [data-vdh-open]; rows expand
* in place to reveal the full rich detail (poster, release, source, path, codecs).
*/
(function () {
'use strict';
var LIMIT = 40;
var state = { open: false, tab: 'all', search: '', page: 1, loading: false,
counts: { movie: 0, show: 0, total: 0 }, items: [], pages: 1 };
var el = null, searchTimer = null;
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtSize(b) {
b = Number(b) || 0;
if (b <= 0) return '';
var u = ['B', 'KB', 'MB', 'GB', 'TB'], i = Math.floor(Math.log(b) / Math.log(1024));
return (b / Math.pow(1024, i)).toFixed(i >= 3 ? 1 : 0) + ' ' + u[i];
}
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function fmtWhen(iso) {
if (!iso) return '';
var p = String(iso).replace('T', ' ').split(/[- :]/);
if (p.length < 3) return esc(iso);
var mo = MO[(+p[1] || 1) - 1], d = +p[2], y = +p[0];
var t = (p.length >= 5) ? (' · ' + p[3] + ':' + p[4]) : '';
return mo + ' ' + d + ', ' + y + t;
}
function relDay(iso) {
if (!iso) return '';
var p = String(iso).replace('T', ' ').split(/[- :]/);
if (p.length < 3) return '';
return p[0] + '-' + p[1] + '-' + p[2];
}
function dayLabel(iso) {
var p = String(iso || '').replace('T', ' ').split(/[- :]/);
if (p.length < 3) return 'Earlier';
return MO[(+p[1] || 1) - 1] + ' ' + (+p[2]) + ', ' + p[0];
}
var OUTCOME = {
completed: ['Imported', 'vdh-oc--ok'], import_failed: ['Needs import', 'vdh-oc--warn'],
failed: ['Failed', 'vdh-oc--fail'], cancelled: ['Cancelled', 'vdh-oc--muted'],
};
function ensureDom() {
if (el) return el;
el = document.createElement('div');
el.className = 'vdh-overlay';
el.innerHTML =
'<div class="vdh-modal" role="dialog" aria-modal="true" aria-label="Download history">' +
'<div class="vdh-head">' +
'<div class="vdh-head-titles">' +
'<h2 class="vdh-title">Download History</h2>' +
'<p class="vdh-sub" data-vdh-sub></p>' +
'</div>' +
'<button class="vdh-close" type="button" data-vdh-close aria-label="Close">&times;</button>' +
'</div>' +
'<div class="vdh-toolbar">' +
'<div class="vdh-tabs" role="tablist">' +
'<button class="vdh-tab vdh-tab--on" type="button" data-vdh-tab="all">All <span class="vdh-tab-n" data-vdh-c-all>0</span></button>' +
'<button class="vdh-tab" type="button" data-vdh-tab="movie">Movies <span class="vdh-tab-n" data-vdh-c-movie>0</span></button>' +
'<button class="vdh-tab" type="button" data-vdh-tab="show">TV <span class="vdh-tab-n" data-vdh-c-show>0</span></button>' +
'</div>' +
'<div class="vdh-search">' +
'<svg class="vdh-search-ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>' +
'<input type="text" class="vdh-search-input" data-vdh-search placeholder="Search history…" autocomplete="off" spellcheck="false">' +
'</div>' +
'</div>' +
'<div class="vdh-body" data-vdh-body></div>' +
'<div class="vdh-foot" data-vdh-foot hidden>' +
'<button class="vdh-more" type="button" data-vdh-more>Load more</button>' +
'</div>' +
'</div>';
document.body.appendChild(el);
el.addEventListener('click', function (e) {
if (e.target === el || e.target.closest('[data-vdh-close]')) { close(); return; }
var tab = e.target.closest('[data-vdh-tab]');
if (tab) { setTab(tab.getAttribute('data-vdh-tab')); return; }
if (e.target.closest('[data-vdh-more]')) { state.page++; load(true); return; }
var row = e.target.closest('[data-vdh-row]');
if (row) { row.classList.toggle('vdh-row--open'); }
});
var si = el.querySelector('[data-vdh-search]');
si.addEventListener('input', function () {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
state.search = si.value.trim(); state.page = 1; load(false);
}, 250);
});
return el;
}
function setTab(tab) {
if (tab === state.tab) return;
state.tab = tab; state.page = 1;
var tabs = el.querySelectorAll('[data-vdh-tab]');
for (var i = 0; i < tabs.length; i++)
tabs[i].classList.toggle('vdh-tab--on', tabs[i].getAttribute('data-vdh-tab') === tab);
load(false);
}
function rowHtml(it) {
var isShow = it.kind === 'show';
var oc = OUTCOME[it.outcome] || OUTCOME.completed;
var sxe = (it.season_number != null && it.episode_number != null)
? 'S' + String(it.season_number).padStart(2, '0') + 'E' + String(it.episode_number).padStart(2, '0') : '';
var sub = [sxe, it.year, it.quality_label, it.resolution, it.video_codec, fmtSize(it.size_bytes)]
.filter(Boolean).join(' · ');
var poster = it.poster_url
? '<img class="vdh-poster" src="' + esc(it.poster_url) + '" alt="" loading="lazy" onerror="this.classList.add(\'vdh-poster--none\')">'
: '<span class="vdh-poster vdh-poster--none">' + (isShow ? '📺' : '🎬') + '</span>';
function dl(label, val) {
return val ? '<div class="vdh-d"><span class="vdh-d-k">' + label + '</span><span class="vdh-d-v">' + esc(val) + '</span></div>' : '';
}
var detail =
'<div class="vdh-detail">' +
dl('Release', it.release_title) +
dl('Source', [it.source, it.username].filter(Boolean).join(' · ')) +
dl('Codec', [it.resolution, it.video_codec].filter(Boolean).join(' · ')) +
dl('Size', fmtSize(it.size_bytes)) +
dl('Grabbed', fmtWhen(it.grabbed_at)) +
dl('Finished', fmtWhen(it.completed_at)) +
dl('Path', it.dest_path) +
(it.error ? '<div class="vdh-d vdh-d--err"><span class="vdh-d-k">Error</span><span class="vdh-d-v">' + esc(it.error) + '</span></div>' : '') +
'</div>';
return '<div class="vdh-row" data-vdh-row data-id="' + esc(it.id) + '">' +
'<div class="vdh-row-main">' +
poster +
'<div class="vdh-row-info">' +
'<div class="vdh-row-title">' + esc(it.title || it.release_title || 'Unknown') +
(sxe ? ' <span class="vdh-sxe">' + esc(sxe) + '</span>' : '') + '</div>' +
'<div class="vdh-row-sub">' + esc(sub) + '</div>' +
'</div>' +
'<div class="vdh-row-right">' +
'<span class="vdh-oc ' + oc[1] + '">' + oc[0] + '</span>' +
'<span class="vdh-when">' + esc(fmtWhen(it.completed_at)) + '</span>' +
'<span class="vdh-chev" aria-hidden="true"></span>' +
'</div>' +
'</div>' + detail +
'</div>';
}
function render() {
var body = el.querySelector('[data-vdh-body]');
if (!state.items.length) {
body.innerHTML = '<div class="vdh-empty">' +
'<div class="vdh-empty-ic">📦</div>' +
'<div class="vdh-empty-t">' + (state.search ? 'No matches' : 'Nothing here yet') + '</div>' +
'<div class="vdh-empty-s">' + (state.search ? 'Try a different search.' : 'Grabs you complete will be recorded here forever.') + '</div></div>';
return;
}
// Group rows under day headers (timeline feel).
var html = '', lastDay = null;
state.items.forEach(function (it) {
var d = relDay(it.completed_at || it.grabbed_at);
if (d !== lastDay) {
lastDay = d;
html += '<div class="vdh-day">' + esc(dayLabel(it.completed_at || it.grabbed_at)) + '</div>';
}
html += rowHtml(it);
});
body.innerHTML = html;
}
function setCounts(c) {
state.counts = c || state.counts;
var q = function (s) { return el.querySelector(s); };
q('[data-vdh-c-all]').textContent = state.counts.total || 0;
q('[data-vdh-c-movie]').textContent = state.counts.movie || 0;
q('[data-vdh-c-show]').textContent = state.counts.show || 0;
var sub = q('[data-vdh-sub]');
sub.textContent = (state.counts.total || 0) + ' grab' + (state.counts.total === 1 ? '' : 's') +
' · ' + (state.counts.movie || 0) + ' movies · ' + (state.counts.show || 0) + ' episodes';
updateBadge(state.counts.total || 0);
}
function updateBadge(n) {
document.querySelectorAll('[data-vdh-count]').forEach(function (b) {
b.textContent = n; b.hidden = !n;
});
}
function load(append) {
if (state.loading) return;
state.loading = true;
var body = el.querySelector('[data-vdh-body]');
if (!append) body.classList.add('vdh-body--loading');
var params = new URLSearchParams({ page: state.page, limit: LIMIT, search: state.search });
if (state.tab !== 'all') params.set('kind', state.tab);
fetch('/api/video/downloads/history?' + params.toString(), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
state.loading = false;
body.classList.remove('vdh-body--loading');
if (!d || !d.success) { if (!append) { state.items = []; render(); } return; }
setCounts(d.counts);
state.pages = (d.pagination && d.pagination.total_pages) || 1;
state.items = append ? state.items.concat(d.items || []) : (d.items || []);
render();
var foot = el.querySelector('[data-vdh-foot]');
foot.hidden = state.page >= state.pages;
})
.catch(function () {
state.loading = false; body.classList.remove('vdh-body--loading');
if (!append) { state.items = []; render(); }
});
}
function open() {
ensureDom();
state.open = true; state.page = 1; state.search = '';
var si = el.querySelector('[data-vdh-search]'); if (si) si.value = '';
el.classList.add('vdh-overlay--on');
document.body.classList.add('vdh-locked');
load(false);
}
function close() {
if (!el) return;
state.open = false;
el.classList.remove('vdh-overlay--on');
document.body.classList.remove('vdh-locked');
}
// Keep the header badge fresh (cheap counts call) without opening the modal.
function refreshCount() {
fetch('/api/video/downloads/history?limit=1', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.counts) updateBadge(d.counts.total || 0); })
.catch(function () {});
}
document.addEventListener('click', function (e) {
if (e.target.closest('[data-vdh-open]')) { e.preventDefault(); open(); }
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && state.open) close(); });
// Refresh the badge when the downloads page is shown.
document.addEventListener('soulsync:video-page-shown', function (e) {
if (e.detail === 'video-downloads') refreshCount();
});
window.VideoDownloadHistory = { open: open, close: close, refreshCount: refreshCount };
})();

View file

@ -3643,3 +3643,108 @@ body[data-side="video"] #soulsync-toggle { display: none; }
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.14); width: 90px; }
.vimp-ep-field--wide input { width: 100%; }
.vimp-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px 18px; border-top: 1px solid rgba(255, 255, 255, 0.06); margin-top: 8px; }
/* ── Download History modal (.vdh-*) — permanent timeline of every grab ───── */
.adl-history-btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 13px;
border-radius: 10px; cursor: pointer; font-size: 12.5px; font-weight: 700;
color: rgba(255, 255, 255, 0.75); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); transition: all 0.18s ease; }
.adl-history-btn:hover { color: #fff; border-color: rgba(var(--accent-rgb, 88 101 242), 0.5);
background: rgba(var(--accent-rgb, 88 101 242), 0.1); }
.adl-history-n { font-size: 10.5px; font-weight: 800; min-width: 16px; text-align: center; padding: 1px 5px;
border-radius: 999px; background: rgba(var(--accent-rgb, 88 101 242), 0.28); color: #fff; }
body.vdh-locked { overflow: hidden; }
.vdh-overlay { position: fixed; inset: 0; z-index: 4000; display: none; align-items: center; justify-content: center;
padding: 4vh 20px; background: rgba(6, 6, 9, 0.7); backdrop-filter: blur(8px);
opacity: 0; transition: opacity 0.2s ease; }
.vdh-overlay--on { display: flex; opacity: 1; }
.vdh-modal { --accent-rgb: var(--accent-rgb, 88, 101, 242); width: min(880px, 96vw); max-height: 92vh;
display: flex; flex-direction: column; border-radius: 20px; overflow: hidden;
background: linear-gradient(180deg, #17171d, #121217);
border: 1px solid rgba(255, 255, 255, 0.09); box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.8);
transform: translateY(8px) scale(0.99); transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); }
.vdh-overlay--on .vdh-modal { transform: none; }
.vdh-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px;
padding: 22px 24px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); }
.vdh-title { margin: 0; font-size: 20px; font-weight: 800; color: #fff; letter-spacing: -0.01em; }
.vdh-sub { margin: 4px 0 0; font-size: 12.5px; color: rgba(255, 255, 255, 0.45); }
.vdh-close { flex: 0 0 auto; width: 34px; height: 34px; border-radius: 10px; cursor: pointer; font-size: 22px; line-height: 1;
color: rgba(255, 255, 255, 0.6); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.15s ease; }
.vdh-close:hover { color: #fff; background: rgba(255, 255, 255, 0.1); }
.vdh-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 24px; }
.vdh-tabs { display: inline-flex; gap: 8px; }
.vdh-tab { display: inline-flex; align-items: center; gap: 7px; padding: 8px 15px; border-radius: 11px; cursor: pointer;
font-size: 12.5px; font-weight: 700; color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.1);
transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s; }
.vdh-tab:hover { color: #fff; border-color: rgba(255, 255, 255, 0.2); }
.vdh-tab--on { color: #fff; background: rgba(var(--accent-rgb), 0.08);
border-color: rgba(var(--accent-rgb), 0.6); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); }
.vdh-tab-n { font-size: 10.5px; font-weight: 800; min-width: 16px; text-align: center; padding: 1px 5px;
border-radius: 999px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.6); }
.vdh-tab--on .vdh-tab-n { background: rgba(var(--accent-rgb), 0.28); color: #fff; }
.vdh-search { position: relative; display: flex; align-items: center; gap: 9px; margin-left: auto; height: 38px;
flex: 1; min-width: 180px; max-width: 320px; padding: 0 13px; box-sizing: border-box;
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 11px;
transition: border-color 0.2s, box-shadow 0.2s; }
.vdh-search:focus-within { border-color: rgba(var(--accent-rgb), 0.6); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); }
.vdh-search-ic { flex: 0 0 auto; color: rgba(255, 255, 255, 0.4); }
.vdh-search:focus-within .vdh-search-ic { color: rgba(var(--accent-rgb), 0.95); }
.vdh-search-input { flex: 1; min-width: 0; height: 100%; border: 0; background: transparent; outline: none;
color: #fff; font-size: 13px; }
.vdh-search-input::placeholder { color: rgba(255, 255, 255, 0.4); }
.vdh-body { flex: 1; overflow-y: auto; padding: 6px 16px 8px; min-height: 160px; }
.vdh-body--loading { opacity: 0.5; }
.vdh-day { position: sticky; top: 0; z-index: 1; padding: 12px 8px 6px; font-size: 11px; font-weight: 800;
text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.38);
background: linear-gradient(180deg, #15151b 60%, transparent); }
.vdh-row { border-radius: 13px; border: 1px solid transparent; margin: 3px 0; cursor: pointer;
transition: background 0.15s, border-color 0.15s; }
.vdh-row:hover { background: rgba(255, 255, 255, 0.03); }
.vdh-row--open { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.08); }
.vdh-row-main { display: flex; align-items: center; gap: 13px; padding: 9px 12px; }
.vdh-poster { flex: 0 0 auto; width: 40px; height: 60px; border-radius: 7px; object-fit: cover; background: #0f0f14;
display: inline-flex; align-items: center; justify-content: center; font-size: 20px; overflow: hidden; }
.vdh-poster--none { color: rgba(255, 255, 255, 0.25); border: 1px solid rgba(255, 255, 255, 0.07); }
img.vdh-poster--none { font-size: 0; }
.vdh-row-info { flex: 1; min-width: 0; }
.vdh-row-title { font-size: 14px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vdh-sxe { font-size: 11px; font-weight: 800; color: rgb(var(--accent-rgb)); margin-left: 4px; }
.vdh-row-sub { margin-top: 3px; font-size: 11.5px; color: rgba(255, 255, 255, 0.45);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vdh-row-right { flex: 0 0 auto; display: flex; align-items: center; gap: 12px; }
.vdh-oc { font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.vdh-oc--ok { color: #4ade80; background: rgba(34, 197, 94, 0.13); }
.vdh-oc--warn { color: #fbbf24; background: rgba(245, 158, 11, 0.14); }
.vdh-oc--fail { color: #f87171; background: rgba(248, 113, 113, 0.14); }
.vdh-oc--muted { color: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.06); }
.vdh-when { font-size: 11px; color: rgba(255, 255, 255, 0.4); white-space: nowrap; }
.vdh-chev { font-size: 18px; color: rgba(255, 255, 255, 0.3); transition: transform 0.18s ease; }
.vdh-row--open .vdh-chev { transform: rotate(90deg); }
.vdh-detail { display: none; grid-template-columns: 1fr 1fr; gap: 4px 24px; padding: 4px 16px 16px 65px; }
.vdh-row--open .vdh-detail { display: grid; }
.vdh-d { display: flex; flex-direction: column; gap: 2px; padding: 6px 0; min-width: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05); }
.vdh-d:nth-child(7), .vdh-d--err { grid-column: 1 / -1; }
.vdh-d-k { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: rgba(255, 255, 255, 0.35); }
.vdh-d-v { font-size: 12.5px; color: rgba(255, 255, 255, 0.8); word-break: break-word; }
.vdh-d--err .vdh-d-v { color: #fca5a5; }
.vdh-empty { text-align: center; padding: 60px 20px; }
.vdh-empty-ic { font-size: 40px; margin-bottom: 12px; opacity: 0.8; }
.vdh-empty-t { font-size: 17px; font-weight: 800; color: #fff; margin-bottom: 6px; }
.vdh-empty-s { font-size: 13px; color: rgba(255, 255, 255, 0.45); }
.vdh-foot { padding: 10px 24px 20px; text-align: center; }
.vdh-more { padding: 9px 22px; border-radius: 11px; cursor: pointer; font-size: 12.5px; font-weight: 700;
color: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.15s ease; }
.vdh-more:hover { color: #fff; background: rgba(255, 255, 255, 0.1); }
@media (max-width: 620px) { .vdh-detail { grid-template-columns: 1fr; padding-left: 16px; } }