Compare commits

...
3 Commits
Author SHA1 Message Date
lzstealth e65a43b13c Handle player redirects on screen slug change 2026-07-14 15:30:05 +01:00
lzstealth 440b8683f7 Make screen slugs editable 2026-07-14 14:49:08 +01:00
lzstealth 1043f26b3e Document tag-based Docker publishing 2026-07-14 14:40:47 +01:00
10 changed files with 58 additions and 43 deletions
+1 -4
View File
@@ -2,8 +2,6 @@ name: Publish Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
@@ -32,9 +30,8 @@ jobs:
with:
images: git.lzstealth.com/LZStealth/pulse-signage
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest
type=ref,event=tag
type=sha
- name: Build and push image
uses: docker/build-push-action@v6
+1
View File
@@ -1,5 +1,6 @@
node_modules/
uploads/
docker-compose.dev.yml
.env
npm-debug.log*
yarn-debug.log*
+7 -23
View File
@@ -63,33 +63,17 @@ The app reads its settings from environment variables.
- `PLAYER_PORT` - player port, default `3001`
## Using Docker
## Docker Compose
The recommended way to run the project is with Docker Compose.
The repository includes a `docker-compose.yml` file that starts three services:
```bash
npm run docker:up
```
- `webui` - the admin app on port `3000`
- `player` - the signage player on port `3001`
- `mysql` - the database on port `3306`
The main Compose file now uses the published Docker image and still reads values from the repository `.env` file. `DB_HOST` comes from that file for the app containers, so you can point the web UI and player at an external database host or change it to `mysql` if you want to use the bundled MySQL service.
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`.
If you only want to test the image build from the current checkout without using Compose, run:
```bash
npm run docker:build
```
To publish the Docker image to the Gitea container registry, push to `main` or create a `v*` tag. The workflow in [.gitea/workflows/docker-publish.yml](.gitea/workflows/docker-publish.yml) builds `git.lzstealth.com/LZStealth/pulse-signage` and pushes `latest`, `sha`, and tagged releases.
This starts:
- the web admin app on port `3000`
- the player app on port `3001`
- MySQL on port `3306`
The web app and player app share the same uploaded media volume, so files uploaded in the admin UI are available to the player.
If you want to stop the stack later, run `npm run docker:down`. To follow logs, run `npm run docker:logs`.
The Compose file also defines a shared `uploads` volume for media and a `mysql_data` volume for database persistence.
## Important Notes
+8 -2
View File
@@ -7,12 +7,18 @@ function slugify(value) {
.replace(/-{2,}/g, '-');
}
async function uniqueScreenSlug(pool, baseSlug) {
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
const start = baseSlug || `screen-${Date.now()}`;
let candidate = start;
let counter = 2;
while (true) {
const [rows] = await pool.query('SELECT id FROM screens WHERE slug = ?', [candidate]);
const params = [candidate];
let sql = 'SELECT id FROM screens WHERE slug = ?';
if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?';
params.push(excludeId);
}
const [rows] = await pool.query(sql, params);
if (!rows.length) {
return candidate;
}
+15 -10
View File
@@ -396,27 +396,32 @@ async function start() {
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (['refresh', 'reload', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' });
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
const isRedirectCommand = command === 'redirect';
let screenRows = [];
if (!isRedirectCommand) {
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
}
const commandPayload = command === 'blackout' && blackoutValue !== undefined
? {
command: command,
blackout: blackoutValue
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: command;
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
const sent = connectionId
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: broadcastCommand(req.params.slug, commandPayload);
res.json({
screen: screenRows[0],
screen: screenRows[0] || null,
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
+5
View File
@@ -488,6 +488,11 @@
case 'refresh':
refresh();
return;
case 'redirect':
if (payload.url) {
window.location.replace(String(payload.url));
}
return;
case 'pause':
setPaused(!isPaused);
return;
+12 -2
View File
@@ -1457,8 +1457,9 @@ async function start() {
if (!name) {
return res.status(400).send('Screen name is required.');
}
const slugInput = String(req.body.slug || '').trim();
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
const slug = await common.uniqueScreenSlug(pool, common.slugify(name));
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
res.redirect('/admin/screens?message=' + encodeURIComponent('Screen created.'));
@@ -1477,8 +1478,17 @@ async function start() {
if (!screen) {
return res.status(404).send('Screen not found');
}
const slugInput = String(req.body.slug || '').trim();
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
await pool.query('UPDATE screens SET name = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, playlistId, getAuditUserId(req), screen.id]);
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
const previousSlug = String(screen.slug || '').trim();
await pool.query('UPDATE screens SET name = ?, slug = ?, playlist_id = ?, modified_by = ? WHERE id = ?', [name, slug, playlistId, getAuditUserId(req), screen.id]);
if (previousSlug && previousSlug !== slug) {
await forwardPlayerCommand(previousSlug, {
command: 'redirect',
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
});
}
res.redirect('/admin/screens?edit=' + screen.id + '&message=' + encodeURIComponent('Screen updated.'));
} catch (error) {
next(error);
+1
View File
@@ -538,6 +538,7 @@
}
showToast(savedMessage || 'Saved slide.');
window.location.href = response.url || slideForm.action;
} finally {
submitting = false;
}
+4 -1
View File
@@ -7,10 +7,13 @@
</div>
<div class="card">
<form method="post" action="/admin/screens" data-async-save>
<form method="post" action="/admin/screens" data-async-save data-async-save-reload>
<label>Name
<input name="name" placeholder="Front desk TV" required />
</label>
<label>Slug
<input name="slug" placeholder="front-desk-tv" />
</label>
<label>Playlist
<select name="playlist_id">
<option value="">-- not assigned --</option>
+4 -1
View File
@@ -8,10 +8,13 @@
<div class="card">
<h3>Edit screen</h3>
<form id="screen-edit-form" class="screen-edit-form" method="post" action="/admin/screens/{{screen.id}}" data-async-save>
<form id="screen-edit-form" class="screen-edit-form" method="post" action="/admin/screens/{{screen.id}}" data-async-save data-async-save-reload>
<label>Name
<input name="name" value="{{screen.name}}" required />
</label>
<label>Slug
<input name="slug" value="{{screen.slug}}" />
</label>
<label>Playlist
<select name="playlist_id">
<option value="">-- not assigned --</option>