Files
embycovers/templates/collections.html
T
2026-04-20 23:16:53 +12:00

1787 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emby Collection Artwork</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">
<style>
:root {
--bg: #0d0f12;
--surface: #13171f;
--surface2: #1a2030;
--surface3: #222840;
--border: #252d3d;
--border-active: #4a6fff;
--text: #e8ecf4;
--text-2: #8892a8;
--text-3: #4e5a70;
--accent: #4a6fff;
--accent-h: #6384ff;
--accent-glow: rgba(74,111,255,0.12);
--green: #34d399;
--green-bg: rgba(52,211,153,0.08);
--red: #f87171;
--red-bg: rgba(248,113,113,0.09);
--r: 10px;
--r-lg: 14px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
min-height: 100svh;
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
display: flex;
flex-direction: column;
}
.header {
height: 56px;
padding: 0 24px;
display: flex;
align-items: center;
gap: 14px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.header-logo {
width: 30px;
height: 30px;
border-radius: 7px;
display: grid;
place-items: center;
background: var(--accent);
color: #fff;
font-size: 12px;
font-weight: 700;
}
.header h1 {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.02em;
}
.header-nav {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
}
.nav-link {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-2);
text-decoration: none;
font-size: 12px;
font-weight: 600;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.nav-link:hover {
background: var(--surface2);
color: var(--text);
}
.nav-link.active {
background: var(--accent-glow);
border-color: var(--accent);
color: var(--text);
}
.header-status {
margin-left: auto;
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--text-2);
}
.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);
}
.body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
}
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--border);
background: var(--surface);
}
.search-wrap {
padding: 14px;
border-bottom: 1px solid var(--border);
}
.search-inner {
position: relative;
}
.search-inner svg {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text-3);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 9px 12px 9px 36px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.search-input:focus {
border-color: var(--border-active);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.results-toolbar,
.results-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.results-footer {
justify-content: flex-end;
border-top: 1px solid var(--border);
border-bottom: 0;
margin-top: auto;
}
.results-count,
.results-page {
font-size: 11px;
color: var(--text-2);
font-family: 'JetBrains Mono', monospace;
}
.results {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 6px;
}
.result-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.result-item:hover {
background: var(--surface2);
}
.result-item.active {
background: var(--accent-glow);
border-color: var(--accent);
}
.result-poster {
width: 36px;
height: 54px;
border-radius: 5px;
object-fit: cover;
background: var(--surface2);
flex-shrink: 0;
}
.result-info {
min-width: 0;
flex: 1;
}
.result-name {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-sub {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
font-size: 11px;
color: var(--text-2);
}
.tag {
padding: 1px 6px;
border-radius: 999px;
background: var(--surface3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.empty {
padding: 42px 20px;
text-align: center;
color: var(--text-3);
font-size: 13px;
}
.pager-btn,
.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;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.pager-btn:hover:not(:disabled),
.btn:hover:not(:disabled) {
background: var(--surface3);
}
.pager-btn:disabled,
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-h);
}
.btn-green {
background: var(--green);
border-color: var(--green);
color: #08120e;
}
.btn-ghost {
background: transparent;
}
.main {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 20px 22px 24px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
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: 780px;
color: var(--text-2);
font-size: 14px;
line-height: 1.5;
}
.hero-badge {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 999px;
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;
white-space: nowrap;
}
.workspace {
display: grid;
grid-template-columns: 292px minmax(0, 1fr);
gap: 18px;
min-height: 0;
}
.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 {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.panel-head h3 {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.panel-head span {
color: var(--text-2);
font-size: 11px;
}
.panel-body {
padding: 16px;
}
.collection-meta {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.collection-name {
font-size: 22px;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.04em;
}
.collection-sub {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--text-2);
font-size: 12px;
}
.asset-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
}
.asset-card {
border: 1px solid var(--border);
background: var(--surface2);
border-radius: 12px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition: border-color 0.12s, background 0.12s, transform 0.12s;
}
.asset-card:hover {
border-color: var(--border-active);
}
.asset-card.active {
border-color: var(--accent);
background: rgba(74,111,255,0.12);
}
.asset-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.asset-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.asset-pill {
padding: 3px 7px;
border-radius: 999px;
background: rgba(52,211,153,0.12);
color: var(--green);
font-size: 10px;
font-weight: 700;
}
.asset-pill.off {
background: rgba(136,146,168,0.14);
color: var(--text-3);
}
.asset-frame {
position: relative;
overflow: hidden;
border-radius: 10px;
background: var(--bg);
border: 1px solid rgba(255,255,255,0.05);
}
.asset-frame.thumb {
aspect-ratio: 16 / 9;
}
.asset-frame.primary {
aspect-ratio: 2 / 3;
}
.asset-frame img {
width: 100%;
height: 100%;
object-fit: cover;
display: none;
}
.asset-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
padding: 12px;
color: var(--text-3);
font-size: 11px;
}
.dropzone {
border: 1px dashed var(--border);
border-radius: 14px;
padding: 14px;
background: rgba(26,32,48,0.5);
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.12s, background 0.12s;
}
.dropzone.drag-over {
border-color: var(--accent);
background: rgba(74,111,255,0.1);
}
.dropzone-copy {
display: flex;
flex-direction: column;
gap: 5px;
}
.dropzone-title {
font-size: 13px;
font-weight: 700;
}
.dropzone-copy p,
.source-status {
color: var(--text-2);
font-size: 12px;
line-height: 1.4;
}
.dropzone-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.source-status strong {
color: var(--text);
}
.editor {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.preview-panel {
padding: 18px;
}
.preview-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.preview-title {
display: flex;
flex-direction: column;
gap: 5px;
}
.preview-title strong {
font-size: 16px;
letter-spacing: -0.03em;
}
.preview-title span {
color: var(--text-2);
font-size: 12px;
}
.preview-meta {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface2);
color: var(--text-2);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
}
.preview-shell {
display: grid;
place-items: center;
min-height: 320px;
border: 1px solid var(--border);
border-radius: 18px;
background:
radial-gradient(circle at top right, rgba(74,111,255,0.18), transparent 34%),
linear-gradient(180deg, rgba(26,32,48,0.7), rgba(13,15,18,0.92));
padding: 20px;
}
.preview-placeholder {
color: var(--text-3);
text-align: center;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
}
.preview-frame {
position: relative;
width: min(100%, 860px);
aspect-ratio: 16 / 9;
border-radius: 18px;
overflow: hidden;
background: var(--surface2);
box-shadow: 0 10px 40px rgba(0,0,0,0.45);
display: none;
cursor: grab;
touch-action: none;
}
.preview-frame.primary {
width: min(100%, 360px);
aspect-ratio: 2 / 3;
}
.preview-frame.visible {
display: block;
}
.preview-frame.dragging {
cursor: grabbing;
}
.preview-frame img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.preview-tools,
.preview-zoom {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.preview-tools {
top: 12px;
left: 12px;
padding: 7px 10px;
border-radius: 999px;
background: rgba(0,0,0,0.56);
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(8px);
}
.preview-zoom {
top: 12px;
right: 12px;
}
.mini-link {
border: 0;
background: transparent;
color: var(--accent-h);
font: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.mini-link:hover {
color: #8fa4ff;
}
.preview-zoom-btn {
width: 32px;
height: 32px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 999px;
background: rgba(0,0,0,0.58);
color: rgba(255,255,255,0.92);
font: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
backdrop-filter: blur(8px);
}
.preview-zoom-btn:hover {
background: rgba(0,0,0,0.72);
}
.preview-hint {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
padding: 7px 10px;
border-radius: 999px;
background: rgba(0,0,0,0.58);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.88);
font-size: 11px;
font-weight: 600;
pointer-events: none;
backdrop-filter: blur(8px);
white-space: nowrap;
}
.loading-cover {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(13,15,18,0.82);
z-index: 3;
}
.loading-cover.on {
display: flex;
}
.controls-panel {
padding: 16px 18px 18px;
}
.control-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-group label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-2);
}
.span-12 { grid-column: span 12; }
.span-6 { grid-column: span 6; }
.span-4 { grid-column: span 4; }
.span-3 { grid-column: span 3; }
.ctrl-input,
.ctrl-textarea,
.ctrl-select {
width: 100%;
min-height: 38px;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.ctrl-input:focus,
.ctrl-textarea:focus,
.ctrl-select:focus {
border-color: var(--border-active);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.ctrl-textarea {
min-height: 94px;
resize: vertical;
line-height: 1.45;
}
.ctrl-select option {
background: var(--surface);
}
.picker-row {
display: flex;
gap: 8px;
align-items: center;
}
.picker-row input[type="color"] {
width: 42px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
cursor: pointer;
padding: 2px;
}
.picker-row input[type="text"] {
font-family: 'JetBrains Mono', monospace;
}
.segmented {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.seg-btn {
min-height: 38px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text-2);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.seg-btn:hover {
background: var(--surface3);
}
.seg-btn.active {
background: var(--accent-glow);
border-color: var(--accent);
color: var(--text);
}
.slider-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.slider-step {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
font-size: 15px;
font-weight: 700;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.slider-step:hover {
background: var(--surface3);
border-color: var(--border-active);
}
.slider-wrap input[type="range"] {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
}
.slider-val {
min-width: 44px;
text-align: right;
color: var(--text-2);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
flex-wrap: wrap;
}
.spinner {
width: 32px;
height: 32px;
border: 2.5px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 11px 18px;
border-radius: var(--r);
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;
}
.toast.ok {
background: var(--green-bg);
border: 1px solid var(--green);
color: var(--green);
}
.toast.err {
background: var(--red-bg);
border: 1px solid var(--red);
color: var(--red);
}
@media (max-width: 1280px) {
.workspace {
grid-template-columns: 260px minmax(0, 1fr);
}
.control-grid {
grid-template-columns: repeat(8, minmax(0, 1fr));
}
.span-6 { grid-column: span 8; }
.span-4 { grid-column: span 4; }
.span-3 { grid-column: span 4; }
}
@media (max-width: 980px) {
.body {
grid-template-columns: 1fr;
grid-template-rows: minmax(220px, 34vh) minmax(0, 1fr);
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.workspace {
grid-template-columns: 1fr;
}
.hero {
flex-direction: column;
}
}
@media (max-width: 720px) {
.header {
padding: 0 14px;
gap: 10px;
}
.header-nav {
gap: 6px;
margin-left: 2px;
}
.nav-link {
padding: 6px 8px;
}
.main {
padding: 16px 14px 20px;
}
.control-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.span-12,
.span-6,
.span-4,
.span-3 {
grid-column: span 1;
}
.actions {
justify-content: stretch;
}
.actions .btn {
width: 100%;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-logo">TG</div>
<h1>Emby Thumbnail Generator</h1>
<nav class="header-nav">
<a class="nav-link" href="/">Generator</a>
<a class="nav-link active" href="/collections">Collections</a>
<a class="nav-link" href="/airing">Airing</a>
</nav>
<div class="header-status">
<span class="dot" id="statusDot"></span>
<span id="statusText">Checking…</span>
</div>
</header>
<div class="body">
<aside class="sidebar">
<div class="search-wrap">
<div class="search-inner">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input class="search-input" id="searchInput" type="text" placeholder="Search collections…" autocomplete="off">
</div>
</div>
<div class="results-toolbar">
<div class="results-count" id="resultsCount">Loading…</div>
<div class="results-page" id="resultsPage"></div>
</div>
<div class="results" id="resultsList">
<div class="empty"><div class="spinner" style="margin:0 auto 10px"></div>Loading collections…</div>
</div>
<div class="results-footer">
<button class="pager-btn" id="btnPrevPage" disabled>Previous</button>
<button class="pager-btn" id="btnNextPage" disabled>Next</button>
</div>
</aside>
<main class="main">
<section class="hero">
<div class="hero-copy">
<h2>Collection Artwork</h2>
<p>Select an Emby collection, switch between `Thumb` and `Primary`, then use either the current artwork or a dropped image as the base. The editor lets you crop, darken, and overlay custom text before pushing the result back to Emby.</p>
</div>
<div class="hero-badge" id="sourceBadge">Waiting for collection</div>
</section>
<section class="workspace">
<div class="panel">
<div class="panel-head">
<h3>Collection</h3>
<span id="selectedTargetNote">Editing Thumb</span>
</div>
<div class="panel-body">
<div class="collection-meta">
<div class="collection-name" id="collectionName">Pick a collection</div>
<div class="collection-sub" id="collectionMeta">
<span class="tag">BoxSet</span>
<span>No selection yet</span>
</div>
</div>
<div class="asset-grid">
<button class="asset-card active" id="thumbCard" type="button" data-target="Thumb">
<div class="asset-card-head">
<span class="asset-title">Thumb</span>
<span class="asset-pill off" id="thumbState">Missing</span>
</div>
<div class="asset-frame thumb">
<img id="thumbCurrentImg" alt="">
<div class="asset-empty" id="thumbEmpty">No thumb artwork yet</div>
</div>
</button>
<button class="asset-card" id="primaryCard" type="button" data-target="Primary">
<div class="asset-card-head">
<span class="asset-title">Primary</span>
<span class="asset-pill off" id="primaryState">Missing</span>
</div>
<div class="asset-frame primary">
<img id="primaryCurrentImg" alt="">
<div class="asset-empty" id="primaryEmpty">No primary artwork yet</div>
</div>
</button>
</div>
<div class="dropzone" id="dropzone">
<div class="dropzone-copy">
<div class="dropzone-title">Upload or drop a base image</div>
<p>Drop a JPG, PNG, or WEBP here to use it as the background for the current target. If you clear it, the editor falls back to the collections existing artwork.</p>
</div>
<div class="source-status" id="sourceStatus">Using current artwork when available.</div>
<div class="dropzone-actions">
<button class="btn btn-primary" id="btnChooseImage" type="button">Choose image</button>
<button class="btn btn-ghost" id="btnClearImage" type="button" disabled>Clear upload</button>
</div>
<input id="uploadInput" type="file" accept="image/*" hidden>
</div>
</div>
</div>
<div class="editor">
<div class="panel preview-panel">
<div class="preview-top">
<div class="preview-title">
<strong id="previewHeading">Preview</strong>
<span id="previewSub">Select a collection to start editing.</span>
</div>
<div class="preview-meta" id="previewMeta">100% · X 0 · Y 0</div>
</div>
<div class="preview-shell">
<div class="preview-placeholder" id="previewPlaceholder">
<svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m8 8 8 8m0-8-8 8"/></svg>
<div>Select a collection from the left to build custom artwork.</div>
</div>
<div class="preview-frame" id="previewFrame" title="Drag to reposition the crop. Use the mouse wheel to zoom.">
<img id="previewImg" alt="">
<div class="preview-tools">
<button class="mini-link" id="btnResetCrop" type="button">Reset</button>
</div>
<div class="preview-zoom">
<button class="preview-zoom-btn" id="btnPreviewZoomOut" type="button" aria-label="Zoom out">-</button>
<button class="preview-zoom-btn" id="btnPreviewZoomIn" type="button" aria-label="Zoom in">+</button>
</div>
<div class="preview-hint">Drag to position • Wheel to zoom</div>
<div class="loading-cover" id="loadingCover"><div class="spinner"></div></div>
</div>
</div>
</div>
<div class="panel controls-panel">
<div class="control-grid">
<div class="control-group span-12">
<label for="overlayText">Overlay Text</label>
<textarea class="ctrl-textarea" id="overlayText" placeholder="Collection title"></textarea>
</div>
<div class="control-group span-4">
<label>Artwork Target</label>
<div class="segmented" id="targetButtons">
<button class="seg-btn active" type="button" data-target="Thumb">Thumb</button>
<button class="seg-btn" type="button" data-target="Primary">Primary</button>
</div>
</div>
<div class="control-group span-4">
<label>Text Alignment</label>
<div class="segmented" id="alignButtons">
<button class="seg-btn" type="button" data-align="left">Left</button>
<button class="seg-btn active" type="button" data-align="center">Center</button>
<button class="seg-btn" type="button" data-align="right">Right</button>
</div>
</div>
<div class="control-group span-4">
<label>Text Position</label>
<div class="segmented" id="positionButtons">
<button class="seg-btn" type="button" data-position="top">Top</button>
<button class="seg-btn" type="button" data-position="center">Center</button>
<button class="seg-btn active" type="button" data-position="bottom">Bottom</button>
</div>
</div>
<div class="control-group span-4">
<label>Text Colour</label>
<div class="picker-row">
<input id="textColorPicker" type="color" value="#ffffff">
<input class="ctrl-input" id="textColorText" type="text" value="#FFFFFF" maxlength="7">
</div>
</div>
<div class="control-group span-4">
<label>Text Size</label>
<div class="slider-wrap">
<button class="slider-step" id="btnTextScaleDown" type="button">-</button>
<input id="textScaleSlider" type="range" min="70" max="180" value="100">
<button class="slider-step" id="btnTextScaleUp" type="button">+</button>
<span class="slider-val" id="textScaleVal">100%</span>
</div>
</div>
<div class="control-group span-4">
<label>Darkness</label>
<div class="slider-wrap">
<input id="darknessSlider" type="range" min="0" max="85" value="18">
<span class="slider-val" id="darknessVal">18%</span>
</div>
</div>
<div class="control-group span-4">
<label>Zoom</label>
<div class="slider-wrap">
<button class="slider-step" id="btnZoomOut" type="button">-</button>
<input id="zoomSlider" type="range" min="100" max="260" value="100">
<button class="slider-step" id="btnZoomIn" type="button">+</button>
<span class="slider-val" id="zoomVal">100%</span>
</div>
</div>
<div class="control-group span-4">
<label>X Position</label>
<div class="slider-wrap">
<input id="panXSlider" type="range" min="-100" max="100" value="0">
<span class="slider-val" id="panXVal">0</span>
</div>
</div>
<div class="control-group span-4">
<label>Y Position</label>
<div class="slider-wrap">
<input id="panYSlider" type="range" min="-100" max="100" value="0">
<span class="slider-val" id="panYVal">0</span>
</div>
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" id="btnRegenerate" type="button" disabled>Regenerate</button>
<button class="btn btn-primary" id="btnGenerate" type="button" disabled>Generate</button>
<button class="btn btn-green" id="btnApply" type="button" disabled>Apply Thumb to Emby</button>
</div>
</div>
</div>
</section>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
const state = {
item: null,
targetType: 'Thumb',
artwork: {
thumb: { exists: false, url: null },
primary: { exists: false, url: null },
},
uploadBgId: null,
uploadFileName: '',
generated: false,
cacheKey: null,
searchQuery: '',
searchStart: 0,
searchLimit: 24,
searchTotal: 0,
searchController: null,
};
const $ = selector => document.querySelector(selector);
const searchInput = $('#searchInput');
const resultsList = $('#resultsList');
const resultsCount = $('#resultsCount');
const resultsPage = $('#resultsPage');
const btnPrevPage = $('#btnPrevPage');
const btnNextPage = $('#btnNextPage');
const collectionName = $('#collectionName');
const collectionMeta = $('#collectionMeta');
const thumbCard = $('#thumbCard');
const primaryCard = $('#primaryCard');
const thumbCurrentImg = $('#thumbCurrentImg');
const primaryCurrentImg = $('#primaryCurrentImg');
const thumbEmpty = $('#thumbEmpty');
const primaryEmpty = $('#primaryEmpty');
const thumbState = $('#thumbState');
const primaryState = $('#primaryState');
const selectedTargetNote = $('#selectedTargetNote');
const dropzone = $('#dropzone');
const sourceStatus = $('#sourceStatus');
const sourceBadge = $('#sourceBadge');
const uploadInput = $('#uploadInput');
const btnChooseImage = $('#btnChooseImage');
const btnClearImage = $('#btnClearImage');
const previewHeading = $('#previewHeading');
const previewSub = $('#previewSub');
const previewMeta = $('#previewMeta');
const previewPlaceholder = $('#previewPlaceholder');
const previewFrame = $('#previewFrame');
const previewImg = $('#previewImg');
const loadingCover = $('#loadingCover');
const overlayText = $('#overlayText');
const targetButtons = $('#targetButtons');
const alignButtons = $('#alignButtons');
const positionButtons = $('#positionButtons');
const textColorPicker = $('#textColorPicker');
const textColorText = $('#textColorText');
const textScaleSlider = $('#textScaleSlider');
const textScaleVal = $('#textScaleVal');
const darknessSlider = $('#darknessSlider');
const darknessVal = $('#darknessVal');
const zoomSlider = $('#zoomSlider');
const zoomVal = $('#zoomVal');
const panXSlider = $('#panXSlider');
const panXVal = $('#panXVal');
const panYSlider = $('#panYSlider');
const panYVal = $('#panYVal');
const btnTextScaleDown = $('#btnTextScaleDown');
const btnTextScaleUp = $('#btnTextScaleUp');
const btnZoomOut = $('#btnZoomOut');
const btnZoomIn = $('#btnZoomIn');
const btnPreviewZoomOut = $('#btnPreviewZoomOut');
const btnPreviewZoomIn = $('#btnPreviewZoomIn');
const btnResetCrop = $('#btnResetCrop');
const btnGenerate = $('#btnGenerate');
const btnRegenerate = $('#btnRegenerate');
const btnApply = $('#btnApply');
const toast = $('#toast');
let searchTimer;
let previewTimer;
let generateController = null;
let generateRequestId = 0;
let dragState = null;
let textAlign = 'center';
let textPosition = 'bottom';
function esc(value) {
const div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
}
function showToast(message, type) {
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 2800);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function updatePreviewMeta() {
zoomVal.textContent = `${zoomSlider.value}%`;
panXVal.textContent = panXSlider.value;
panYVal.textContent = panYSlider.value;
textScaleVal.textContent = `${textScaleSlider.value}%`;
darknessVal.textContent = `${darknessSlider.value}%`;
previewMeta.textContent = `${zoomSlider.value}% · X ${panXSlider.value} · Y ${panYSlider.value}`;
}
function getSelectedTextAlign() {
return textAlign;
}
function getSelectedTextPosition() {
return textPosition;
}
function getCurrentSourceLabel() {
if (state.uploadBgId) {
return `Using uploaded image${state.uploadFileName ? `: ${state.uploadFileName}` : ''}`;
}
const target = state.targetType.toLowerCase();
const targetExists = state.targetType === 'Thumb' ? state.artwork.thumb.exists : state.artwork.primary.exists;
const fallbackExists = state.targetType === 'Thumb' ? state.artwork.primary.exists : state.artwork.thumb.exists;
if (targetExists) {
return `Using current ${target} artwork as the base`;
}
if (fallbackExists) {
return `Using current ${state.targetType === 'Thumb' ? 'primary' : 'thumb'} artwork as the base`;
}
return 'No existing artwork found. Preview uses a generated fallback background until you upload an image.';
}
function updateSourceUi() {
sourceStatus.innerHTML = `<strong>Source:</strong> ${esc(getCurrentSourceLabel())}`;
sourceBadge.textContent = state.item ? getCurrentSourceLabel() : 'Waiting for collection';
btnClearImage.disabled = !state.uploadBgId;
}
function updateApplyButton() {
btnApply.textContent = state.targetType === 'Primary' ? 'Apply Primary to Emby' : 'Apply Thumb to Emby';
selectedTargetNote.textContent = `Editing ${state.targetType}`;
}
function updateTargetUi() {
thumbCard.classList.toggle('active', state.targetType === 'Thumb');
primaryCard.classList.toggle('active', state.targetType === 'Primary');
targetButtons.querySelectorAll('[data-target]').forEach(button => {
button.classList.toggle('active', button.dataset.target === state.targetType);
});
previewFrame.classList.toggle('primary', state.targetType === 'Primary');
updateApplyButton();
updateSourceUi();
}
function updateArtworkCard(kind, payload) {
const isThumb = kind === 'thumb';
const img = isThumb ? thumbCurrentImg : primaryCurrentImg;
const empty = isThumb ? thumbEmpty : primaryEmpty;
const pill = isThumb ? thumbState : primaryState;
const frame = img.closest('.asset-frame');
frame.classList.toggle('thumb', isThumb);
frame.classList.toggle('primary', !isThumb);
if (payload.exists && payload.url) {
img.src = `${payload.url}&t=${Date.now()}`;
img.style.display = 'block';
empty.style.display = 'none';
pill.textContent = 'Exists';
pill.classList.remove('off');
} else {
img.removeAttribute('src');
img.style.display = 'none';
empty.style.display = '';
pill.textContent = 'Missing';
pill.classList.add('off');
}
}
function updateArtworkUi() {
updateArtworkCard('thumb', state.artwork.thumb);
updateArtworkCard('primary', state.artwork.primary);
updateSourceUi();
}
function updateSelectionUi() {
if (!state.item) {
collectionName.textContent = 'Pick a collection';
collectionMeta.innerHTML = '<span class="tag">BoxSet</span><span>No selection yet</span>';
previewHeading.textContent = 'Preview';
previewSub.textContent = 'Select a collection to start editing.';
btnGenerate.disabled = true;
btnRegenerate.disabled = true;
btnApply.disabled = true;
updateTargetUi();
return;
}
collectionName.textContent = state.item.name;
collectionMeta.innerHTML = `
<span class="tag">${esc(state.item.type || 'BoxSet')}</span>
<span>${state.item.child_count || 0} item${state.item.child_count === 1 ? '' : 's'}</span>
`;
previewHeading.textContent = `${state.item.name} · ${state.targetType}`;
previewSub.textContent = 'Drag to adjust the crop, then apply the generated artwork back to Emby.';
btnGenerate.disabled = false;
btnRegenerate.disabled = !state.generated;
btnApply.disabled = !state.generated;
updateTargetUi();
}
function setGenerated(value) {
state.generated = value;
btnRegenerate.disabled = !value || !state.item;
btnApply.disabled = !value || !state.item;
}
async function checkConnection() {
try {
const response = await fetch('/api/config');
const data = await response.json();
if (data.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';
}
}
function updatePager(total, start, limit) {
resultsCount.textContent = total
? `${Math.min(start + 1, total)}-${Math.min(start + limit, total)} of ${total}`
: '0 results';
resultsPage.textContent = total ? `Page ${Math.floor(start / limit) + 1}` : '';
btnPrevPage.disabled = start === 0;
btnNextPage.disabled = start + limit >= total;
}
async function loadCollections(query = '', start = 0) {
if (state.searchController) state.searchController.abort();
const controller = new AbortController();
state.searchController = controller;
state.searchQuery = query;
state.searchStart = start;
resultsList.innerHTML = '<div class="empty"><div class="spinner" style="margin:0 auto 10px"></div>Loading collections…</div>';
try {
const response = await fetch(`/api/collections?q=${encodeURIComponent(query)}&start=${start}&limit=${state.searchLimit}`, {
signal: controller.signal,
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || `${response.status}`);
if (state.searchController !== controller) return;
state.searchTotal = data.total;
state.searchStart = data.start;
if (!data.items.length) {
resultsList.innerHTML = `<div class="empty">${query ? 'No matching collections.' : 'No collections found.'}</div>`;
updatePager(0, 0, state.searchLimit);
return;
}
resultsList.innerHTML = data.items.map(item => `
<div class="result-item ${state.item?.id === item.id ? 'active' : ''}" data-item='${JSON.stringify(item).replace(/'/g, '&#39;')}'>
${item.poster_url
? `<img class="result-poster" src="${item.poster_url}" alt="" loading="lazy" decoding="async" width="36" height="54">`
: '<div class="result-poster" aria-hidden="true"></div>'}
<div class="result-info">
<div class="result-name">${esc(item.name)}</div>
<div class="result-sub">
<span class="tag">Collection</span>
<span>${item.child_count || 0} item${item.child_count === 1 ? '' : 's'}</span>
</div>
</div>
</div>
`).join('');
updatePager(data.total, data.start, data.limit);
resultsList.querySelectorAll('.result-item').forEach(element => {
element.addEventListener('click', () => selectCollection(JSON.parse(element.dataset.item)));
});
} catch (error) {
if (error.name === 'AbortError') return;
resultsList.innerHTML = `<div class="empty">Failed to load collections: ${esc(error.message)}</div>`;
updatePager(0, 0, state.searchLimit);
} finally {
if (state.searchController === controller) state.searchController = null;
}
}
async function refreshArtwork() {
if (!state.item) return;
try {
const response = await fetch(`/api/collections/${state.item.id}/artwork`);
const data = await response.json();
if (!response.ok) throw new Error(data.detail || `${response.status}`);
state.artwork = {
thumb: data.thumb ?? { exists: false, url: null },
primary: data.primary ?? { exists: false, url: null },
};
updateArtworkUi();
} catch {
state.artwork = {
thumb: { exists: false, url: null },
primary: { exists: false, url: null },
};
updateArtworkUi();
}
}
function schedulePreview() {
if (!state.item) return;
clearTimeout(previewTimer);
setGenerated(false);
previewTimer = setTimeout(() => generatePreview({ silent: true }), 180);
}
function setCropValues({ zoom, panX, panY }, { regenerate = false } = {}) {
if (zoom != null) zoomSlider.value = String(clamp(Math.round(zoom), 100, 260));
if (panX != null) panXSlider.value = String(clamp(Math.round(panX), -100, 100));
if (panY != null) panYSlider.value = String(clamp(Math.round(panY), -100, 100));
updatePreviewMeta();
if (regenerate) schedulePreview();
}
function setTextScale(value, { regenerate = false } = {}) {
textScaleSlider.value = String(clamp(Math.round(value), 70, 180));
updatePreviewMeta();
if (regenerate) schedulePreview();
}
function setTarget(targetType) {
if (!targetType || targetType === state.targetType) return;
state.targetType = targetType;
state.cacheKey = null;
setGenerated(false);
updateSelectionUi();
updateSourceUi();
if (state.item) generatePreview({ silent: true });
}
async function selectCollection(item) {
if (state.item?.id === item.id) return;
state.item = item;
state.artwork = {
thumb: { exists: false, url: null },
primary: { exists: false, url: null },
};
state.uploadBgId = null;
state.uploadFileName = '';
state.cacheKey = null;
previewPlaceholder.style.display = 'none';
previewFrame.classList.add('visible');
overlayText.value = item.name;
setCropValues({ zoom: 100, panX: 0, panY: 0 });
setTextScale(100);
darknessSlider.value = '18';
textColorText.value = '#FFFFFF';
textColorPicker.value = '#ffffff';
updatePreviewMeta();
setGenerated(false);
updateSelectionUi();
updateArtworkUi();
resultsList.querySelectorAll('.result-item').forEach(element => element.classList.remove('active'));
resultsList.querySelector(`[data-item*='"id":"${item.id}"']`)?.classList.add('active');
generatePreview({ silent: true });
refreshArtwork();
}
async function uploadFile(file) {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
btnChooseImage.disabled = true;
try {
const response = await fetch('/api/upload-background', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || `${response.status}`);
state.uploadBgId = data.upload_id;
state.uploadFileName = file.name;
updateSourceUi();
showToast(`Image ready (${data.width}×${data.height})`, 'ok');
if (state.item) generatePreview({ silent: true });
} catch (error) {
state.uploadBgId = null;
state.uploadFileName = '';
updateSourceUi();
showToast(`Upload failed: ${error.message}`, 'err');
} finally {
btnChooseImage.disabled = false;
}
}
async function generatePreview({ silent = false } = {}) {
if (!state.item) return;
clearTimeout(previewTimer);
if (generateController) generateController.abort();
const controller = new AbortController();
generateController = controller;
const requestId = ++generateRequestId;
loadingCover.classList.add('on');
btnGenerate.disabled = true;
try {
const response = await fetch('/api/collections/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
item_id: state.item.id,
title: state.item.name,
target_type: state.targetType,
text: overlayText.value.trim() || state.item.name,
text_color: textColorText.value,
text_align: getSelectedTextAlign(),
text_position: getSelectedTextPosition(),
text_scale: Number(textScaleSlider.value) / 100,
darkness: Number(darknessSlider.value) / 100,
zoom: Number(zoomSlider.value) / 100,
pan_x: Number(panXSlider.value) / 100,
pan_y: Number(panYSlider.value) / 100,
upload_bg_id: state.uploadBgId,
}),
});
if (!response.ok) {
let detail = `${response.status}`;
try {
const data = await response.json();
if (data.detail) detail = data.detail;
} catch {}
throw new Error(detail);
}
if (requestId !== generateRequestId) return;
state.cacheKey = response.headers.get('X-Cache-Key');
const blob = await response.blob();
if (previewImg.src.startsWith('blob:')) URL.revokeObjectURL(previewImg.src);
previewImg.src = URL.createObjectURL(blob);
setGenerated(true);
if (!silent) {
showToast(`Generated ${state.targetType.toLowerCase()} artwork`, 'ok');
}
} catch (error) {
if (error.name === 'AbortError') return;
state.cacheKey = null;
setGenerated(false);
showToast(`Failed: ${error.message}`, 'err');
} finally {
if (generateController === controller) generateController = null;
if (requestId === generateRequestId) {
loadingCover.classList.remove('on');
btnGenerate.disabled = !state.item;
}
}
}
async function applyArtwork() {
if (!state.item || !state.generated) return;
btnApply.disabled = true;
try {
const response = await fetch('/api/collections/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_id: state.item.id,
title: state.item.name,
target_type: state.targetType,
text: overlayText.value.trim() || state.item.name,
text_color: textColorText.value,
text_align: getSelectedTextAlign(),
text_position: getSelectedTextPosition(),
text_scale: Number(textScaleSlider.value) / 100,
darkness: Number(darknessSlider.value) / 100,
zoom: Number(zoomSlider.value) / 100,
pan_x: Number(panXSlider.value) / 100,
pan_y: Number(panYSlider.value) / 100,
upload_bg_id: state.uploadBgId,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || `${response.status}`);
showToast(`Applied ${data.target_type.toLowerCase()} artwork`, 'ok');
refreshArtwork();
} catch (error) {
showToast(`Failed: ${error.message}`, 'err');
} finally {
btnApply.disabled = !state.generated;
}
}
function finishDrag(pointerId = null) {
if (!dragState) return;
if (pointerId != null && dragState.pointerId !== pointerId) return;
try {
previewFrame.releasePointerCapture(dragState.pointerId);
} catch {}
previewFrame.classList.remove('dragging');
dragState = null;
}
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadCollections(searchInput.value.trim(), 0), 180);
});
btnPrevPage.addEventListener('click', () => {
if (state.searchStart === 0) return;
loadCollections(state.searchQuery, Math.max(0, state.searchStart - state.searchLimit));
});
btnNextPage.addEventListener('click', () => {
if (state.searchStart + state.searchLimit >= state.searchTotal) return;
loadCollections(state.searchQuery, state.searchStart + state.searchLimit);
});
[thumbCard, primaryCard].forEach(card => {
card.addEventListener('click', () => setTarget(card.dataset.target));
});
targetButtons.querySelectorAll('[data-target]').forEach(button => {
button.addEventListener('click', () => setTarget(button.dataset.target));
});
alignButtons.querySelectorAll('[data-align]').forEach(button => {
button.addEventListener('click', () => {
textAlign = button.dataset.align;
alignButtons.querySelectorAll('[data-align]').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
schedulePreview();
});
});
positionButtons.querySelectorAll('[data-position]').forEach(button => {
button.addEventListener('click', () => {
textPosition = button.dataset.position;
positionButtons.querySelectorAll('[data-position]').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
schedulePreview();
});
});
overlayText.addEventListener('input', schedulePreview);
textColorPicker.addEventListener('input', () => {
textColorText.value = textColorPicker.value.toUpperCase();
schedulePreview();
});
textColorText.addEventListener('input', () => {
if (/^#[0-9a-f]{6}$/i.test(textColorText.value)) {
textColorPicker.value = textColorText.value;
schedulePreview();
}
});
textScaleSlider.addEventListener('input', () => setTextScale(Number(textScaleSlider.value), { regenerate: true }));
darknessSlider.addEventListener('input', () => {
updatePreviewMeta();
schedulePreview();
});
zoomSlider.addEventListener('input', () => setCropValues({ zoom: Number(zoomSlider.value) }, { regenerate: true }));
panXSlider.addEventListener('input', () => setCropValues({ panX: Number(panXSlider.value) }, { regenerate: true }));
panYSlider.addEventListener('input', () => setCropValues({ panY: Number(panYSlider.value) }, { regenerate: true }));
btnTextScaleDown.addEventListener('click', () => setTextScale(Number(textScaleSlider.value) - 10, { regenerate: true }));
btnTextScaleUp.addEventListener('click', () => setTextScale(Number(textScaleSlider.value) + 10, { regenerate: true }));
btnZoomOut.addEventListener('click', () => setCropValues({ zoom: Number(zoomSlider.value) - 10 }, { regenerate: true }));
btnZoomIn.addEventListener('click', () => setCropValues({ zoom: Number(zoomSlider.value) + 10 }, { regenerate: true }));
btnPreviewZoomOut.addEventListener('click', () => setCropValues({ zoom: Number(zoomSlider.value) - 10 }, { regenerate: true }));
btnPreviewZoomIn.addEventListener('click', () => setCropValues({ zoom: Number(zoomSlider.value) + 10 }, { regenerate: true }));
btnResetCrop.addEventListener('click', () => setCropValues({ zoom: 100, panX: 0, panY: 0 }, { regenerate: true }));
btnGenerate.addEventListener('click', () => generatePreview());
btnRegenerate.addEventListener('click', () => generatePreview());
btnApply.addEventListener('click', applyArtwork);
previewFrame.addEventListener('pointerdown', event => {
if (!state.item || event.target.closest('button')) return;
event.preventDefault();
dragState = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startPanX: Number(panXSlider.value),
startPanY: Number(panYSlider.value),
};
previewFrame.classList.add('dragging');
previewFrame.setPointerCapture(event.pointerId);
});
previewFrame.addEventListener('pointermove', event => {
if (!dragState || dragState.pointerId !== event.pointerId) return;
const rect = previewFrame.getBoundingClientRect();
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
setCropValues({
panX: dragState.startPanX - (deltaX / Math.max(1, rect.width)) * 200,
panY: dragState.startPanY - (deltaY / Math.max(1, rect.height)) * 200,
}, { regenerate: true });
});
previewFrame.addEventListener('pointerup', event => finishDrag(event.pointerId));
previewFrame.addEventListener('pointercancel', event => finishDrag(event.pointerId));
previewFrame.addEventListener('lostpointercapture', () => finishDrag());
previewFrame.addEventListener('wheel', event => {
if (!state.item) return;
event.preventDefault();
const delta = event.deltaY < 0 ? 8 : -8;
setCropValues({ zoom: Number(zoomSlider.value) + delta }, { regenerate: true });
}, { passive: false });
btnChooseImage.addEventListener('click', () => uploadInput.click());
uploadInput.addEventListener('change', () => {
const file = uploadInput.files[0];
if (file) uploadFile(file);
uploadInput.value = '';
});
btnClearImage.addEventListener('click', () => {
state.uploadBgId = null;
state.uploadFileName = '';
updateSourceUi();
schedulePreview();
});
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, event => {
event.preventDefault();
dropzone.classList.add('drag-over');
});
});
['dragleave', 'dragend', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, event => {
event.preventDefault();
if (eventName !== 'drop') {
dropzone.classList.remove('drag-over');
}
});
});
dropzone.addEventListener('drop', event => {
dropzone.classList.remove('drag-over');
const file = event.dataTransfer?.files?.[0];
if (file) uploadFile(file);
});
updatePreviewMeta();
updateSelectionUi();
updateArtworkUi();
checkConnection();
loadCollections('', 0);
</script>
</body>
</html>