From d92617f82cbd1ae18ef4d2be3f0d2602590461f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 9 Dec 2025 10:29:10 +0100 Subject: [PATCH] Improve translation controls --- .../imgfloat/app/config/SchemaMigration.java | 60 +++++++ .../app/controller/ChannelApiController.java | 18 ++ .../app/model/CanvasSettingsRequest.java | 35 ++++ .../java/com/imgfloat/app/model/Channel.java | 28 +++ .../app/service/ChannelDirectoryService.java | 14 ++ src/main/resources/static/css/styles.css | 25 ++- src/main/resources/static/js/admin.js | 162 ++++++++++++++---- src/main/resources/static/js/broadcast.js | 45 ++++- src/main/resources/static/js/dashboard.js | 44 +++++ src/main/resources/templates/admin.html | 16 +- src/main/resources/templates/dashboard.html | 18 ++ 11 files changed, 413 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/imgfloat/app/config/SchemaMigration.java create mode 100644 src/main/java/com/imgfloat/app/model/CanvasSettingsRequest.java diff --git a/src/main/java/com/imgfloat/app/config/SchemaMigration.java b/src/main/java/com/imgfloat/app/config/SchemaMigration.java new file mode 100644 index 0000000..c2384a3 --- /dev/null +++ b/src/main/java/com/imgfloat/app/config/SchemaMigration.java @@ -0,0 +1,60 @@ +package com.imgfloat.app.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class SchemaMigration implements ApplicationRunner { + + private static final Logger logger = LoggerFactory.getLogger(SchemaMigration.class); + + private final JdbcTemplate jdbcTemplate; + + public SchemaMigration(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void run(ApplicationArguments args) { + ensureChannelCanvasColumns(); + } + + private void ensureChannelCanvasColumns() { + List columns; + try { + columns = jdbcTemplate.query("PRAGMA table_info(channels)", (rs, rowNum) -> rs.getString("name")); + } catch (DataAccessException ex) { + logger.warn("Unable to inspect channels table for canvas columns", ex); + return; + } + + if (columns.isEmpty()) { + // Table does not exist yet; Hibernate will create it with the correct columns. + return; + } + + addColumnIfMissing(columns, "canvas_width", "REAL", "1920"); + addColumnIfMissing(columns, "canvas_height", "REAL", "1080"); + } + + private void addColumnIfMissing(List existingColumns, String columnName, String dataType, String defaultValue) { + if (existingColumns.contains(columnName)) { + return; + } + + try { + jdbcTemplate.execute("ALTER TABLE channels ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue); + logger.info("Added missing column '{}' to channels table", columnName); + } catch (DataAccessException ex) { + logger.warn("Failed to add column '{}' to channels table", columnName, ex); + } + } +} + diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index 7cefec5..12b7291 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -2,6 +2,7 @@ package com.imgfloat.app.controller; import com.imgfloat.app.model.AdminRequest; import com.imgfloat.app.model.Asset; +import com.imgfloat.app.model.CanvasSettingsRequest; import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.service.ChannelDirectoryService; @@ -85,6 +86,23 @@ public class ChannelApiController { return channelDirectoryService.getVisibleAssets(broadcaster); } + @GetMapping("/canvas") + public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken authentication) { + String login = TwitchUser.from(authentication).login(); + ensureAuthorized(broadcaster, login); + return channelDirectoryService.getCanvasSettings(broadcaster); + } + + @PutMapping("/canvas") + public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody CanvasSettingsRequest request, + OAuth2AuthenticationToken authentication) { + String login = TwitchUser.from(authentication).login(); + ensureBroadcaster(broadcaster, login); + return channelDirectoryService.updateCanvasSettings(broadcaster, request); + } + @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createAsset(@PathVariable("broadcaster") String broadcaster, @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, diff --git a/src/main/java/com/imgfloat/app/model/CanvasSettingsRequest.java b/src/main/java/com/imgfloat/app/model/CanvasSettingsRequest.java new file mode 100644 index 0000000..187e9dd --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/CanvasSettingsRequest.java @@ -0,0 +1,35 @@ +package com.imgfloat.app.model; + +import jakarta.validation.constraints.Positive; + +public class CanvasSettingsRequest { + @Positive + private double width; + + @Positive + private double height; + + public CanvasSettingsRequest() { + } + + public CanvasSettingsRequest(double width, double height) { + this.width = width; + this.height = height; + } + + public double getWidth() { + return width; + } + + public void setWidth(double width) { + this.width = width; + } + + public double getHeight() { + return height; + } + + public void setHeight(double height) { + this.height = height; + } +} diff --git a/src/main/java/com/imgfloat/app/model/Channel.java b/src/main/java/com/imgfloat/app/model/Channel.java index 3b57501..3d3f7c8 100644 --- a/src/main/java/com/imgfloat/app/model/Channel.java +++ b/src/main/java/com/imgfloat/app/model/Channel.java @@ -28,11 +28,17 @@ public class Channel { @Column(name = "admin_username") private Set admins = new HashSet<>(); + private double canvasWidth = 1920; + + private double canvasHeight = 1080; + public Channel() { } public Channel(String broadcaster) { this.broadcaster = normalize(broadcaster); + this.canvasWidth = 1920; + this.canvasHeight = 1080; } public String getBroadcaster() { @@ -51,6 +57,22 @@ public class Channel { return admins.remove(normalize(username)); } + public double getCanvasWidth() { + return canvasWidth; + } + + public void setCanvasWidth(double canvasWidth) { + this.canvasWidth = canvasWidth; + } + + public double getCanvasHeight() { + return canvasHeight; + } + + public void setCanvasHeight(double canvasHeight) { + this.canvasHeight = canvasHeight; + } + @PrePersist @PreUpdate public void normalizeFields() { @@ -58,6 +80,12 @@ public class Channel { this.admins = admins.stream() .map(Channel::normalize) .collect(Collectors.toSet()); + if (canvasWidth <= 0) { + canvasWidth = 1920; + } + if (canvasHeight <= 0) { + canvasHeight = 1080; + } } private static String normalize(String value) { diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index fd52405..b707776 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -3,6 +3,7 @@ package com.imgfloat.app.service; import com.imgfloat.app.model.Asset; import com.imgfloat.app.model.AssetEvent; import com.imgfloat.app.model.Channel; +import com.imgfloat.app.model.CanvasSettingsRequest; import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.repository.AssetRepository; @@ -68,6 +69,19 @@ public class ChannelDirectoryService { return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)); } + public CanvasSettingsRequest getCanvasSettings(String broadcaster) { + Channel channel = getOrCreateChannel(broadcaster); + return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); + } + + public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest request) { + Channel channel = getOrCreateChannel(broadcaster); + channel.setCanvasWidth(request.getWidth()); + channel.setCanvasHeight(request.getHeight()); + channelRepository.save(channel); + return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); + } + public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { Channel channel = getOrCreateChannel(broadcaster); byte[] bytes = file.getBytes(); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 58694e3..38ac9d5 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -6,6 +6,12 @@ body { padding: 0; } +.admin-layout { + min-height: 100vh; + display: flex; + flex-direction: column; +} + .container { max-width: 960px; margin: 40px auto; @@ -55,8 +61,11 @@ body { .overlay { position: relative; - height: 600px; + flex: 1; + min-height: 420px; + height: calc(100vh - 260px); background: black; + overflow: hidden; } .overlay iframe { @@ -73,8 +82,6 @@ body { position: absolute; top: 0; left: 0; - width: 100%; - height: 100%; pointer-events: auto; z-index: 2; } @@ -210,6 +217,18 @@ body { flex-wrap: wrap; } +.checkbox-inline { + display: flex; + align-items: center; + gap: 8px; + padding-top: 6px; +} + +.muted { + color: #94a3b8; + font-size: 0.9em; +} + .range-value { color: #a5b4fc; font-size: 12px; diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 815f825..ca052b3 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -1,26 +1,29 @@ let stompClient; const canvas = document.getElementById('admin-canvas'); const ctx = canvas.getContext('2d'); -canvas.width = canvas.offsetWidth; -canvas.height = canvas.offsetHeight; +const overlay = document.getElementById('admin-overlay'); +const overlayFrame = overlay?.querySelector('iframe'); +let canvasSettings = { width: 1920, height: 1080 }; +canvas.width = canvasSettings.width; +canvas.height = canvasSettings.height; const assets = new Map(); const imageCache = new Map(); const renderStates = new Map(); let selectedAssetId = null; let dragState = null; let animationFrameId = null; +let lastSizeInputChanged = null; const controlsPanel = document.getElementById('asset-controls'); const widthInput = document.getElementById('asset-width'); const heightInput = document.getElementById('asset-height'); -const rotationInput = document.getElementById('asset-rotation'); -const rotationDisplay = document.getElementById('rotation-display'); +const aspectLockInput = document.getElementById('maintain-aspect'); const selectedAssetName = document.getElementById('selected-asset-name'); const selectedAssetMeta = document.getElementById('selected-asset-meta'); +const aspectLockState = new Map(); -if (rotationInput) { - rotationInput.addEventListener('input', updateRotationDisplay); -} +if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width')); +if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height')); function connect() { const socket = new SockJS('/ws'); @@ -38,6 +41,39 @@ function fetchAssets() { fetch(`/api/channels/${broadcaster}/assets`).then((r) => r.json()).then(renderAssets); } +function fetchCanvasSettings() { + return fetch(`/api/channels/${broadcaster}/canvas`) + .then((r) => r.json()) + .then((settings) => { + canvasSettings = settings; + resizeCanvas(); + }) + .catch(() => resizeCanvas()); +} + +function resizeCanvas() { + if (!overlay) { + return; + } + const bounds = overlay.getBoundingClientRect(); + const scale = Math.min(bounds.width / canvasSettings.width, bounds.height / canvasSettings.height); + const displayWidth = canvasSettings.width * scale; + const displayHeight = canvasSettings.height * scale; + canvas.width = canvasSettings.width; + canvas.height = canvasSettings.height; + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + canvas.style.left = `${(bounds.width - displayWidth) / 2}px`; + canvas.style.top = `${(bounds.height - displayHeight) / 2}px`; + if (overlayFrame) { + overlayFrame.style.width = `${displayWidth}px`; + overlayFrame.style.height = `${displayHeight}px`; + overlayFrame.style.left = `${(bounds.width - displayWidth) / 2}px`; + overlayFrame.style.top = `${(bounds.height - displayHeight) / 2}px`; + } + draw(); +} + function renderAssets(list) { list.forEach((asset) => assets.set(asset.id, asset)); drawAndList(); @@ -70,30 +106,32 @@ function draw() { function drawAsset(asset) { const renderState = smoothState(asset); + const halfWidth = renderState.width / 2; + const halfHeight = renderState.height / 2; ctx.save(); - ctx.translate(renderState.x, renderState.y); + ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.rotate(renderState.rotation * Math.PI / 180); const image = ensureImage(asset); if (image?.complete) { ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; - ctx.drawImage(image, 0, 0, renderState.width, renderState.height); + ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height); } else { ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; ctx.fillStyle = 'rgba(124, 58, 237, 0.35)'; - ctx.fillRect(0, 0, renderState.width, renderState.height); + ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); } if (asset.hidden) { ctx.fillStyle = 'rgba(15, 23, 42, 0.35)'; - ctx.fillRect(0, 0, renderState.width, renderState.height); + ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.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, renderState.width, renderState.height); + ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height); ctx.restore(); } @@ -238,27 +276,38 @@ function updateSelectedAssetControls() { } controlsPanel.classList.remove('hidden'); + lastSizeInputChanged = null; selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; selectedAssetMeta.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`; if (widthInput) widthInput.value = Math.round(asset.width); if (heightInput) heightInput.value = Math.round(asset.height); - if (rotationInput) { - rotationInput.value = Math.round(asset.rotation); - updateRotationDisplay(); + if (aspectLockInput) { + aspectLockInput.checked = isAspectLocked(asset.id); + aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); } } function applyTransformFromInputs() { const asset = getSelectedAsset(); if (!asset) return; - const nextWidth = parseFloat(widthInput?.value) || asset.width; - const nextHeight = parseFloat(heightInput?.value) || asset.height; - const nextRotation = parseFloat(rotationInput?.value) || 0; + const locked = isAspectLocked(asset.id); + const ratio = getAssetAspectRatio(asset); + let nextWidth = parseFloat(widthInput?.value) || asset.width; + let nextHeight = parseFloat(heightInput?.value) || asset.height; + + if (locked && ratio) { + if (lastSizeInputChanged === 'height') { + nextWidth = nextHeight * ratio; + if (widthInput) widthInput.value = Math.round(nextWidth); + } else { + nextHeight = nextWidth / ratio; + if (heightInput) heightInput.value = Math.round(nextHeight); + } + } asset.width = Math.max(10, nextWidth); asset.height = Math.max(10, nextHeight); - asset.rotation = nextRotation; renderStates.set(asset.id, { ...asset }); persistTransform(asset); drawAndList(); @@ -268,17 +317,63 @@ function nudgeRotation(delta) { const asset = getSelectedAsset(); if (!asset) return; const next = (asset.rotation || 0) + delta; - if (rotationInput) rotationInput.value = next; asset.rotation = next; renderStates.set(asset.id, { ...asset }); - updateRotationDisplay(); persistTransform(asset); + drawAndList(); } -function updateRotationDisplay() { - if (rotationDisplay && rotationInput) { - const value = Math.round(parseFloat(rotationInput.value || '0')); - rotationDisplay.textContent = `${value}°`; +function recenterSelectedAsset() { + const asset = getSelectedAsset(); + if (!asset) return; + const centerX = (canvas.width - asset.width) / 2; + const centerY = (canvas.height - asset.height) / 2; + asset.x = centerX; + asset.y = centerY; + renderStates.set(asset.id, { ...asset }); + persistTransform(asset); + drawAndList(); +} + +function getAssetAspectRatio(asset) { + const image = ensureImage(asset); + if (image?.naturalWidth && image?.naturalHeight) { + return image.naturalWidth / image.naturalHeight; + } + if (asset.width && asset.height) { + return asset.width / asset.height; + } + return null; +} + +function setAspectLock(assetId, locked) { + aspectLockState.set(assetId, locked); +} + +function isAspectLocked(assetId) { + return aspectLockState.has(assetId) ? aspectLockState.get(assetId) : true; +} + +function handleSizeInputChange(type) { + lastSizeInputChanged = type; + const asset = getSelectedAsset(); + if (!asset || !isAspectLocked(asset.id)) { + return; + } + const ratio = getAssetAspectRatio(asset); + if (!ratio) { + return; + } + if (type === 'width' && widthInput && heightInput) { + const width = parseFloat(widthInput.value); + if (width > 0) { + heightInput.value = Math.round(width / ratio); + } + } else if (type === 'height' && widthInput && heightInput) { + const height = parseFloat(heightInput.value); + if (height > 0) { + widthInput.value = Math.round(height * ratio); + } } } @@ -333,10 +428,12 @@ function getCanvasPoint(event) { function isPointOnAsset(asset, x, y) { ctx.save(); - ctx.translate(asset.x, asset.y); + const halfWidth = asset.width / 2; + const halfHeight = asset.height / 2; + ctx.translate(asset.x + halfWidth, asset.y + halfHeight); ctx.rotate(asset.rotation * Math.PI / 180); const path = new Path2D(); - path.rect(0, 0, asset.width, asset.height); + path.rect(-halfWidth, -halfHeight, asset.width, asset.height); const hit = ctx.isPointInPath(path, x, y); ctx.restore(); return hit; @@ -411,10 +508,11 @@ canvas.addEventListener('mouseup', endDrag); canvas.addEventListener('mouseleave', endDrag); window.addEventListener('resize', () => { - canvas.width = canvas.offsetWidth; - canvas.height = canvas.offsetHeight; - draw(); + resizeCanvas(); }); -startRenderLoop(); -connect(); +fetchCanvasSettings().finally(() => { + resizeCanvas(); + startRenderLoop(); + connect(); +}); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index aae8db3..5c4e1d5 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -1,7 +1,8 @@ const canvas = document.getElementById('broadcast-canvas'); const ctx = canvas.getContext('2d'); -canvas.width = window.innerWidth; -canvas.height = window.innerHeight; +let canvasSettings = { width: 1920, height: 1080 }; +canvas.width = canvasSettings.width; +canvas.height = canvasSettings.height; const assets = new Map(); const imageCache = new Map(); const renderStates = new Map(); @@ -24,6 +25,29 @@ function renderAssets(list) { draw(); } +function fetchCanvasSettings() { + return fetch(`/api/channels/${broadcaster}/canvas`) + .then((r) => r.json()) + .then((settings) => { + canvasSettings = settings; + resizeCanvas(); + }) + .catch(() => resizeCanvas()); +} + +function resizeCanvas() { + const scale = Math.min(window.innerWidth / canvasSettings.width, window.innerHeight / canvasSettings.height); + const displayWidth = canvasSettings.width * scale; + const displayHeight = canvasSettings.height * scale; + canvas.width = canvasSettings.width; + canvas.height = canvasSettings.height; + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + canvas.style.left = `${(window.innerWidth - displayWidth) / 2}px`; + canvas.style.top = `${(window.innerHeight - displayHeight) / 2}px`; + draw(); +} + function handleEvent(event) { if (event.type === 'DELETED') { assets.delete(event.assetId); @@ -47,13 +71,15 @@ function draw() { function drawAsset(asset) { const renderState = smoothState(asset); + const halfWidth = renderState.width / 2; + const halfHeight = renderState.height / 2; ctx.save(); - ctx.translate(renderState.x, renderState.y); + ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.rotate(renderState.rotation * Math.PI / 180); const image = ensureImage(asset); if (image?.complete) { - ctx.drawImage(image, 0, 0, renderState.width, renderState.height); + ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height); } ctx.restore(); @@ -107,10 +133,11 @@ function startRenderLoop() { } window.addEventListener('resize', () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - draw(); + resizeCanvas(); }); -startRenderLoop(); -connect(); +fetchCanvasSettings().finally(() => { + resizeCanvas(); + startRenderLoop(); + connect(); +}); diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 1b1236d..6f51295 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -41,4 +41,48 @@ function addAdmin() { }); } +function renderCanvasSettings(settings) { + const widthInput = document.getElementById('canvas-width'); + const heightInput = document.getElementById('canvas-height'); + if (widthInput) widthInput.value = Math.round(settings.width); + if (heightInput) heightInput.value = Math.round(settings.height); +} + +function fetchCanvasSettings() { + fetch(`/api/channels/${broadcaster}/canvas`) + .then((r) => r.json()) + .then(renderCanvasSettings) + .catch(() => renderCanvasSettings({ width: 1920, height: 1080 })); +} + +function saveCanvasSettings() { + const widthInput = document.getElementById('canvas-width'); + const heightInput = document.getElementById('canvas-height'); + const status = document.getElementById('canvas-status'); + const width = parseFloat(widthInput?.value) || 0; + const height = parseFloat(heightInput?.value) || 0; + if (width <= 0 || height <= 0) { + alert('Please enter a valid width and height.'); + return; + } + if (status) status.textContent = 'Saving...'; + fetch(`/api/channels/${broadcaster}/canvas`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ width, height }) + }) + .then((r) => r.json()) + .then((settings) => { + renderCanvasSettings(settings); + if (status) status.textContent = 'Saved.'; + setTimeout(() => { + if (status) status.textContent = ''; + }, 2000); + }) + .catch(() => { + if (status) status.textContent = 'Unable to save right now.'; + }); +} + fetchAdmins(); +fetchCanvasSettings(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 90e39b0..ec81895 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -39,21 +39,21 @@ Height -