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:
parent
6fe82b2a78
commit
ed2fbf2ae4
5 changed files with 424 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
253
webui/static/video/video-download-history.js
Normal file
253
webui/static/video/video-download-history.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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">×</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 };
|
||||
})();
|
||||
|
|
@ -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; } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue