Homelabtoolkit v1
This commit is contained in:
+14
-7
@@ -4,8 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EmbyToolkit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/app-theme.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0f12;
|
||||
@@ -33,7 +32,7 @@
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
height: 100vh;
|
||||
@@ -379,7 +378,8 @@
|
||||
.pager-meta {
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pager-actions {
|
||||
display: flex;
|
||||
@@ -511,10 +511,17 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
<span>Bulk Assign</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item" href="/favorites">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
<span>User Favourites</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
<div class="app-nav-footer">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
<div class="app-nav-foot">
|
||||
<div class="app-nav-status">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
</div>
|
||||
<div class="app-nav-version">EmbyToolkit · v1.0</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EmbyToolkit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/app-theme.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0f12;
|
||||
@@ -31,7 +30,7 @@
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
height: 100vh;
|
||||
@@ -215,7 +214,8 @@
|
||||
.selection-meta {
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chip-toggle {
|
||||
min-height: 34px;
|
||||
@@ -390,7 +390,8 @@
|
||||
.pager-meta {
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pager-actions {
|
||||
display: flex;
|
||||
@@ -466,10 +467,17 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
<span>Bulk Assign</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item" href="/favorites">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
<span>User Favourites</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
<div class="app-nav-footer">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
<div class="app-nav-foot">
|
||||
<div class="app-nav-status">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
</div>
|
||||
<div class="app-nav-version">EmbyToolkit · v1.0</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
+22
-11
@@ -4,8 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EmbyToolkit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/app-theme.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0f12;
|
||||
@@ -33,7 +32,7 @@
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
height: 100vh;
|
||||
@@ -172,7 +171,8 @@
|
||||
.results-page {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.results {
|
||||
flex: 1;
|
||||
@@ -313,7 +313,8 @@
|
||||
background: linear-gradient(180deg, rgba(26,32,48,0.95), rgba(19,23,31,0.95));
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -523,7 +524,8 @@
|
||||
background: var(--surface2);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.preview-shell {
|
||||
@@ -722,7 +724,8 @@
|
||||
padding: 2px;
|
||||
}
|
||||
.picker-row input[type="text"] {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
@@ -786,7 +789,8 @@
|
||||
text-align: right;
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -924,10 +928,17 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
<span>Bulk Assign</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item" href="/favorites">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
<span>User Favourites</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
<div class="app-nav-footer">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
<div class="app-nav-foot">
|
||||
<div class="app-nav-status">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
</div>
|
||||
<div class="app-nav-version">EmbyToolkit · v1.0</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EmbyToolkit — User Favourites</title>
|
||||
<link rel="stylesheet" href="/static/app-theme.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0f12;
|
||||
--surface: #13171f;
|
||||
--surface2: #1a2030;
|
||||
--surface3: #222840;
|
||||
--border: #252d3d;
|
||||
--border-active: #14b8a6;
|
||||
--text: #e8ecf4;
|
||||
--text-2: #8892a8;
|
||||
--text-3: #4e5a70;
|
||||
--accent: #14b8a6;
|
||||
--accent-h: #2dd4bf;
|
||||
--accent-glow: rgba(20,184,166,0.12);
|
||||
--green: #34d399;
|
||||
--green-bg: rgba(52,211,153,0.08);
|
||||
--red: #f87171;
|
||||
--r: 10px;
|
||||
--r-lg: 14px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Mobile top bar ── */
|
||||
.app-topbar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
.app-topbar-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.03em; }
|
||||
.icon-btn {
|
||||
width: 38px; height: 38px;
|
||||
display: inline-grid; place-items: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface3); }
|
||||
.nav-scrim {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
z-index: 40; opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.nav-scrim.show { opacity: 1; }
|
||||
|
||||
/* ── App Nav Sidebar ── */
|
||||
.app-nav { display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); overflow: hidden; }
|
||||
.app-nav-brand { padding: 18px 16px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.app-nav-logo { width: 32px; height: 32px; background: var(--accent); border-radius: 8px; display: grid; place-items: center; color: #fff; flex-shrink: 0; }
|
||||
.app-nav-name { font-size: 15px; font-weight: 700; letter-spacing: -0.03em; }
|
||||
.app-nav-items { list-style: none; padding: 8px; flex: 1; overflow-y: auto; margin: 0; }
|
||||
.app-nav-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--text-2); text-decoration: none; font-size: 13px; font-weight: 500; transition: background 0.12s, color 0.12s; margin-bottom: 1px; }
|
||||
.app-nav-item:hover { background: var(--surface2); color: var(--text); }
|
||||
.app-nav-item.active { background: var(--accent); color: #fff; }
|
||||
.app-nav-footer { padding: 14px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text-2); flex-shrink: 0; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.dot.off { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||
|
||||
/* ── Page ── */
|
||||
.page { padding: 24px clamp(16px, 2.4vw, 36px) 32px; overflow-y: auto; }
|
||||
.page-inner { width: 100%; max-width: 1680px; margin: 0 auto; }
|
||||
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
|
||||
.hero-copy h2 { margin: 0 0 8px; font-size: clamp(24px, 3vw, 34px); line-height: 1; letter-spacing: -0.04em; }
|
||||
.hero-copy p { margin: 0; max-width: 820px; color: var(--text-2); font-size: 14px; line-height: 1.5; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.section { margin-bottom: 18px; }
|
||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-3); margin: 0 0 10px 2px; }
|
||||
|
||||
.user-picker { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.user-chip {
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
padding: 8px 14px 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
color: var(--text-2);
|
||||
font-family: inherit; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s, border-color 0.14s, color 0.14s;
|
||||
}
|
||||
.user-chip:hover { background: var(--surface3); color: var(--text); }
|
||||
.user-chip .avatar { width: 26px; height: 26px; font-size: 10px; }
|
||||
.user-chip .count { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||
.user-chip.active { background: var(--accent-glow); border-color: var(--accent); color: var(--text); }
|
||||
.user-chip .pl-ico { display: inline-grid; place-items: center; color: var(--text-3); }
|
||||
.user-chip.active .pl-ico { color: var(--accent-h); }
|
||||
.chip-badge {
|
||||
font-size: 9px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 2px 7px; border-radius: 999px;
|
||||
background: var(--accent-glow); color: var(--accent-h); border: 1px solid rgba(54,214,224,0.3);
|
||||
}
|
||||
.actions-note {
|
||||
margin-top: 10px; font-size: 12px; color: #f6d98c;
|
||||
background: rgba(243,201,105,0.08); border: 1px solid rgba(243,201,105,0.25);
|
||||
border-radius: 10px; padding: 8px 12px;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
background: linear-gradient(180deg, rgba(26,32,48,0.95), rgba(19,23,31,0.95));
|
||||
}
|
||||
.actions-group { display: flex; align-items: center; gap: 8px; }
|
||||
.actions-sep { width: 1px; align-self: stretch; background: var(--border); margin: 2px 4px; }
|
||||
.actions-spacer { flex: 1; }
|
||||
.target-field { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-2); }
|
||||
.target-field input {
|
||||
width: 64px; padding: 7px 9px; border-radius: 9px;
|
||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
|
||||
font-family: inherit; font-size: 13px; font-variant-numeric: tabular-nums; text-align: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 36px; padding: 0 14px; border-radius: 10px;
|
||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
|
||||
font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: var(--surface3); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #04181b; }
|
||||
.btn-danger { color: #ffb4b2; border-color: rgba(240,114,111,0.35); background: rgba(240,114,111,0.12); }
|
||||
.btn-green { background: var(--green); border-color: var(--green); color: #08120e; }
|
||||
|
||||
.stat-row { margin-bottom: 18px; }
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
background: linear-gradient(180deg, rgba(19,23,31,0.98), rgba(13,15,18,0.98));
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 13px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.panel-head h3 { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.panel-head .muted { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.item-id { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
|
||||
.preview-card {
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.preview-head h4 { margin: 0; font-size: 13px; font-weight: 700; }
|
||||
.preview-flag {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 999px;
|
||||
}
|
||||
.preview-flag.dry { background: var(--amber-bg, rgba(243,201,105,0.12)); border: 1px solid rgba(243,201,105,0.3); color: #f6d98c; }
|
||||
.preview-flag.applied { background: var(--green-bg); border: 1px solid rgba(70,217,154,0.3); color: #b7f0d4; }
|
||||
.preview-body { padding: 6px 8px 10px; max-height: 320px; overflow-y: auto; }
|
||||
.preview-list { list-style: none; margin: 0; padding: 0; }
|
||||
.preview-list li {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; font-size: 13px;
|
||||
}
|
||||
.preview-list li:hover { background: var(--surface2); }
|
||||
.preview-list .pl-title { flex: 1; min-width: 0; color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.preview-list .pl-score { font-size: 11px; color: var(--accent-h); font-variant-numeric: tabular-nums; }
|
||||
.preview-list .pl-id { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||
.preview-empty { padding: 24px; text-align: center; color: var(--text-3); font-size: 13px; }
|
||||
|
||||
.empty {
|
||||
padding: 54px 20px; border: 1px dashed var(--border); border-radius: var(--r-lg);
|
||||
text-align: center; color: var(--text-3); font-size: 14px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; right: 20px;
|
||||
padding: 11px 18px; border-radius: 12px;
|
||||
font-size: 13px; font-weight: 500; z-index: 200;
|
||||
transform: translateY(80px); opacity: 0;
|
||||
transition: all 0.25s cubic-bezier(0.4,0,0.2,1); pointer-events: none;
|
||||
}
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
|
||||
.spinner { width: 30px; height: 30px; border: 2.5px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
body { grid-template-columns: 1fr; }
|
||||
.app-topbar { display: flex; }
|
||||
.nav-scrim.show { display: block; }
|
||||
.app-nav {
|
||||
position: fixed; top: 0; bottom: 0; left: 0; width: 264px; z-index: 50;
|
||||
transform: translateX(-100%); transition: transform 0.2s ease;
|
||||
}
|
||||
.app-nav.open { transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="app-topbar">
|
||||
<button class="icon-btn" id="navToggle" aria-label="Open navigation">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="app-topbar-brand"><span>EmbyToolkit</span></div>
|
||||
</header>
|
||||
|
||||
<div class="nav-scrim" id="navScrim"></div>
|
||||
|
||||
<nav class="app-nav" id="appNav">
|
||||
<div class="app-nav-brand">
|
||||
<div class="app-nav-logo">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" y1="22" x2="12" y2="12"/></svg>
|
||||
</div>
|
||||
<span class="app-nav-name">EmbyToolkit</span>
|
||||
</div>
|
||||
<ul class="app-nav-items">
|
||||
<li><a class="app-nav-item" href="/">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
|
||||
<span>Generator</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item" href="/collections">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
<span>Collections</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item" href="/airing">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2"/><polyline points="17 2 12 7 7 2"/></svg>
|
||||
<span>Airing</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item" href="/bulk-assign">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
<span>Bulk Assign</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item active" href="/favorites">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
<span>User Favourites</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
<div class="app-nav-foot">
|
||||
<div class="app-nav-status">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
</div>
|
||||
<div class="app-nav-version">EmbyToolkit · v1.0</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="page">
|
||||
<div class="page-inner">
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<h2>User Favourites</h2>
|
||||
<p>Browse any Emby collection and view it as a given user. For a user's own <strong>"{Name} Favorites"</strong> collection you can also remove items they have already watched and top it back up with recommendations built only from that user's watch history. Destructive and bulk actions preview first; nothing changes until you apply.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="section-label">Collection</p>
|
||||
<div class="user-picker" id="collectionPicker">
|
||||
<div style="color:var(--text-3);font-size:13px;padding:6px 2px">Loading collections…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<p class="section-label">Watched status for</p>
|
||||
<div class="user-picker" id="userPicker"></div>
|
||||
</section>
|
||||
|
||||
<div id="workspace" style="display:none">
|
||||
|
||||
<section class="section stat-row" id="statRow">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg></div>
|
||||
<div class="stat-meta"><span class="stat-value" id="statCurrent">—</span><span class="stat-label">Current collection count</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></div>
|
||||
<div class="stat-meta"><span class="stat-value" id="statWatched">—</span><span class="stat-label">Watched items found</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg></div>
|
||||
<div class="stat-meta"><span class="stat-value" id="statRecommended">—</span><span class="stat-label">Items recommended</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>
|
||||
<div class="stat-meta"><span class="stat-value" id="statFinal">—</span><span class="stat-label">Final count after apply</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="actions-bar">
|
||||
<div class="actions-group">
|
||||
<button class="btn" id="btnPreviewCleanup">Preview cleanup</button>
|
||||
<button class="btn btn-danger" id="btnApplyCleanup" disabled>Apply cleanup</button>
|
||||
</div>
|
||||
<div class="actions-sep"></div>
|
||||
<div class="actions-group">
|
||||
<label class="target-field">Target size
|
||||
<input type="number" id="targetSize" min="1" max="200" value="25">
|
||||
</label>
|
||||
<button class="btn" id="btnPreviewRegen">Preview regenerate</button>
|
||||
<button class="btn btn-green" id="btnApplyRegen" disabled>Apply regenerate</button>
|
||||
</div>
|
||||
<div class="actions-spacer"></div>
|
||||
<button class="btn" id="btnRefresh">Refresh</button>
|
||||
</div>
|
||||
<div class="actions-note" id="actionsNote" style="display:none"></div>
|
||||
<div id="actionResult"></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3 id="playlistTitle">Collection</h3>
|
||||
<span class="muted" id="playlistMeta"></span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Year</th>
|
||||
<th>Runtime</th>
|
||||
<th>Watched</th>
|
||||
<th>Emby item id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlistBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="playlistEmpty" class="preview-empty" style="display:none">This collection has no items.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="noUsers" class="empty" style="display:none">
|
||||
No collections found. Create a collection named "<strong>{User} Favorites</strong>" in Emby (for example "Matt Favorites") and refresh.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const state = { collections: [], users: [], selectedCollectionId: null, selectedUserId: null, view: null };
|
||||
|
||||
/* ── nav (mobile) ── */
|
||||
const appNav = $('#appNav'), navScrim = $('#navScrim'), navToggle = $('#navToggle');
|
||||
function closeNav() { appNav.classList.remove('open'); navScrim.classList.remove('show'); }
|
||||
if (navToggle) navToggle.addEventListener('click', () => { appNav.classList.add('open'); navScrim.classList.add('show'); });
|
||||
if (navScrim) navScrim.addEventListener('click', closeNav);
|
||||
|
||||
/* ── helpers ── */
|
||||
function toast(msg, ok = true) {
|
||||
const t = $('#toast');
|
||||
t.textContent = msg;
|
||||
t.style.background = ok ? 'rgba(70,217,154,0.12)' : 'rgba(240,114,111,0.12)';
|
||||
t.style.border = '1px solid ' + (ok ? 'rgba(70,217,154,0.4)' : 'rgba(240,114,111,0.4)');
|
||||
t.style.color = ok ? '#b7f0d4' : '#ffb4b2';
|
||||
t.classList.add('show');
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => t.classList.remove('show'), 3200);
|
||||
}
|
||||
function initials(name) { return (name || '?').trim().slice(0, 2).toUpperCase(); }
|
||||
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }
|
||||
function fmtRuntime(min) { if (!min) return '—'; const h = Math.floor(min / 60), m = min % 60; return h ? `${h}h ${m}m` : `${m}m`; }
|
||||
|
||||
async function getJSON(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
}
|
||||
async function postJSON(url, body) {
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `Request failed (${r.status})`); }
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/* ── data ── */
|
||||
const ICON_FAV = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>';
|
||||
const ICON_COL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
|
||||
|
||||
async function loadOverview() {
|
||||
try {
|
||||
const data = await getJSON('/api/favorites/collections');
|
||||
state.collections = data.collections || [];
|
||||
state.users = data.users || [];
|
||||
if (!state.collections.length) {
|
||||
$('#collectionPicker').innerHTML = '<div style="color:var(--text-3);font-size:13px;padding:6px 2px">No collections found in Emby.</div>';
|
||||
$('#noUsers').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
$('#noUsers').style.display = 'none';
|
||||
const def = state.collections.find(c => c.is_favorites) || state.collections[0];
|
||||
renderCollections();
|
||||
renderUsers();
|
||||
selectCollection(def.collection_id);
|
||||
} catch (e) {
|
||||
$('#collectionPicker').innerHTML = `<div style="color:#ffb4b2;font-size:13px">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function currentCollection() { return state.collections.find(c => c.collection_id === state.selectedCollectionId); }
|
||||
function actionsReady() { return !!(state.selectedUserId && state.view && state.view.actions_enabled); }
|
||||
|
||||
function renderCollections() {
|
||||
const picker = $('#collectionPicker');
|
||||
picker.innerHTML = state.collections.map(c => `
|
||||
<button class="user-chip${c.collection_id === state.selectedCollectionId ? ' active' : ''}" data-id="${escapeHtml(c.collection_id)}">
|
||||
<span class="pl-ico">${c.is_favorites ? ICON_FAV : ICON_COL}</span>
|
||||
<span>${escapeHtml(c.collection_name)}</span>
|
||||
${c.is_favorites ? '<span class="chip-badge">Favourites</span>' : ''}
|
||||
${c.item_count != null ? `<span class="count">${c.item_count}</span>` : ''}
|
||||
</button>`).join('');
|
||||
picker.querySelectorAll('.user-chip').forEach(btn =>
|
||||
btn.addEventListener('click', () => selectCollection(btn.dataset.id)));
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const picker = $('#userPicker');
|
||||
picker.innerHTML = state.users.map(u => `
|
||||
<button class="user-chip${u.id === state.selectedUserId ? ' active' : ''}" data-id="${escapeHtml(u.id)}">
|
||||
<span class="avatar">${escapeHtml(initials(u.name))}</span>
|
||||
<span>${escapeHtml(u.name)}</span>
|
||||
</button>`).join('');
|
||||
picker.querySelectorAll('.user-chip').forEach(btn =>
|
||||
btn.addEventListener('click', () => selectUser(btn.dataset.id)));
|
||||
}
|
||||
|
||||
function selectCollection(id) {
|
||||
state.selectedCollectionId = id;
|
||||
const col = currentCollection();
|
||||
if (col && col.owner_user_id) state.selectedUserId = col.owner_user_id;
|
||||
else if (!state.selectedUserId && state.users.length) state.selectedUserId = state.users[0].id;
|
||||
renderCollections();
|
||||
renderUsers();
|
||||
$('#actionResult').innerHTML = '';
|
||||
loadView();
|
||||
}
|
||||
|
||||
function selectUser(id) {
|
||||
state.selectedUserId = id;
|
||||
renderUsers();
|
||||
$('#actionResult').innerHTML = '';
|
||||
loadView();
|
||||
}
|
||||
|
||||
async function loadView() {
|
||||
if (!state.selectedCollectionId || !state.selectedUserId) return;
|
||||
$('#workspace').style.display = 'block';
|
||||
$('#playlistBody').innerHTML = `<tr><td colspan="6"><div class="spinner"></div></td></tr>`;
|
||||
try {
|
||||
const view = await getJSON(`/api/favorites/collection/${state.selectedCollectionId}?user_id=${encodeURIComponent(state.selectedUserId)}`);
|
||||
state.view = view;
|
||||
renderItems();
|
||||
renderSummary({ current: view.summary.current_count, watched: view.summary.watched_count, recommended: '—', final: '—' });
|
||||
gateActions(view);
|
||||
} catch (e) {
|
||||
$('#playlistBody').innerHTML = `<tr><td colspan="6" style="color:#ffb4b2">${escapeHtml(e.message)}</td></tr>`;
|
||||
toast(e.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
function gateActions(view) {
|
||||
const on = !!view.actions_enabled;
|
||||
$('#btnPreviewCleanup').disabled = !on;
|
||||
$('#btnPreviewRegen').disabled = !on;
|
||||
if (!on) { $('#btnApplyCleanup').disabled = true; $('#btnApplyRegen').disabled = true; }
|
||||
const note = $('#actionsNote');
|
||||
if (on) { note.style.display = 'none'; note.textContent = ''; }
|
||||
else { note.style.display = 'block'; note.textContent = view.actions_reason || 'Cleanup and regenerate are unavailable for this collection.'; }
|
||||
}
|
||||
|
||||
function renderItems() {
|
||||
const v = state.view;
|
||||
$('#playlistTitle').textContent = v.collection_name;
|
||||
$('#playlistMeta').textContent = `${v.summary.current_count} items · ${v.summary.watched_count} watched · as ${v.user_name}`;
|
||||
const body = $('#playlistBody');
|
||||
const empty = $('#playlistEmpty');
|
||||
if (!v.items.length) {
|
||||
body.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
body.innerHTML = v.items.map(it => `
|
||||
<tr>
|
||||
<td class="cell-strong">${escapeHtml(it.title)}${it.series_name ? `<div class="cell-sub">${escapeHtml(it.series_name)}</div>` : ''}</td>
|
||||
<td>${escapeHtml(it.type || it.media_type || '—')}</td>
|
||||
<td class="cell-num">${it.year || '—'}</td>
|
||||
<td class="cell-num">${fmtRuntime(it.runtime_minutes)}</td>
|
||||
<td>${it.watched
|
||||
? '<span class="badge badge-watched">Watched</span>'
|
||||
: '<span class="badge">Unwatched</span>'}</td>
|
||||
<td><span class="item-id">${escapeHtml(it.id)}</span></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
async function refreshCounts() {
|
||||
try {
|
||||
const data = await getJSON('/api/favorites/collections');
|
||||
state.collections = data.collections || [];
|
||||
renderCollections();
|
||||
} catch { /* keep stale counts on failure */ }
|
||||
}
|
||||
|
||||
function renderSummary({ current, watched, recommended, final }) {
|
||||
$('#statCurrent').textContent = current;
|
||||
$('#statWatched').textContent = watched;
|
||||
$('#statRecommended').textContent = recommended;
|
||||
$('#statFinal').textContent = final;
|
||||
}
|
||||
|
||||
/* ── action result panel ── */
|
||||
function renderActionResult(kind, payload) {
|
||||
const dry = payload.dry_run;
|
||||
const isCleanup = kind === 'cleanup';
|
||||
const list = isCleanup ? (payload.removed || []) : (payload.recommended || []);
|
||||
const verb = isCleanup ? (dry ? 'would be removed' : 'removed') : (dry ? 'would be added' : 'added');
|
||||
const heading = isCleanup ? 'Watched-item cleanup' : 'Recommended replacements';
|
||||
const rows = list.length ? `<ul class="preview-list">${list.map(i => `
|
||||
<li>
|
||||
<span class="pl-title">${escapeHtml(i.title)}</span>
|
||||
${i.score != null ? `<span class="pl-score">score ${i.score}</span>` : ''}
|
||||
<span class="pl-id">${escapeHtml(i.item_id)}</span>
|
||||
</li>`).join('')}</ul>`
|
||||
: `<div class="preview-empty">${payload.message ? escapeHtml(payload.message) : `Nothing ${verb}.`}</div>`;
|
||||
|
||||
$('#actionResult').innerHTML = `
|
||||
<div class="preview-card">
|
||||
<div class="preview-head">
|
||||
<h4>${heading}</h4>
|
||||
<span class="preview-flag ${dry ? 'dry' : 'applied'}">${dry ? 'Dry run · preview' : 'Applied'}</span>
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--text-3)">${list.length} ${list.length === 1 ? 'item' : 'items'} ${verb}</span>
|
||||
</div>
|
||||
<div class="preview-body">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── cleanup ── */
|
||||
async function previewCleanup() {
|
||||
if (!actionsReady()) return;
|
||||
try {
|
||||
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/cleanup`, { userId: state.selectedUserId, dryRun: true });
|
||||
renderActionResult('cleanup', res);
|
||||
renderSummary({
|
||||
current: res.summary.current_count, watched: res.summary.watched_count,
|
||||
recommended: $('#statRecommended').textContent,
|
||||
final: res.summary.current_count - res.summary.watched_count,
|
||||
});
|
||||
$('#btnApplyCleanup').disabled = res.summary.watched_count === 0;
|
||||
toast(`${res.watched_found} watched item${res.watched_found === 1 ? '' : 's'} found`);
|
||||
} catch (e) { toast(e.message, false); }
|
||||
}
|
||||
async function applyCleanup() {
|
||||
if (!actionsReady()) return;
|
||||
const who = (state.view && state.view.user_name) || 'this user';
|
||||
const where = (state.view && state.view.collection_name) || 'this collection';
|
||||
if (!confirm(`Remove items ${who} has watched from "${where}"? Collections are shared, so removed items leave the collection for everyone. This cannot be undone.`)) return;
|
||||
try {
|
||||
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/cleanup`, { userId: state.selectedUserId, dryRun: false });
|
||||
renderActionResult('cleanup', res);
|
||||
toast(`Removed ${res.summary.removed_count} watched item${res.summary.removed_count === 1 ? '' : 's'}`);
|
||||
$('#btnApplyCleanup').disabled = true;
|
||||
await refreshCounts();
|
||||
await loadView();
|
||||
} catch (e) { toast(e.message, false); }
|
||||
}
|
||||
|
||||
/* ── regenerate ── */
|
||||
function targetSize() { return Math.max(1, parseInt($('#targetSize').value, 10) || 25); }
|
||||
async function previewRegen() {
|
||||
if (!actionsReady()) return;
|
||||
try {
|
||||
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/regenerate`, { userId: state.selectedUserId, dryRun: true, targetSize: targetSize() });
|
||||
renderActionResult('regenerate', res);
|
||||
renderSummary({
|
||||
current: res.summary.current_count, watched: $('#statWatched').textContent,
|
||||
recommended: res.summary.recommended_count, final: res.summary.final_count,
|
||||
});
|
||||
$('#btnApplyRegen').disabled = res.summary.recommended_count === 0;
|
||||
toast(res.message ? res.message : `${res.summary.recommended_count} recommendation${res.summary.recommended_count === 1 ? '' : 's'} ready`, !res.message || res.summary.recommended_count > 0);
|
||||
} catch (e) { toast(e.message, false); }
|
||||
}
|
||||
async function applyRegen() {
|
||||
if (!actionsReady()) return;
|
||||
const where = (state.view && state.view.collection_name) || 'this collection';
|
||||
if (!confirm(`Add recommendations to "${where}" (target ${targetSize()} items)? Collections are shared with all users.`)) return;
|
||||
try {
|
||||
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/regenerate`, { userId: state.selectedUserId, dryRun: false, targetSize: targetSize() });
|
||||
renderActionResult('regenerate', res);
|
||||
toast(`Added ${res.summary.added_count} item${res.summary.added_count === 1 ? '' : 's'}`);
|
||||
$('#btnApplyRegen').disabled = true;
|
||||
await refreshCounts();
|
||||
await loadView();
|
||||
} catch (e) { toast(e.message, false); }
|
||||
}
|
||||
|
||||
$('#btnPreviewCleanup').addEventListener('click', previewCleanup);
|
||||
$('#btnApplyCleanup').addEventListener('click', applyCleanup);
|
||||
$('#btnPreviewRegen').addEventListener('click', previewRegen);
|
||||
$('#btnApplyRegen').addEventListener('click', applyRegen);
|
||||
$('#btnRefresh').addEventListener('click', loadView);
|
||||
|
||||
/* ── status ── */
|
||||
(async () => {
|
||||
try {
|
||||
const config = await (await fetch('/api/config')).json();
|
||||
if (config.connected) { $('#statusText').textContent = 'Connected to Emby'; }
|
||||
else { $('#statusDot').classList.add('off'); $('#statusText').textContent = 'No API key'; }
|
||||
} catch {
|
||||
$('#statusDot').classList.add('off'); $('#statusText').textContent = 'Connection error';
|
||||
}
|
||||
})();
|
||||
|
||||
loadOverview();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+23
-12
@@ -4,8 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EmbyToolkit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/app-theme.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0f12;
|
||||
@@ -30,7 +29,7 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: grid;
|
||||
@@ -225,7 +224,8 @@
|
||||
.results-page {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.results-footer {
|
||||
justify-content: flex-end;
|
||||
@@ -332,7 +332,8 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.primary-preview-note {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
font-size: 11px;
|
||||
@@ -483,7 +484,8 @@
|
||||
}
|
||||
.thumb-preview-meta {
|
||||
color: rgba(255,255,255,0.88);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -655,7 +657,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||
font-family: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
outline: none;
|
||||
}
|
||||
.color-wrap input[type="text"]:focus { border-color: var(--border-active); }
|
||||
@@ -688,7 +690,8 @@
|
||||
}
|
||||
.slider-val {
|
||||
font-size: 11px; color: var(--text-2);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 28px; text-align: right;
|
||||
}
|
||||
.asset-picker {
|
||||
@@ -716,7 +719,8 @@
|
||||
min-width: 40px;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
.logo-swatch {
|
||||
@@ -959,10 +963,17 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
<span>Bulk Assign</span>
|
||||
</a></li>
|
||||
<li><a class="app-nav-item" href="/favorites">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
<span>User Favourites</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
<div class="app-nav-footer">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
<div class="app-nav-foot">
|
||||
<div class="app-nav-status">
|
||||
<span class="dot" id="statusDot"></span>
|
||||
<span id="statusText">Checking…</span>
|
||||
</div>
|
||||
<div class="app-nav-version">EmbyToolkit · v1.0</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user