diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 9055e10..a4b06a2 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -66,9 +66,20 @@ body { width: 100%; height: 100%; border: none; + filter: brightness(0.35) saturate(0.7); } -.overlay canvas, .broadcast-body canvas { +.overlay canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: auto; + z-index: 2; +} + +.broadcast-body canvas { position: absolute; top: 0; left: 0; @@ -100,3 +111,45 @@ body { .panel li { margin: 6px 0; } + +.asset-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +.asset-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + border-radius: 8px; + background: #111827; + border: 1px solid #1f2937; + cursor: pointer; +} + +.asset-item.selected { + border-color: #7c3aed; + box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.6); +} + +.asset-item .meta { + display: flex; + flex-direction: column; + gap: 4px; +} + +.asset-item small { + color: #94a3b8; +} + +.asset-item .actions { + display: flex; + gap: 8px; +} + +.asset-item.hidden { + opacity: 0.6; +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index cf5876c..ab1b696 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -4,6 +4,9 @@ const ctx = canvas.getContext('2d'); canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; const assets = new Map(); +const imageCache = new Map(); +let selectedAssetId = null; +let dragState = null; function connect() { const socket = new SockJS('/ws'); @@ -18,33 +21,167 @@ function connect() { } function fetchAssets() { - fetch(`/api/channels/${broadcaster}/assets`).then(r => r.json()).then(renderAssets); + fetch(`/api/channels/${broadcaster}/assets`).then((r) => r.json()).then(renderAssets); } function renderAssets(list) { - list.forEach(asset => assets.set(asset.id, asset)); - draw(); + list.forEach((asset) => assets.set(asset.id, asset)); + drawAndList(); } function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); + imageCache.delete(event.assetId); + if (selectedAssetId === event.assetId) { + selectedAssetId = null; + } } else if (event.payload) { assets.set(event.payload.id, event.payload); + ensureImage(event.payload); } + drawAndList(); +} + +function drawAndList() { draw(); + renderAssetList(); } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); - assets.forEach(asset => { - ctx.save(); - ctx.globalAlpha = asset.hidden ? 0.35 : 1; - ctx.translate(asset.x, asset.y); - ctx.rotate(asset.rotation * Math.PI / 180); - ctx.fillStyle = 'rgba(124, 58, 237, 0.25)'; + assets.forEach((asset) => drawAsset(asset)); +} + +function drawAsset(asset) { + ctx.save(); + ctx.translate(asset.x, asset.y); + ctx.rotate(asset.rotation * Math.PI / 180); + + const image = ensureImage(asset); + if (image?.complete) { + ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; + ctx.drawImage(image, 0, 0, asset.width, asset.height); + } else { + ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; + ctx.fillStyle = 'rgba(124, 58, 237, 0.35)'; ctx.fillRect(0, 0, asset.width, asset.height); - ctx.restore(); + } + + if (asset.hidden) { + ctx.fillStyle = 'rgba(15, 23, 42, 0.35)'; + ctx.fillRect(0, 0, asset.width, asset.height); + } + + ctx.globalAlpha = 1; + ctx.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)'; + ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1; + ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []); + ctx.strokeRect(0, 0, asset.width, asset.height); + ctx.restore(); +} + +function ensureImage(asset) { + const cached = imageCache.get(asset.id); + if (cached && cached.src === asset.url) { + return cached; + } + + const image = new Image(); + image.onload = draw; + image.src = asset.url; + imageCache.set(asset.id, image); + return image; +} + +function renderAssetList() { + const list = document.getElementById('asset-list'); + list.innerHTML = ''; + + if (!assets.size) { + const empty = document.createElement('li'); + empty.textContent = 'No assets yet. Upload to get started.'; + list.appendChild(empty); + return; + } + + const sortedAssets = Array.from(assets.values()).sort( + (a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0) + ); + sortedAssets.forEach((asset) => { + const li = document.createElement('li'); + li.className = 'asset-item'; + if (asset.id === selectedAssetId) { + li.classList.add('selected'); + } + if (asset.hidden) { + li.classList.add('hidden'); + } + + const meta = document.createElement('div'); + meta.className = 'meta'; + const name = document.createElement('strong'); + name.textContent = `Asset ${asset.id.slice(0, 6)}`; + const details = document.createElement('small'); + details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} ยท ${asset.hidden ? 'Hidden' : 'Visible'}`; + meta.appendChild(name); + meta.appendChild(details); + + const actions = document.createElement('div'); + actions.className = 'actions'; + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'secondary'; + toggleBtn.textContent = asset.hidden ? 'Show' : 'Hide'; + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + selectedAssetId = asset.id; + updateVisibility(asset, !asset.hidden); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'secondary'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + deleteAsset(asset); + }); + + actions.appendChild(toggleBtn); + actions.appendChild(deleteBtn); + + li.addEventListener('click', () => { + selectedAssetId = asset.id; + drawAndList(); + }); + + li.appendChild(meta); + li.appendChild(actions); + list.appendChild(li); + }); +} + +function updateVisibility(asset, hidden) { + fetch(`/api/channels/${broadcaster}/assets/${asset.id}/visibility`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hidden }) + }).then((r) => r.json()).then((updated) => { + assets.set(updated.id, updated); + drawAndList(); + }); +} + +function deleteAsset(asset) { + fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => { + assets.delete(asset.id); + imageCache.delete(asset.id); + if (selectedAssetId === asset.id) { + selectedAssetId = null; + } + drawAndList(); }); } @@ -64,6 +201,95 @@ function uploadAsset() { }); } +function getCanvasPoint(event) { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY + }; +} + +function isPointOnAsset(asset, x, y) { + ctx.save(); + ctx.translate(asset.x, asset.y); + ctx.rotate(asset.rotation * Math.PI / 180); + const path = new Path2D(); + path.rect(0, 0, asset.width, asset.height); + const hit = ctx.isPointInPath(path, x, y); + ctx.restore(); + return hit; +} + +function findAssetAtPoint(x, y) { + const ordered = Array.from(assets.values()).reverse(); + return ordered.find((asset) => isPointOnAsset(asset, x, y)) || null; +} + +function persistTransform(asset) { + fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + x: asset.x, + y: asset.y, + width: asset.width, + height: asset.height, + rotation: asset.rotation + }) + }).then((r) => r.json()).then((updated) => { + assets.set(updated.id, updated); + drawAndList(); + }); +} + +canvas.addEventListener('mousedown', (event) => { + const point = getCanvasPoint(event); + const hit = findAssetAtPoint(point.x, point.y); + if (hit) { + selectedAssetId = hit.id; + dragState = { + assetId: hit.id, + offsetX: point.x - hit.x, + offsetY: point.y - hit.y + }; + } else { + selectedAssetId = null; + } + drawAndList(); +}); + +canvas.addEventListener('mousemove', (event) => { + if (!dragState) { + return; + } + const asset = assets.get(dragState.assetId); + if (!asset) { + dragState = null; + return; + } + const point = getCanvasPoint(event); + asset.x = point.x - dragState.offsetX; + asset.y = point.y - dragState.offsetY; + draw(); +}); + +function endDrag() { + if (!dragState) { + return; + } + const asset = assets.get(dragState.assetId); + dragState = null; + drawAndList(); + if (asset) { + persistTransform(asset); + } +} + +canvas.addEventListener('mouseup', endDrag); +canvas.addEventListener('mouseleave', endDrag); + window.addEventListener('resize', () => { canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 6646814..058c8d6 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -24,7 +24,7 @@

Upload images to place on the broadcaster's overlay. Changes are visible to the broadcaster instantly.

- +