From 4e0e87c86c4ca429e59ddf96d32d990038ebac42 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Tue, 14 Jul 2026 18:35:24 +0100 Subject: [PATCH] Refactor web server and release workflow --- .env.example | 1 - .gitea/workflows/docker-publish.yml | 2 + Dockerfile | 2 +- README.md | 8 +- docker-compose.yml | 8 +- package.json | 6 +- src/data/templates.js | 5 +- src/player.js | 49 +- src/player/player-page.script.html | 2 +- src/player/public/css/player.css | 12 - src/{webui.js => web.js} | 489 +++++++++++++++++- src/{webui => web}/public/css/admin.css | 46 +- src/{webui => web}/public/js/admin-page.js | 2 +- .../public/js/compact/editor.header.min.js | 0 .../public/js/compact/editor.list.min.js | 0 .../public/js/compact/editor.marker.min.js | 0 .../public/js/compact/editor.min.js | 0 .../public/js/compact/editor.table.min.js | 0 .../public/js/playlist-schedule.js | 0 src/{webui => web}/public/js/slide-form.js | 0 src/{webui => web}/public/js/system-status.js | 4 +- src/{webui => web}/public/js/table-sort.js | 0 .../public/js/template-designer.js | 14 + src/{webui => web}/public/js/theme-init.js | 2 +- src/{webui => web}/public/js/theme-sidebar.js | 2 +- src/{webui => web}/public/js/toast.js | 0 src/{webui => web}/routes/account/password.js | 0 src/{webui => web}/routes/auth/login.js | 0 src/{webui => web}/routes/canvas-sizes/add.js | 0 .../routes/canvas-sizes/edit.js | 0 .../routes/canvas-sizes/list.js | 0 src/{webui => web}/routes/clients/list.js | 0 src/{webui => web}/routes/common.js | 0 src/{webui => web}/routes/dashboard/index.js | 0 src/{webui => web}/routes/index.js | 0 src/{webui => web}/routes/playlists/add.js | 0 src/{webui => web}/routes/playlists/edit.js | 0 src/{webui => web}/routes/playlists/list.js | 0 .../routes/playlists/slide-config.js | 0 src/{webui => web}/routes/screens/add.js | 0 src/{webui => web}/routes/screens/edit.js | 0 src/{webui => web}/routes/screens/list.js | 0 src/{webui => web}/routes/slides/form.js | 0 src/{webui => web}/routes/slides/list.js | 0 src/{webui => web}/routes/templates/add.js | 0 src/{webui => web}/routes/templates/edit.js | 0 src/{webui => web}/routes/templates/list.js | 0 src/{webui => web}/routes/users/add.js | 0 src/{webui => web}/routes/users/edit.js | 0 src/{webui => web}/routes/users/list.js | 0 src/{webui => web}/view.js | 0 src/{webui => web}/views/account/password.hbs | 0 src/{webui => web}/views/canvas-sizes/add.hbs | 0 .../views/canvas-sizes/edit.hbs | 0 .../views/canvas-sizes/list.hbs | 0 src/{webui => web}/views/clients/list.hbs | 0 src/{webui => web}/views/dashboard/index.hbs | 0 src/{webui => web}/views/frame-layout.hbs | 0 src/{webui => web}/views/layout.hbs | 0 src/{webui => web}/views/login.hbs | 0 src/web/views/playlists/add.hbs | 16 + src/{webui => web}/views/playlists/edit.hbs | 0 src/{webui => web}/views/playlists/list.hbs | 0 .../views/playlists/slide-config.hbs | 0 src/{webui => web}/views/screens/add.hbs | 0 src/{webui => web}/views/screens/edit.hbs | 0 src/{webui => web}/views/screens/list.hbs | 0 src/{webui => web}/views/slides/form.hbs | 0 src/{webui => web}/views/slides/list.hbs | 0 src/{webui => web}/views/templates/add.hbs | 0 src/{webui => web}/views/templates/edit.hbs | 6 +- src/{webui => web}/views/templates/list.hbs | 0 src/{webui => web}/views/users/add.hbs | 0 src/{webui => web}/views/users/edit.hbs | 0 src/{webui => web}/views/users/list.hbs | 0 src/webui/views/playlists/add.hbs | 24 - 76 files changed, 616 insertions(+), 84 deletions(-) rename src/{webui.js => web.js} (81%) rename src/{webui => web}/public/css/admin.css (98%) rename src/{webui => web}/public/js/admin-page.js (99%) rename src/{webui => web}/public/js/compact/editor.header.min.js (100%) rename src/{webui => web}/public/js/compact/editor.list.min.js (100%) rename src/{webui => web}/public/js/compact/editor.marker.min.js (100%) rename src/{webui => web}/public/js/compact/editor.min.js (100%) rename src/{webui => web}/public/js/compact/editor.table.min.js (100%) rename src/{webui => web}/public/js/playlist-schedule.js (100%) rename src/{webui => web}/public/js/slide-form.js (100%) rename src/{webui => web}/public/js/system-status.js (96%) rename src/{webui => web}/public/js/table-sort.js (100%) rename src/{webui => web}/public/js/template-designer.js (97%) rename src/{webui => web}/public/js/theme-init.js (94%) rename src/{webui => web}/public/js/theme-sidebar.js (98%) rename src/{webui => web}/public/js/toast.js (100%) rename src/{webui => web}/routes/account/password.js (100%) rename src/{webui => web}/routes/auth/login.js (100%) rename src/{webui => web}/routes/canvas-sizes/add.js (100%) rename src/{webui => web}/routes/canvas-sizes/edit.js (100%) rename src/{webui => web}/routes/canvas-sizes/list.js (100%) rename src/{webui => web}/routes/clients/list.js (100%) rename src/{webui => web}/routes/common.js (100%) rename src/{webui => web}/routes/dashboard/index.js (100%) rename src/{webui => web}/routes/index.js (100%) rename src/{webui => web}/routes/playlists/add.js (100%) rename src/{webui => web}/routes/playlists/edit.js (100%) rename src/{webui => web}/routes/playlists/list.js (100%) rename src/{webui => web}/routes/playlists/slide-config.js (100%) rename src/{webui => web}/routes/screens/add.js (100%) rename src/{webui => web}/routes/screens/edit.js (100%) rename src/{webui => web}/routes/screens/list.js (100%) rename src/{webui => web}/routes/slides/form.js (100%) rename src/{webui => web}/routes/slides/list.js (100%) rename src/{webui => web}/routes/templates/add.js (100%) rename src/{webui => web}/routes/templates/edit.js (100%) rename src/{webui => web}/routes/templates/list.js (100%) rename src/{webui => web}/routes/users/add.js (100%) rename src/{webui => web}/routes/users/edit.js (100%) rename src/{webui => web}/routes/users/list.js (100%) rename src/{webui => web}/view.js (100%) rename src/{webui => web}/views/account/password.hbs (100%) rename src/{webui => web}/views/canvas-sizes/add.hbs (100%) rename src/{webui => web}/views/canvas-sizes/edit.hbs (100%) rename src/{webui => web}/views/canvas-sizes/list.hbs (100%) rename src/{webui => web}/views/clients/list.hbs (100%) rename src/{webui => web}/views/dashboard/index.hbs (100%) rename src/{webui => web}/views/frame-layout.hbs (100%) rename src/{webui => web}/views/layout.hbs (100%) rename src/{webui => web}/views/login.hbs (100%) create mode 100644 src/web/views/playlists/add.hbs rename src/{webui => web}/views/playlists/edit.hbs (100%) rename src/{webui => web}/views/playlists/list.hbs (100%) rename src/{webui => web}/views/playlists/slide-config.hbs (100%) rename src/{webui => web}/views/screens/add.hbs (100%) rename src/{webui => web}/views/screens/edit.hbs (100%) rename src/{webui => web}/views/screens/list.hbs (100%) rename src/{webui => web}/views/slides/form.hbs (100%) rename src/{webui => web}/views/slides/list.hbs (100%) rename src/{webui => web}/views/templates/add.hbs (100%) rename src/{webui => web}/views/templates/edit.hbs (90%) rename src/{webui => web}/views/templates/list.hbs (100%) rename src/{webui => web}/views/users/add.hbs (100%) rename src/{webui => web}/views/users/edit.hbs (100%) rename src/{webui => web}/views/users/list.hbs (100%) delete mode 100644 src/webui/views/playlists/add.hbs diff --git a/.env.example b/.env.example index 4ce5263..18bc519 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,6 @@ MYSQL_ROOT_PASSWORD=root_password PLAYER_INTERNAL_BASE_URL=http://player:3001 PLAYER_PUBLIC_BASE_URL=http://localhost:3001 -UPLOAD_DIR=/app/uploads SESSION_MAX_AGE_DAYS=14 DASHBOARD_REFRESH_INTERVAL_MS=2000 diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index e6ee2cf..7fe71d2 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -32,6 +32,8 @@ jobs: tags: | type=raw,value=latest type=ref,event=tag + type=semver,pattern=v{{major}}.{{minor}} + type=semver,pattern=v{{major}} - name: Build and push image uses: docker/build-push-action@v6 diff --git a/Dockerfile b/Dockerfile index 1b5843e..b207399 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,4 +11,4 @@ RUN mkdir -p /app/uploads EXPOSE 3000 -CMD ["npm", "run", "start:webui"] +CMD ["npm", "run", "start:web"] diff --git a/README.md b/README.md index 6747dd3..bb64fd4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,6 @@ The app reads its settings from environment variables. - `WEB_PORT` - admin app port, default `3000` - `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001` - `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001` -- `UPLOAD_DIR` - storage location for uploaded files, default `./uploads` ### Player App @@ -67,14 +66,17 @@ The app reads its settings from environment variables. The repository includes a `docker-compose.yml` file that starts three services: -- `webui` - the admin app on port `3000` +- `web` - the admin app on port `3000` - `player` - the signage player on port `3001` - `mysql` - the database on port `3306` -By default, `webui` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`. +By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`. The Compose file also defines a shared `uploads` volume for media and a `mysql_data` volume for database persistence. +In Docker, upload storage is controlled by the `uploads` volume mount at `/app/uploads`. +Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path. + ## Important Notes - The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`. diff --git a/docker-compose.yml b/docker-compose.yml index f265f26..4c950a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: - webui: + web: image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest} - container_name: signage-webui + container_name: signage-web restart: unless-stopped ports: - "3000:3000" @@ -15,7 +15,6 @@ services: DB_PASSWORD: ${DB_PASSWORD:-signage_password} PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001} PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001} - UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14} DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} @@ -24,7 +23,7 @@ services: PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000} volumes: - uploads:/app/uploads - command: ["npm", "run", "start:webui"] + command: ["npm", "run", "start:web"] depends_on: mysql: condition: service_healthy @@ -45,7 +44,6 @@ services: DB_NAME: ${DB_NAME:-signage} DB_USER: ${DB_USER:-signage_user} DB_PASSWORD: ${DB_PASSWORD:-signage_password} - UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads} volumes: - uploads:/app/uploads command: ["npm", "run", "start:player"] diff --git a/package.json b/package.json index d5b6ac3..be4c36f 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "description": "Pulse Signage application with MySQL and media uploads", "main": "src/common.js", "scripts": { - "start": "node -r dotenv/config src/webui.js", - "start:webui": "node -r dotenv/config src/webui.js", + "start": "node -r dotenv/config src/web.js", + "start:web": "node -r dotenv/config src/web.js", "start:player": "node -r dotenv/config src/player.js", - "dev:webui": "nodemon -r dotenv/config src/webui.js", + "dev:web": "nodemon -r dotenv/config src/web.js", "dev:player": "nodemon -r dotenv/config src/player.js", "docker:build": "docker build -t pulse-signage:test .", "docker:up": "docker-compose up -d", diff --git a/src/data/templates.js b/src/data/templates.js index 08f8a48..26f1a0a 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -106,9 +106,12 @@ async function buildTemplatePayload(pool, req, existingTemplate) { let regions = extractTemplateRegions(req.body); const filesByField = getFilesByField(req.files || []); const backgroundImage = filesByField.background_image; + const removeBackgroundImage = Boolean(req.body.remove_background_image); const backgroundImagePath = backgroundImage ? `/uploads/${backgroundImage.filename}` - : String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null; + : removeBackgroundImage + ? null + : String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null; if (!name) { const error = new Error('Template name is required.'); diff --git a/src/player.js b/src/player.js index 506cfd2..4683e17 100644 --- a/src/player.js +++ b/src/player.js @@ -6,6 +6,7 @@ const path = require('path'); const { WebSocketServer, WebSocket } = require('ws'); const common = require('./common'); +// Playlist assembly and revision helpers. async function buildScreenPlaylist(pool, slug) { const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]); if (!screenRows.length) { @@ -147,12 +148,13 @@ function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRo } +// Player runtime, upload API, and websocket wiring. async function start() { const app = express(); const pool = common.createPool(); const PORT = Number(process.env.PLAYER_PORT || 3001); const ASSET_DIR = path.join(__dirname, 'player', 'public'); - const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads'); + const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); const connectionsBySlug = new Map(); const dashboardListenersBySlug = new Map(); const server = http.createServer(app); @@ -160,9 +162,52 @@ async function start() { app.use(express.json()); + // Static assets and mirrored uploads are served from the player container. app.use('/assets', express.static(ASSET_DIR)); app.use('/uploads', express.static(UPLOAD_DIR)); + app.get('/api/uploads/config', function (_req, res) { + res.json({ + uploadDir: UPLOAD_DIR + }); + }); + + app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) { + try { + const filename = path.basename(String(req.params.filename || '').trim()); + if (!filename) { + return res.status(400).json({ error: 'Filename is required' }); + } + const filePath = path.join(UPLOAD_DIR, filename); + const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); + await fs.promises.mkdir(UPLOAD_DIR, { recursive: true }); + await fs.promises.writeFile(filePath, body); + res.json({ ok: true, filename: filename }); + } catch (error) { + next(error); + } + }); + + app.delete('/api/uploads/:filename', async function (req, res, next) { + try { + const filename = path.basename(String(req.params.filename || '').trim()); + if (!filename) { + return res.status(400).json({ error: 'Filename is required' }); + } + const filePath = path.join(UPLOAD_DIR, filename); + try { + await fs.promises.unlink(filePath); + } catch (error) { + if (!error || error.code !== 'ENOENT') { + throw error; + } + } + res.json({ ok: true, filename: filename }); + } catch (error) { + next(error); + } + }); + function getConnectionBucket(slug) { const key = String(slug || '').trim(); if (!key) { @@ -337,6 +382,7 @@ async function start() { res.send('Pulse Signage player service'); }); + // Screen playback endpoints render the active playlist for a slug. app.get('/screen/:slug', function (req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); @@ -439,6 +485,7 @@ async function start() { await common.ensureSchema(pool); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + // Websocket upgrades split dashboard snapshots from player client sessions. server.on('upgrade', function (request, socket, head) { let pathname = ''; try { diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html index 375655f..252bb7e 100644 --- a/src/player/player-page.script.html +++ b/src/player/player-page.script.html @@ -1060,7 +1060,7 @@ function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) { const body = slide.body ? '
' + escapeHtml(slide.body) + '
' : ''; const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas'; - return '
' + escapeHtml(slide.title) + '
' + innerHtml + body + '
'; + return '
' + innerHtml + body + '
'; } // Build the cache key for rendered slide markup. diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index 808c805..33e872e 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -86,18 +86,6 @@ body.screen-blackout #app { border: 0; } -.overlay { - position: absolute; - left: 0; - right: 0; - top: 0; - padding: 14px 18px; - background: rgba(0, 0, 0, 0.35); - font-size: 14px; - display: flex; - justify-content: space-between; -} - .body { position: absolute; left: 5%; diff --git a/src/webui.js b/src/web.js similarity index 81% rename from src/webui.js rename to src/web.js index e6b1fc2..150ddc0 100644 --- a/src/webui.js +++ b/src/web.js @@ -7,7 +7,7 @@ const crypto = require('crypto'); const { WebSocketServer, WebSocket } = require('ws'); const common = require('./common'); const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth'); -const pages = require('./webui/routes'); +const pages = require('./web/routes'); const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, ''); const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws'); @@ -17,6 +17,18 @@ const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX const playerSnapshotCache = new Map(); const playerSnapshotSockets = new Map(); +let playerUploadSyncMode = null; +let playerUploadSyncModePromise = null; +const pendingPlayerUploadSyncs = new Map(); +let pendingPlayerUploadSyncFlushTimer = null; +let pendingPlayerUploadSyncFlushInFlight = null; +const pendingPlaylistUploadSyncs = new Map(); +let pendingPlaylistUploadSyncFlushTimer = null; +let pendingPlaylistUploadSyncFlushInFlight = null; + +function normalizeUploadRoot(uploadDir) { + return path.resolve(String(uploadDir || '').trim()); +} function readArrayField(body, keys) { const searchKeys = Array.isArray(keys) ? keys : [keys]; @@ -60,6 +72,7 @@ function normalizeScheduleMode(value) { } +// Session and auth helpers. function getAuditUserId(req) { return req && req.currentUser ? Number(req.currentUser.id) : null; } @@ -91,6 +104,7 @@ async function fetchPlaylistCanvasSignature(pool, playlistId) { return signatures.length === 1 ? signatures[0] : 'mismatch'; } +// Player snapshot and dashboard helpers. function getPlayerSnapshotSocketUrl(slug) { const url = new URL(PLAYER_WS_BASE_URL); url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`; @@ -178,6 +192,7 @@ function buildClientRows(screens, connectionsBySlug) { }); } +// Player command helpers. async function forwardPlayerCommand(slug, commandOrPayload, connectionId) { const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) @@ -224,6 +239,7 @@ async function notifyPlayerScreens(slugs, commandOrPayload) { }).length; } +// Screen lookup helpers used by the admin routes. async function fetchAllScreenSlugs(connection) { const [rows] = await connection.query('SELECT slug FROM screens WHERE slug IS NOT NULL'); return rows.map(function (row) { @@ -255,6 +271,33 @@ async function fetchScreensBySlideId(connection, slideId) { }); } +async function fetchScreensByTemplateId(connection, templateId) { + const [rows] = await connection.query( + `SELECT DISTINCT s.slug + FROM screens s + JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id + JOIN slides sl ON sl.id = ps.slide_id + WHERE sl.template_id = ? + AND s.slug IS NOT NULL`, + [templateId] + ); + return rows.map(function (row) { + return row.slug; + }); +} + +async function fetchSlideIdsByTemplateId(connection, templateId) { + const [rows] = await connection.query( + 'SELECT id FROM slides WHERE template_id = ?', + [templateId] + ); + return rows.map(function (row) { + return Number(row.id); + }).filter(function (value) { + return Number.isFinite(value); + }); +} + function formatDashboardDate(value) { if (!value) { return ''; @@ -325,6 +368,7 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) { return rows; } +// Upload synchronization and retry helpers. function createUploadMiddleware(uploadDir) { const storage = multer.diskStorage({ destination: function (_req, _file, cb) { @@ -438,9 +482,385 @@ async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { console.warn('Unable to remove unused upload file:', filePath, error); } } + queuePlayerUploadSync({ + type: 'delete', + uploadPath: uploadPath, + uploadDir: uploadDir + }); } } +async function getPlayerUploadSyncMode(localUploadDir) { + if (playerUploadSyncMode) { + return playerUploadSyncMode; + } + if (playerUploadSyncModePromise) { + return playerUploadSyncModePromise; + } + + playerUploadSyncModePromise = (async function () { + try { + const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/config`, { + headers: { + Accept: 'application/json' + } + }); + if (!response.ok) { + return null; + } + const data = await response.json(); + const playerUploadDir = data && data.uploadDir ? normalizeUploadRoot(data.uploadDir) : null; + if (!playerUploadDir) { + return null; + } + return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different'; + } catch (_error) { + return null; + } + })().then(function (mode) { + if (mode) { + playerUploadSyncMode = mode; + } + playerUploadSyncModePromise = null; + return mode; + }, function () { + playerUploadSyncModePromise = null; + return null; + }); + + return playerUploadSyncModePromise; +} + +async function shouldMirrorUploads(localUploadDir) { + return Boolean(localUploadDir); +} + +function queuePlayerUploadSync(operation) { + if (!operation || !operation.uploadPath) { + return; + } + + pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), { + type: operation.type === 'delete' ? 'delete' : 'put', + uploadPath: normalizeUploadReference(operation.uploadPath), + uploadDir: operation.uploadDir || null + }); + + schedulePendingPlayerUploadSyncFlush(); +} + +function schedulePendingPlayerUploadSyncFlush() { + if (pendingPlayerUploadSyncFlushTimer) { + return; + } + + pendingPlayerUploadSyncFlushTimer = setTimeout(function () { + pendingPlayerUploadSyncFlushTimer = null; + flushPendingPlayerUploadSyncs().catch(function (error) { + console.warn('Unable to flush pending upload syncs:', error); + }); + }, 5000); +} + +async function pushUploadFileToPlayer(uploadPath, localUploadDir) { + if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { + return false; + } + + const filename = path.basename(uploadPath); + const sourcePath = path.join(localUploadDir, filename); + let fileBuffer = null; + try { + fileBuffer = await fs.promises.readFile(sourcePath); + } catch (error) { + if (!error || error.code !== 'ENOENT') { + console.warn('Unable to read upload for player sync:', sourcePath, error); + } + return; + } + + try { + const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/${encodeURIComponent(filename)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: fileBuffer + }); + if (!response.ok) { + console.warn('Unable to sync upload to player:', filename, response.status, response.statusText); + return false; + } + return true; + } catch (error) { + console.warn('Unable to sync upload to player:', filename, error); + return false; + } +} + +async function removeUploadFileFromPlayer(uploadPath, localUploadDir) { + if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) { + return false; + } + + const filename = path.basename(uploadPath); + try { + const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/${encodeURIComponent(filename)}`, { + method: 'DELETE', + headers: { + Accept: 'application/json' + } + }); + if (!response.ok && response.status !== 404) { + console.warn('Unable to remove upload from player:', filename, response.status, response.statusText); + return false; + } + return true; + } catch (error) { + console.warn('Unable to remove upload from player:', filename, error); + return false; + } +} + +async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) { + if (!(await shouldMirrorUploads(localUploadDir))) { + return; + } + + const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean))); + for (let i = 0; i < uniqueRefs.length; i += 1) { + const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir); + if (!success) { + queuePlayerUploadSync({ + type: 'put', + uploadPath: uniqueRefs[i], + uploadDir: localUploadDir + }); + } + } +} + +async function syncExistingUploadsToPlayer(pool, localUploadDir) { + if (!(await shouldMirrorUploads(localUploadDir))) { + return; + } + + const data = await common.fetchAdminData(pool); + const uploadRefs = new Set(); + (data.slides || []).forEach(function (slide) { + collectUploadReferencesFromSlide(slide).forEach(function (reference) { + uploadRefs.add(reference); + }); + }); + (data.templates || []).forEach(function (template) { + collectUploadReferencesFromTemplate(template).forEach(function (reference) { + uploadRefs.add(reference); + }); + }); + Array.from(uploadRefs).forEach(function (uploadPath) { + queuePlayerUploadSync({ + type: 'put', + uploadPath: uploadPath, + uploadDir: localUploadDir + }); + }); + await flushPendingPlayerUploadSyncs(); +} + +function getVisibleCurrentSlideIds() { + const visibleSlideIds = new Set(); + playerSnapshotCache.forEach(function (snapshot) { + const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; + connections.forEach(function (connection) { + const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' + ? connection.currentSlide + : null; + const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null + ? String(currentSlide.id).trim() + : ''; + if (slideId) { + visibleSlideIds.add(slideId); + } + }); + }); + return visibleSlideIds; +} + +function isScreenRefreshBlocked(screenSlug, blockedSlideIds) { + const slideIds = Array.isArray(blockedSlideIds) + ? blockedSlideIds.map(function (value) { + return String(value || '').trim(); + }).filter(Boolean) + : []; + if (!slideIds.length) { + return false; + } + const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim()); + const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : []; + return connections.some(function (connection) { + const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object' + ? connection.currentSlide + : null; + const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null + ? String(currentSlide.id).trim() + : ''; + return currentSlideId && slideIds.includes(currentSlideId); + }); +} + +function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds) { + const ready = []; + const blocked = []; + Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) { + const normalizedScreenSlug = String(screenSlug || '').trim(); + if (!normalizedScreenSlug) { + return; + } + if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds)) { + blocked.push(normalizedScreenSlug); + } else { + ready.push(normalizedScreenSlug); + } + }); + return { ready: ready, blocked: blocked }; +} + +function normalizePlaylistUploadSyncOperation(options) { + return { + key: String(options && options.key ? options.key : '').trim(), + pool: options && options.pool ? options.pool : null, + localUploadDir: options && options.localUploadDir ? options.localUploadDir : null, + previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])), + nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])), + blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) { + return String(value || '').trim(); + }).filter(Boolean), + refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) { + return String(value || '').trim(); + }).filter(Boolean) + }; +} + +function queuePlaylistUploadSync(operation) { + if (!operation || !operation.key) { + return; + } + pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation)); + schedulePendingPlaylistUploadSyncFlush(); +} + +function schedulePendingPlaylistUploadSyncFlush() { + if (pendingPlaylistUploadSyncFlushTimer) { + return; + } + + pendingPlaylistUploadSyncFlushTimer = setTimeout(function () { + pendingPlaylistUploadSyncFlushTimer = null; + flushPendingPlaylistUploadSyncs().catch(function (error) { + console.warn('Unable to flush pending playlist upload syncs:', error); + }); + }, 5000); +} + +async function syncPlaylistUploadsOnChange(options) { + const operation = normalizePlaylistUploadSyncOperation(options); + if (!operation.key) { + return; + } + + if (operation.previousUploadRefs.length) { + const nextUploadRefSet = new Set(operation.nextUploadRefs); + await removeUnusedUploadFiles(operation.pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) { + return !nextUploadRefSet.has(reference); + })); + } + + if (operation.nextUploadRefs.length) { + await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir); + } + + if (operation.refreshScreenSlugs.length) { + const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds); + if (refreshTargets.ready.length) { + await notifyPlayerScreens(refreshTargets.ready, 'refresh'); + } + refreshTargets.blocked.forEach(function (screenSlug) { + queuePlaylistUploadSync({ + key: operation.key + ':refresh:' + screenSlug, + blockedSlideIds: operation.blockedSlideIds, + refreshScreenSlugs: [screenSlug] + }); + }); + } +} + +async function flushPendingPlaylistUploadSyncs() { + if (pendingPlaylistUploadSyncFlushInFlight) { + return pendingPlaylistUploadSyncFlushInFlight; + } + + if (!pendingPlaylistUploadSyncs.size) { + return null; + } + + pendingPlaylistUploadSyncFlushInFlight = (async function () { + const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values()); + for (let i = 0; i < pendingEntries.length; i += 1) { + const operation = pendingEntries[i]; + const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds); + if (!refreshTargets.ready.length) { + continue; + } + + if (refreshTargets.ready.length) { + await notifyPlayerScreens(refreshTargets.ready, 'refresh'); + } + + pendingPlaylistUploadSyncs.delete(operation.key); + } + })().finally(function () { + pendingPlaylistUploadSyncFlushInFlight = null; + if (pendingPlaylistUploadSyncs.size) { + schedulePendingPlaylistUploadSyncFlush(); + } + }); + + return pendingPlaylistUploadSyncFlushInFlight; +} + +async function flushPendingPlayerUploadSyncs() { + if (pendingPlayerUploadSyncFlushInFlight) { + return pendingPlayerUploadSyncFlushInFlight; + } + + if (!pendingPlayerUploadSyncs.size) { + return null; + } + + pendingPlayerUploadSyncFlushInFlight = (async function () { + const pendingEntries = Array.from(pendingPlayerUploadSyncs.values()); + for (let i = 0; i < pendingEntries.length; i += 1) { + const operation = pendingEntries[i]; + let success = false; + if (operation.type === 'delete') { + success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir); + } else { + success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir); + } + if (success) { + pendingPlayerUploadSyncs.delete(operation.uploadPath); + } + } + })().finally(function () { + pendingPlayerUploadSyncFlushInFlight = null; + if (pendingPlayerUploadSyncs.size) { + schedulePendingPlayerUploadSyncFlush(); + } + }); + + return pendingPlayerUploadSyncFlushInFlight; +} + function parseCookies(cookieHeader) { return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) { if (!pair) { @@ -521,13 +941,14 @@ function requireAuth(req, res, next) { res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); } +// Admin app routes and websocket wiring. async function start() { const app = express(); const server = http.createServer(app); const pool = common.createPool(); const PORT = Number(process.env.WEB_PORT || 3000); - const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads'); - const ASSET_DIR = path.join(__dirname, 'webui', 'public'); + const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); + const ASSET_DIR = path.join(__dirname, 'web', 'public'); const dashboardWs = new WebSocketServer({ noServer: true }); const dashboardClients = new Set(); const dashboardRefreshIntervalMs = Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000); @@ -599,6 +1020,7 @@ async function start() { app.use('/admin', requireAuth); + // Dashboard and account pages live in the authenticated admin area. app.get('/admin', async function (req, res, next) { try { const data = await buildDashboardState(pool); @@ -1547,6 +1969,12 @@ async function start() { 'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId] ); + await syncPlaylistUploadsOnChange({ + key: 'slide:create:' + result.insertId, + pool: pool, + localUploadDir: UPLOAD_DIR, + nextUploadRefs: collectUploadReferencesFromPayload(payload) + }); res.redirect('/admin/slides/' + result.insertId + '/edit?message=' + encodeURIComponent('Slide created.')); } catch (error) { next(error); @@ -1568,10 +1996,15 @@ async function start() { 'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?', [payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id] ); - await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) { - return !nextUploadRefs.has(reference); - })); - await notifyPlayerScreens(affectedScreens, 'refresh'); + await syncPlaylistUploadsOnChange({ + key: 'slide:update:' + slide.id, + pool: pool, + localUploadDir: UPLOAD_DIR, + previousUploadRefs: existingUploadRefs, + nextUploadRefs: nextUploadRefs, + blockedSlideIds: [slide.id], + refreshScreenSlugs: affectedScreens + }); await broadcastDashboardState(); res.redirect('/admin/slides/' + slide.id + '/edit?message=' + encodeURIComponent('Slide updated.')); } catch (error) { @@ -1588,8 +2021,14 @@ async function start() { const affectedScreens = await fetchScreensBySlideId(pool, slide.id); const uploadRefs = collectUploadReferencesFromSlide(slide); await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]); - await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs)); - await notifyPlayerScreens(affectedScreens, 'refresh'); + await syncPlaylistUploadsOnChange({ + key: 'slide:delete:' + slide.id, + pool: pool, + localUploadDir: UPLOAD_DIR, + previousUploadRefs: uploadRefs, + blockedSlideIds: [slide.id], + refreshScreenSlugs: affectedScreens + }); await broadcastDashboardState(); res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.')); } catch (error) { @@ -1630,6 +2069,12 @@ async function start() { [result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] ); } + await syncPlaylistUploadsOnChange({ + key: 'template:create:' + result.insertId, + pool: pool, + localUploadDir: UPLOAD_DIR, + nextUploadRefs: collectUploadReferencesFromPayload(payload) + }); res.redirect('/admin/templates/' + result.insertId + '/edit?message=' + encodeURIComponent('Template created.')); } catch (error) { next(error); @@ -1658,14 +2103,12 @@ async function start() { const existingUploadRefs = collectUploadReferencesFromTemplate(template); const payload = await common.buildTemplatePayload(pool, req, template); const nextUploadRefs = collectUploadReferencesFromPayload(payload); + const affectedScreens = await fetchScreensByTemplateId(pool, template.id); const actorId = getAuditUserId(req); await pool.query( 'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, modified_by = ? WHERE id = ?', [payload.name, payload.canvasSizeId, payload.backgroundImagePath, actorId, template.id] ); - await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) { - return !nextUploadRefs.has(reference); - })); await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); for (let i = 0; i < payload.regions.length; i += 1) { const region = payload.regions[i]; @@ -1674,6 +2117,14 @@ async function start() { [template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] ); } + await syncPlaylistUploadsOnChange({ + key: 'template:update:' + template.id, + pool: pool, + localUploadDir: UPLOAD_DIR, + previousUploadRefs: existingUploadRefs, + nextUploadRefs: nextUploadRefs + }); + await notifyPlayerScreens(affectedScreens, 'refresh'); res.redirect('/admin/templates/' + template.id + '/edit?message=' + encodeURIComponent('Template updated.')); } catch (error) { next(error); @@ -1687,10 +2138,17 @@ async function start() { return res.status(404).send('Template not found'); } const uploadRefs = collectUploadReferencesFromTemplate(template); + const affectedScreens = await fetchScreensByTemplateId(pool, template.id); await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]); await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]); await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]); - await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs)); + await syncPlaylistUploadsOnChange({ + key: 'template:delete:' + template.id, + pool: pool, + localUploadDir: UPLOAD_DIR, + previousUploadRefs: uploadRefs + }); + await notifyPlayerScreens(affectedScreens, 'refresh'); res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.')); } catch (error) { next(error); @@ -1766,7 +2224,12 @@ async function start() { res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error'); }); + // Ensure schema and mirror uploads before the web service starts handling traffic. await common.ensureSchema(pool); + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + syncExistingUploadsToPlayer(pool, UPLOAD_DIR).catch(function (error) { + console.warn('Unable to sync existing uploads to player:', error); + }); server.on('upgrade', async function (request, socket, head) { let pathname = ''; try { diff --git a/src/webui/public/css/admin.css b/src/web/public/css/admin.css similarity index 98% rename from src/webui/public/css/admin.css rename to src/web/public/css/admin.css index f1aa3bb..750a91f 100644 --- a/src/webui/public/css/admin.css +++ b/src/web/public/css/admin.css @@ -2286,14 +2286,34 @@ table td .actions form { .template-meta-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); - gap: 8px; + gap: 12px; + align-items: stretch; } .template-meta-row > label { - flex: 1 1 0; + display: flex; + flex-direction: column; + gap: 8px; margin-top: 0; } +.template-background-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.template-background-row input[type="file"] { + flex: 1 1 auto; + min-width: 0; +} + +.template-background-row button { + flex: 0 0 auto; + white-space: nowrap; +} + .template-designer-sidebar { display: grid; gap: 8px; @@ -2380,7 +2400,7 @@ table td .actions form { .designer-rect-label { position: absolute; - left: 0; + left: 12px; top: 0; padding: 2px 6px; background: rgba(15, 23, 42, 0.8); @@ -2390,34 +2410,34 @@ table td .actions form { .designer-rect .resize-handle { position: absolute; - width: 12px; - height: 12px; + width: 24px; + height: 24px; background: var(--surface); border: 1px solid var(--border-strong); box-sizing: border-box; } .designer-rect .resize-handle.nw { - left: -7px; - top: -7px; + left: -13px; + top: -13px; cursor: nwse-resize; } .designer-rect .resize-handle.ne { - right: -7px; - top: -7px; + right: -13px; + top: -13px; cursor: nesw-resize; } .designer-rect .resize-handle.sw { - left: -7px; - bottom: -7px; + left: -13px; + bottom: -13px; cursor: nesw-resize; } .designer-rect .resize-handle.se { - right: -7px; - bottom: -7px; + right: -13px; + bottom: -13px; cursor: nwse-resize; } diff --git a/src/webui/public/js/admin-page.js b/src/web/public/js/admin-page.js similarity index 99% rename from src/webui/public/js/admin-page.js rename to src/web/public/js/admin-page.js index 6a1af1c..e028b3f 100644 --- a/src/webui/public/js/admin-page.js +++ b/src/web/public/js/admin-page.js @@ -322,7 +322,7 @@ updateDashboardQuickActions(state); } - window.webuiHandleDashboardState = handleDashboardState; + window.webHandleDashboardState = handleDashboardState; function initConfirmForms() { document.addEventListener('submit', function (event) { diff --git a/src/webui/public/js/compact/editor.header.min.js b/src/web/public/js/compact/editor.header.min.js similarity index 100% rename from src/webui/public/js/compact/editor.header.min.js rename to src/web/public/js/compact/editor.header.min.js diff --git a/src/webui/public/js/compact/editor.list.min.js b/src/web/public/js/compact/editor.list.min.js similarity index 100% rename from src/webui/public/js/compact/editor.list.min.js rename to src/web/public/js/compact/editor.list.min.js diff --git a/src/webui/public/js/compact/editor.marker.min.js b/src/web/public/js/compact/editor.marker.min.js similarity index 100% rename from src/webui/public/js/compact/editor.marker.min.js rename to src/web/public/js/compact/editor.marker.min.js diff --git a/src/webui/public/js/compact/editor.min.js b/src/web/public/js/compact/editor.min.js similarity index 100% rename from src/webui/public/js/compact/editor.min.js rename to src/web/public/js/compact/editor.min.js diff --git a/src/webui/public/js/compact/editor.table.min.js b/src/web/public/js/compact/editor.table.min.js similarity index 100% rename from src/webui/public/js/compact/editor.table.min.js rename to src/web/public/js/compact/editor.table.min.js diff --git a/src/webui/public/js/playlist-schedule.js b/src/web/public/js/playlist-schedule.js similarity index 100% rename from src/webui/public/js/playlist-schedule.js rename to src/web/public/js/playlist-schedule.js diff --git a/src/webui/public/js/slide-form.js b/src/web/public/js/slide-form.js similarity index 100% rename from src/webui/public/js/slide-form.js rename to src/web/public/js/slide-form.js diff --git a/src/webui/public/js/system-status.js b/src/web/public/js/system-status.js similarity index 96% rename from src/webui/public/js/system-status.js rename to src/web/public/js/system-status.js index 808cafa..22c95d0 100644 --- a/src/webui/public/js/system-status.js +++ b/src/web/public/js/system-status.js @@ -64,8 +64,8 @@ socket.onmessage = function (event) { try { var payload = JSON.parse(String(event.data || '{}')); - if (payload && payload.type === 'dashboard-state' && typeof window.webuiHandleDashboardState === 'function') { - window.webuiHandleDashboardState(payload.state); + if (payload && payload.type === 'dashboard-state' && typeof window.webHandleDashboardState === 'function') { + window.webHandleDashboardState(payload.state); updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed'); } } catch (_error) { diff --git a/src/webui/public/js/table-sort.js b/src/web/public/js/table-sort.js similarity index 100% rename from src/webui/public/js/table-sort.js rename to src/web/public/js/table-sort.js diff --git a/src/webui/public/js/template-designer.js b/src/web/public/js/template-designer.js similarity index 97% rename from src/webui/public/js/template-designer.js rename to src/web/public/js/template-designer.js index 11c72b8..60c1d3b 100644 --- a/src/webui/public/js/template-designer.js +++ b/src/web/public/js/template-designer.js @@ -26,6 +26,8 @@ var backgroundInput = document.getElementById('background-image'); var backgroundPreview = document.getElementById('background-preview'); var backgroundEmpty = document.getElementById('background-empty'); + var removeBackgroundButton = document.getElementById('remove-background-image'); + var removeBackgroundFlag = document.getElementById('remove-background-image-flag'); var addTextRegionButton = document.getElementById('add-text-region'); var addImageRegionButton = document.getElementById('add-image-region'); var regionsJsonInput = document.getElementById('regions-json'); @@ -168,6 +170,9 @@ if (!file) { return; } + if (removeBackgroundFlag) { + removeBackgroundFlag.checked = false; + } var reader = new FileReader(); reader.onload = function () { backgroundPreview.src = reader.result; @@ -431,6 +436,15 @@ updateBackgroundPreview(file); } }); + if (removeBackgroundButton && removeBackgroundFlag) { + removeBackgroundButton.addEventListener('click', function () { + removeBackgroundFlag.checked = true; + backgroundInput.value = ''; + backgroundPreview.removeAttribute('src'); + backgroundPreview.style.display = 'none'; + backgroundEmpty.style.display = 'block'; + }); + } canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); }); canvasWidthInput.addEventListener('input', render); canvasHeightInput.addEventListener('input', render); diff --git a/src/webui/public/js/theme-init.js b/src/web/public/js/theme-init.js similarity index 94% rename from src/webui/public/js/theme-init.js rename to src/web/public/js/theme-init.js index 1f71dfc..f867351 100644 --- a/src/webui/public/js/theme-init.js +++ b/src/web/public/js/theme-init.js @@ -1,5 +1,5 @@ (function () { - var storageKey = 'webui-theme'; + var storageKey = 'web-theme'; var theme = 'light'; try { diff --git a/src/webui/public/js/theme-sidebar.js b/src/web/public/js/theme-sidebar.js similarity index 98% rename from src/webui/public/js/theme-sidebar.js rename to src/web/public/js/theme-sidebar.js index 97836a2..c6a8252 100644 --- a/src/webui/public/js/theme-sidebar.js +++ b/src/web/public/js/theme-sidebar.js @@ -1,5 +1,5 @@ (function () { - var THEME_STORAGE_KEY = 'webui-theme'; + var THEME_STORAGE_KEY = 'web-theme'; function getPreferredTheme() { if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { diff --git a/src/webui/public/js/toast.js b/src/web/public/js/toast.js similarity index 100% rename from src/webui/public/js/toast.js rename to src/web/public/js/toast.js diff --git a/src/webui/routes/account/password.js b/src/web/routes/account/password.js similarity index 100% rename from src/webui/routes/account/password.js rename to src/web/routes/account/password.js diff --git a/src/webui/routes/auth/login.js b/src/web/routes/auth/login.js similarity index 100% rename from src/webui/routes/auth/login.js rename to src/web/routes/auth/login.js diff --git a/src/webui/routes/canvas-sizes/add.js b/src/web/routes/canvas-sizes/add.js similarity index 100% rename from src/webui/routes/canvas-sizes/add.js rename to src/web/routes/canvas-sizes/add.js diff --git a/src/webui/routes/canvas-sizes/edit.js b/src/web/routes/canvas-sizes/edit.js similarity index 100% rename from src/webui/routes/canvas-sizes/edit.js rename to src/web/routes/canvas-sizes/edit.js diff --git a/src/webui/routes/canvas-sizes/list.js b/src/web/routes/canvas-sizes/list.js similarity index 100% rename from src/webui/routes/canvas-sizes/list.js rename to src/web/routes/canvas-sizes/list.js diff --git a/src/webui/routes/clients/list.js b/src/web/routes/clients/list.js similarity index 100% rename from src/webui/routes/clients/list.js rename to src/web/routes/clients/list.js diff --git a/src/webui/routes/common.js b/src/web/routes/common.js similarity index 100% rename from src/webui/routes/common.js rename to src/web/routes/common.js diff --git a/src/webui/routes/dashboard/index.js b/src/web/routes/dashboard/index.js similarity index 100% rename from src/webui/routes/dashboard/index.js rename to src/web/routes/dashboard/index.js diff --git a/src/webui/routes/index.js b/src/web/routes/index.js similarity index 100% rename from src/webui/routes/index.js rename to src/web/routes/index.js diff --git a/src/webui/routes/playlists/add.js b/src/web/routes/playlists/add.js similarity index 100% rename from src/webui/routes/playlists/add.js rename to src/web/routes/playlists/add.js diff --git a/src/webui/routes/playlists/edit.js b/src/web/routes/playlists/edit.js similarity index 100% rename from src/webui/routes/playlists/edit.js rename to src/web/routes/playlists/edit.js diff --git a/src/webui/routes/playlists/list.js b/src/web/routes/playlists/list.js similarity index 100% rename from src/webui/routes/playlists/list.js rename to src/web/routes/playlists/list.js diff --git a/src/webui/routes/playlists/slide-config.js b/src/web/routes/playlists/slide-config.js similarity index 100% rename from src/webui/routes/playlists/slide-config.js rename to src/web/routes/playlists/slide-config.js diff --git a/src/webui/routes/screens/add.js b/src/web/routes/screens/add.js similarity index 100% rename from src/webui/routes/screens/add.js rename to src/web/routes/screens/add.js diff --git a/src/webui/routes/screens/edit.js b/src/web/routes/screens/edit.js similarity index 100% rename from src/webui/routes/screens/edit.js rename to src/web/routes/screens/edit.js diff --git a/src/webui/routes/screens/list.js b/src/web/routes/screens/list.js similarity index 100% rename from src/webui/routes/screens/list.js rename to src/web/routes/screens/list.js diff --git a/src/webui/routes/slides/form.js b/src/web/routes/slides/form.js similarity index 100% rename from src/webui/routes/slides/form.js rename to src/web/routes/slides/form.js diff --git a/src/webui/routes/slides/list.js b/src/web/routes/slides/list.js similarity index 100% rename from src/webui/routes/slides/list.js rename to src/web/routes/slides/list.js diff --git a/src/webui/routes/templates/add.js b/src/web/routes/templates/add.js similarity index 100% rename from src/webui/routes/templates/add.js rename to src/web/routes/templates/add.js diff --git a/src/webui/routes/templates/edit.js b/src/web/routes/templates/edit.js similarity index 100% rename from src/webui/routes/templates/edit.js rename to src/web/routes/templates/edit.js diff --git a/src/webui/routes/templates/list.js b/src/web/routes/templates/list.js similarity index 100% rename from src/webui/routes/templates/list.js rename to src/web/routes/templates/list.js diff --git a/src/webui/routes/users/add.js b/src/web/routes/users/add.js similarity index 100% rename from src/webui/routes/users/add.js rename to src/web/routes/users/add.js diff --git a/src/webui/routes/users/edit.js b/src/web/routes/users/edit.js similarity index 100% rename from src/webui/routes/users/edit.js rename to src/web/routes/users/edit.js diff --git a/src/webui/routes/users/list.js b/src/web/routes/users/list.js similarity index 100% rename from src/webui/routes/users/list.js rename to src/web/routes/users/list.js diff --git a/src/webui/view.js b/src/web/view.js similarity index 100% rename from src/webui/view.js rename to src/web/view.js diff --git a/src/webui/views/account/password.hbs b/src/web/views/account/password.hbs similarity index 100% rename from src/webui/views/account/password.hbs rename to src/web/views/account/password.hbs diff --git a/src/webui/views/canvas-sizes/add.hbs b/src/web/views/canvas-sizes/add.hbs similarity index 100% rename from src/webui/views/canvas-sizes/add.hbs rename to src/web/views/canvas-sizes/add.hbs diff --git a/src/webui/views/canvas-sizes/edit.hbs b/src/web/views/canvas-sizes/edit.hbs similarity index 100% rename from src/webui/views/canvas-sizes/edit.hbs rename to src/web/views/canvas-sizes/edit.hbs diff --git a/src/webui/views/canvas-sizes/list.hbs b/src/web/views/canvas-sizes/list.hbs similarity index 100% rename from src/webui/views/canvas-sizes/list.hbs rename to src/web/views/canvas-sizes/list.hbs diff --git a/src/webui/views/clients/list.hbs b/src/web/views/clients/list.hbs similarity index 100% rename from src/webui/views/clients/list.hbs rename to src/web/views/clients/list.hbs diff --git a/src/webui/views/dashboard/index.hbs b/src/web/views/dashboard/index.hbs similarity index 100% rename from src/webui/views/dashboard/index.hbs rename to src/web/views/dashboard/index.hbs diff --git a/src/webui/views/frame-layout.hbs b/src/web/views/frame-layout.hbs similarity index 100% rename from src/webui/views/frame-layout.hbs rename to src/web/views/frame-layout.hbs diff --git a/src/webui/views/layout.hbs b/src/web/views/layout.hbs similarity index 100% rename from src/webui/views/layout.hbs rename to src/web/views/layout.hbs diff --git a/src/webui/views/login.hbs b/src/web/views/login.hbs similarity index 100% rename from src/webui/views/login.hbs rename to src/web/views/login.hbs diff --git a/src/web/views/playlists/add.hbs b/src/web/views/playlists/add.hbs new file mode 100644 index 0000000..faaf970 --- /dev/null +++ b/src/web/views/playlists/add.hbs @@ -0,0 +1,16 @@ + + +
+
+ + +
+
diff --git a/src/webui/views/playlists/edit.hbs b/src/web/views/playlists/edit.hbs similarity index 100% rename from src/webui/views/playlists/edit.hbs rename to src/web/views/playlists/edit.hbs diff --git a/src/webui/views/playlists/list.hbs b/src/web/views/playlists/list.hbs similarity index 100% rename from src/webui/views/playlists/list.hbs rename to src/web/views/playlists/list.hbs diff --git a/src/webui/views/playlists/slide-config.hbs b/src/web/views/playlists/slide-config.hbs similarity index 100% rename from src/webui/views/playlists/slide-config.hbs rename to src/web/views/playlists/slide-config.hbs diff --git a/src/webui/views/screens/add.hbs b/src/web/views/screens/add.hbs similarity index 100% rename from src/webui/views/screens/add.hbs rename to src/web/views/screens/add.hbs diff --git a/src/webui/views/screens/edit.hbs b/src/web/views/screens/edit.hbs similarity index 100% rename from src/webui/views/screens/edit.hbs rename to src/web/views/screens/edit.hbs diff --git a/src/webui/views/screens/list.hbs b/src/web/views/screens/list.hbs similarity index 100% rename from src/webui/views/screens/list.hbs rename to src/web/views/screens/list.hbs diff --git a/src/webui/views/slides/form.hbs b/src/web/views/slides/form.hbs similarity index 100% rename from src/webui/views/slides/form.hbs rename to src/web/views/slides/form.hbs diff --git a/src/webui/views/slides/list.hbs b/src/web/views/slides/list.hbs similarity index 100% rename from src/webui/views/slides/list.hbs rename to src/web/views/slides/list.hbs diff --git a/src/webui/views/templates/add.hbs b/src/web/views/templates/add.hbs similarity index 100% rename from src/webui/views/templates/add.hbs rename to src/web/views/templates/add.hbs diff --git a/src/webui/views/templates/edit.hbs b/src/web/views/templates/edit.hbs similarity index 90% rename from src/webui/views/templates/edit.hbs rename to src/web/views/templates/edit.hbs index 4334609..704633a 100644 --- a/src/webui/views/templates/edit.hbs +++ b/src/web/views/templates/edit.hbs @@ -28,7 +28,11 @@ diff --git a/src/webui/views/templates/list.hbs b/src/web/views/templates/list.hbs similarity index 100% rename from src/webui/views/templates/list.hbs rename to src/web/views/templates/list.hbs diff --git a/src/webui/views/users/add.hbs b/src/web/views/users/add.hbs similarity index 100% rename from src/webui/views/users/add.hbs rename to src/web/views/users/add.hbs diff --git a/src/webui/views/users/edit.hbs b/src/web/views/users/edit.hbs similarity index 100% rename from src/webui/views/users/edit.hbs rename to src/web/views/users/edit.hbs diff --git a/src/webui/views/users/list.hbs b/src/web/views/users/list.hbs similarity index 100% rename from src/webui/views/users/list.hbs rename to src/web/views/users/list.hbs diff --git a/src/webui/views/playlists/add.hbs b/src/webui/views/playlists/add.hbs deleted file mode 100644 index a1aeff6..0000000 --- a/src/webui/views/playlists/add.hbs +++ /dev/null @@ -1,24 +0,0 @@ - - -
-
- -
- Fade: - -
-
Disabled by default.
- -
-