Compare commits

...
7 Commits
Author SHA1 Message Date
lzstealth 9f3fcbdaf2 Refresh favicon and layout assets 2026-07-14 21:10:27 +01:00
lzstealth 31b10e6fd1 Refresh player and admin UI 2026-07-14 20:52:39 +01:00
lzstealth 4e0e87c86c Refactor web server and release workflow 2026-07-14 18:35:24 +01:00
lzstealth 9070ded66d Update README.md
Docker tags need  to be lowercase.
2026-07-14 15:52:15 +01:00
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
311 changed files with 3529 additions and 2042 deletions
-1
View File
@@ -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
+3 -4
View File
@@ -2,8 +2,6 @@ name: Publish Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
@@ -32,9 +30,10 @@ 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
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
- 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*
+2 -2
View File
@@ -3,7 +3,7 @@ FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
@@ -11,4 +11,4 @@ RUN mkdir -p /app/uploads
EXPOSE 3000
CMD ["npm", "run", "start:webui"]
CMD ["npm", "run", "start:web"]
+9 -23
View File
@@ -57,39 +57,25 @@ 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
- `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
```
- `web` - 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, `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`.
If you only want to test the image build from the current checkout without using Compose, run:
The Compose file also defines a shared `uploads` volume for media and a `mysql_data` volume for database persistence.
```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`.
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
+3 -5
View File
@@ -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"]
-1604
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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",
+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;
}
+4 -1
View File
@@ -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.');
+63 -11
View File
@@ -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');
@@ -396,27 +442,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,
@@ -434,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 {
+82 -13
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;
@@ -623,26 +628,87 @@
};
}
// Remove unsafe markup while preserving simple formatting tags.
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
// Remove unsafe markup while preserving richer CKEditor formatting.
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var allowed = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
if (allowed.indexOf(name) === -1) {
var attrText = String(match[3] || '');
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
return '';
}
if (name === 'br') {
return '<br>';
if (closing) {
return '</' + name + '>';
}
return closing ? '</' + name + '>' : '<' + name + '>';
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
});
}
@@ -708,7 +774,7 @@
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="editorjs-table">' + tableRows + '</table>';
return '<table class="ck-content-table">' + tableRows + '</table>';
}
// Render Editor.js JSON or plain content safely.
@@ -951,12 +1017,16 @@
var width = (Number(region.width) / templateCanvas.width) * 100;
var height = (Number(region.height) / templateCanvas.height) * 100;
var baseStyle = 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';';
var pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
var pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
return {
regionKey: region.region_key,
regionType: region.region_type,
label: region.label,
baseStyle: baseStyle,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
fontFamily: region.font_family || null,
fontSize: region.font_size || null,
fontColor: region.font_color || null,
@@ -1010,11 +1080,10 @@
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
}
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var scaledFontSize = Math.max(1, Math.round(fontSize * region.canvasScale));
var style = region.baseStyle + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + scaledFontSize + 'px;color:' + escapeHtml(fontColor) + ';';
return '<div class="template-region text" style="' + style + '">' + renderEditorJsContent(regionContent.value || '') + '</div>';
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(regionContent.value || '') + '</div></div>';
}
};
@@ -1055,7 +1124,7 @@
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';"><div class="overlay"><div>' + escapeHtml(slide.title) + '</div></div>' + innerHtml + body + '</div></div>';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>';
}
// Build the cache key for rendered slide markup.
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TITLE}}</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="stylesheet" href="/assets/css/player.css" />
</head>
<body>
+24 -15
View File
@@ -4,7 +4,7 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
background: #111;
color: #fff;
font-family: Arial, sans-serif;
}
@@ -15,7 +15,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
background: #000;
background: #111;
position: relative;
}
@@ -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%;
@@ -133,7 +121,6 @@ body.screen-blackout #app {
position: relative;
width: 100%;
height: 100%;
background: #111;
}
.template-stage .template-background {
@@ -162,6 +149,28 @@ body.screen-blackout #app {
line-height: 1.35;
}
.template-region.text .template-region-text-scale {
display: block;
transform-origin: top left;
}
.template-region.text .template-region-text-scale > * {
margin: 0;
}
.template-region.text .template-region-text-scale > * + * {
margin-top: 0.5em;
}
.template-region.text .template-region-text-scale ul,
.template-region.text .template-region-text-scale ol {
padding-left: 1.2em;
}
.template-region.text .template-region-text-scale code {
white-space: pre-wrap;
}
.template-region.text > * {
margin: 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

+66 -6
View File
@@ -41,7 +41,66 @@ function sanitizeTextColor(value, fallback) {
return fallback || '#000000';
}
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
@@ -108,19 +167,20 @@ function sanitizeRichText(html) {
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, (tag) => {
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
const attrText = String(match[3] || '');
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return '';
}
if (name === 'br') {
return '<br>';
if (closing) {
return `</${name}>`;
}
return closing ? `</${name}>` : `<${name}>`;
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
});
}
@@ -173,7 +233,7 @@ function renderEditorJsTable(data) {
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="editorjs-table">' + tableRows + '</table>';
return '<table class="ck-content-table">' + tableRows + '</table>';
}
function renderEditorJsContent(value) {
+488 -15
View File
@@ -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);
@@ -1457,8 +1879,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 +1900,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);
@@ -1537,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);
@@ -1558,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) {
@@ -1578,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) {
@@ -1620,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);
@@ -1648,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];
@@ -1664,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);
@@ -1677,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);
@@ -1756,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 {
@@ -431,6 +431,10 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
width: auto;
padding: 0;
justify-content: center;
position: fixed;
top: 16px;
right: 16px;
z-index: 90;
}
.theme-toggle--floating .theme-toggle__label {
@@ -442,13 +446,6 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
height: 18px;
}
.theme-toggle--floating {
position: fixed;
top: 16px;
right: 16px;
z-index: 90;
}
.auth-shell-body .theme-toggle--floating {
top: auto;
bottom: 16px;
@@ -507,9 +504,6 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.status-pill {
color: #0f5132;
background: rgba(16, 185, 129, 0.14);
}
@@ -769,8 +763,6 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
white-space: nowrap;
}
/* Text region controls. */
.template-field-head {
display: flex;
justify-content: space-between;
@@ -779,16 +771,6 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
margin-bottom: 8px;
}
.template-text-style-row {
margin-top: 0;
align-items: flex-end;
}
.template-text-style-row input[type="color"] {
height: 44px;
padding: 4px;
}
.users-table th:last-child {
width: 220px;
}
@@ -915,8 +897,9 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
.stats {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
margin-bottom: 0;
}
.stat {
@@ -924,9 +907,10 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
overflow: hidden;
background: var(--stat-background);
border: 1px solid var(--border);
border-radius: 20px;
padding: 18px;
border-radius: 12px;
padding: 16px;
box-shadow: var(--shadow-sm);
margin-bottom: 0;
}
.stat::after {
@@ -975,6 +959,15 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
letter-spacing: 0.08em;
}
.stat span {
position: relative;
color: var(--muted);
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.row {
display: flex;
gap: 12px;
@@ -1170,6 +1163,19 @@ button.is-blackout {
background: linear-gradient(180deg, #f59e0b, #d97706);
}
.ce-inline-toolbar__buttons button,
.ce-inline-toolbar__actions button {
background: transparent;
color: inherit;
box-shadow: none;
}
.ce-inline-toolbar__buttons button:hover,
.ce-inline-toolbar__actions button:hover {
background: rgba(255, 255, 255, 0.08);
filter: none;
}
a.button-link {
display: inline-flex;
align-items: center;
@@ -1280,7 +1286,7 @@ thead th {
}
tbody tr:hover {
background: var(--table-hover);
background: rgba(37, 99, 235, 0.07);
}
tbody tr:last-child td {
@@ -1718,10 +1724,6 @@ tbody tr:nth-child(even) {
background: var(--table-row-even);
}
tbody tr:hover {
background: rgba(37, 99, 235, 0.07);
}
table input,
table select,
table textarea,
@@ -1850,28 +1852,6 @@ table td .actions form {
color: var(--muted);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 0;
}
.stat {
background: var(--stat-background);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: var(--shadow-sm);
margin-bottom: 0;
}
.stat strong {
display: block;
font-size: 28px;
margin-bottom: 4px;
}
.playlist-item-actions {
flex-wrap: nowrap;
align-items: center;
@@ -1899,7 +1879,7 @@ table td .actions form {
.empty {
color: var(--muted);
padding: 12px 0;
padding: 12px 0 12px 24px;
}
.chip {
@@ -1912,7 +1892,6 @@ table td .actions form {
margin-left: 6px;
}
/* Slide editor pages. */
.slide-editor-top {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
@@ -2022,8 +2001,8 @@ table td .actions form {
}
.slide-preview-text-content {
width: 100%;
height: 100%;
transform-origin: top left;
display: block;
}
.slide-preview-image-region {
@@ -2099,6 +2078,13 @@ table td .actions form {
.slide-schedule-dialog button {
width: auto;
margin: 0;
.theme-toggle--floating .theme-toggle__label {
display: none;
}
.theme-toggle--floating .theme-toggle__icon {
width: 18px;
height: 18px;
}
}
.slide-schedule-dialog button.secondary {
@@ -2201,66 +2187,138 @@ table td .actions form {
font-weight: 700;
}
.rich-editor {
display: grid;
gap: 8px;
}
.rich-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rich-toolbar button {
width: auto;
margin-top: 0;
padding: 8px 10px;
}
.rich-surface {
min-height: 240px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: var(--text);
line-height: 1.4;
overflow: auto;
}
.rich-surface:focus {
outline: 2px solid rgba(37, 99, 235, 0.3);
border-color: var(--primary);
}
.editorjs-holder {
min-height: 240px;
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 16px 12px 12px;
.ckeditor-holder {
min-height: 360px;
background: var(--editor-paper);
color: var(--editor-text);
margin-top: 8px;
}
.editorjs-holder .codex-editor,
.editorjs-holder .codex-editor__redactor,
.editorjs-holder .ce-block,
.editorjs-holder .ce-paragraph,
.editorjs-holder .ce-header,
.editorjs-holder .ce-list,
.editorjs-holder .ce-list__item,
.editorjs-holder [contenteditable],
.editorjs-holder [contenteditable] * {
html[data-theme='dark'] .ckeditor-holder {
color-scheme: dark;
--editor-paper: #111b2d;
--editor-text: #e6eefb;
--ck-color-base-foreground: #111b2d;
--ck-color-base-background: #111b2d;
--ck-color-base-border: rgba(148, 163, 184, 0.22);
--ck-color-base-text: #e6eefb;
--ck-color-text: #e6eefb;
--ck-color-base-active: #60a5fa;
--ck-color-base-active-focus: #93c5fd;
--ck-color-base-focus: #60a5fa;
--ck-color-focus-border: rgba(96, 165, 250, 0.92);
--ck-color-focus-outer-shadow: rgba(96, 165, 250, 0.28);
--ck-color-toolbar-background: #111b2d;
--ck-color-toolbar-border: rgba(148, 163, 184, 0.2);
--ck-color-dropdown-panel-background: #111b2d;
--ck-color-dropdown-panel-border: rgba(148, 163, 184, 0.22);
--ck-color-panel-background: #111b2d;
--ck-color-panel-border: rgba(148, 163, 184, 0.22);
--ck-color-dialog-background: #111b2d;
--ck-color-dialog-form-header-border: rgba(148, 163, 184, 0.18);
--ck-color-input-background: #0e1728;
--ck-color-input-border: rgba(148, 163, 184, 0.22);
--ck-color-input-text: #e6eefb;
--ck-color-list-background: #111b2d;
--ck-color-list-button-hover-background: #162235;
--ck-color-list-button-on-background: #1b2a41;
--ck-color-list-button-on-background-focus: #22324a;
--ck-color-list-button-on-text: #e6eefb;
--ck-color-button-default-hover-background: #162235;
--ck-color-button-default-active-background: #1b2a41;
--ck-color-button-on-background: #1b2a41;
--ck-color-button-on-hover-background: #22324a;
--ck-color-button-on-active-background: #22324a;
--ck-color-button-on-color: #93c5fd;
--ck-color-button-on-disabled-background: #162235;
--ck-color-button-action-background: #2563eb;
--ck-color-button-action-hover-background: #1d4ed8;
--ck-color-button-action-active-background: #1d4ed8;
--ck-color-button-action-disabled-background: #3b82f6;
--ck-color-button-action-text: #ffffff;
--ck-color-switch-button-off-background: #475569;
--ck-color-switch-button-off-hover-background: #64748b;
--ck-color-switch-button-inner-background: #111b2d;
--ck-color-switch-button-inner-shadow: rgba(2, 6, 23, 0.45);
--ck-color-engine-placeholder-text: #94a3b8;
--ck-powered-by-background: #111b2d;
--ck-powered-by-text-color: #cbd5e1;
--ck-evaluation-badge-background: #111b2d;
--ck-evaluation-badge-text-color: #cbd5e1;
}
.ckeditor-holder .ckeditor-source {
display: none;
}
.ckeditor-holder .ck-editor,
.ckeditor-holder .ck-editor__main,
.ckeditor-holder .ck-editor__editable,
.ckeditor-holder .ck-content,
.ckeditor-holder .ck-editor__editable * {
color: var(--editor-text);
}
.editorjs-holder [contenteditable] {
.ckeditor-holder .ck button,
.ckeditor-holder .ck button:hover,
.ckeditor-holder .ck button:active,
.ckeditor-holder .ck button:focus,
.ckeditor-holder .ck button:focus-visible {
box-shadow: none;
}
.ckeditor-holder .ck-font-size-input,
.ckeditor-holder .ck-font-size-input.ck-input-text,
.ckeditor-holder .ck-font-size-input.ck-input-number,
.ckeditor-holder .ck-font-size-input .ck-input__field,
.ckeditor-holder .ck-font-size-input input {
width: 72px !important;
min-width: 72px !important;
max-width: 72px !important;
flex: 0 0 72px !important;
box-sizing: border-box;
}
.ckeditor-holder .ck-font-size-input,
.ckeditor-holder .ck-font-size-input .ck-input__field,
.ckeditor-holder .ck-font-size-input input {
text-align: center;
}
.ckeditor-holder .ck-font-size-input input::-webkit-outer-spin-button,
.ckeditor-holder .ck-font-size-input input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ckeditor-holder .ck-font-size-input input[type='number'] {
appearance: textfield;
-moz-appearance: textfield;
}
.ckeditor-holder .ck-content {
font-family: Arial, Helvetica, sans-serif;
font-size: 16px;
line-height: 1.5;
letter-spacing: normal;
text-transform: none;
}
.ckeditor-holder .ck-editor__editable {
caret-color: var(--editor-text);
}
.editorjs-holder .codex-editor__redactor [contenteditable]:empty:after {
.ckeditor-holder .ck-editor__editable.ck-focused {
border-color: var(--primary);
box-shadow: none;
}
.ckeditor-holder .ck-editor__editable:not(.ck-editor__nested-editable) {
min-height: 360px;
}
.ckeditor-holder .ck-placeholder:before {
color: rgba(0, 0, 0, 0.45);
}
@@ -2286,14 +2344,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 +2458,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 +2468,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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

@@ -322,7 +322,7 @@
updateDashboardQuickActions(state);
}
window.webuiHandleDashboardState = handleDashboardState;
window.webHandleDashboardState = handleDashboardState;
function initConfirmForms() {
document.addEventListener('submit', function (event) {
@@ -0,0 +1,55 @@
Software License Agreement
==========================
**CKEditor&nbsp;5** (https://github.com/ckeditor/ckeditor5)<br>
Copyright (c) 20032026, [CKSource Holding sp. z o.o.](https://cksource.com) All rights reserved.
Licensed under a dual-license model, this software is available under:
* the [GNU General Public License Version 2 or later](https://www.gnu.org/licenses/gpl.html) (see COPYING.GPL),
* or commercial license terms from CKSource Holding sp. z o.o.
For more information, see: [https://ckeditor.com/legal/ckeditor-licensing-options](https://ckeditor.com/legal/ckeditor-licensing-options).
If you are using CKEditor under commercial terms, you are free to remove the COPYING.GPL file with the full copy of a GPL license.
Sources of Intellectual Property Included in CKEditor&nbsp;5
------------------------------------------------------------
Where not otherwise indicated, all CKEditor&nbsp;5 content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor&nbsp;5 will incorporate work done by developers outside of CKSource with their express permission.
The following libraries are included in CKEditor&nbsp;5 under the [ISC license](https://opensource.org/licenses/ISC):
* hast-util-from-dom - Copyright (c) Keith McKnight <keith@mcknig.ht>.
* rehype-dom-parse - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>.
* rehype-dom-stringify - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>.
The following libraries are included in CKEditor&nbsp;5 under the [MIT license](https://opensource.org/licenses/MIT):
* @types/color-convert - Copyright (c) Microsoft Corporation.
* @types/hast - Copyright (c) Microsoft Corporation.
* blurhash - Copyright (c) 2018 Wolt Enterprises.
* color-convert - Copyright (c) 2011-2016 Heather Arthur <fayearthur@gmail.com> and Copyright (c) 2016-2021 Josh Junon <josh@junon.me>.
* color-parse - Copyright (c) 2015 Dmitry Ivanov.
* emojibase-data - Copyright (c) 2017-2019 Miles Johnson.
* es-toolkit - Copyright (c) 2024 Viva Republica, Inc and Copyright OpenJS Foundation and other contributors.
* fuzzysort - Copyright (c) 2018 Stephen Kamenar.
* hast-util-to-html - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* hast-util-to-mdast - Copyright (c) Titus Wormer <tituswormer@gmail.com> and Copyright (c) Seth Vincent <sethvincent@gmail.com>.
* hastscript - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* is-emoji-supported - Copyright (c) 2016-2020 Koala Interactive, Inc.
* Regular expression for URL validation - Copyright (c) 2010-2018 Diego Perini.
* rehype-remark - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* remark-breaks - Copyright (c) 2017 Titus Wormer <tituswormer@gmail.com>.
* remark-gfm - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* remark-parse - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>.
* remark-rehype - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* remark-stringify - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>.
* unified - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>.
* unist-util-visit - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>.
* vanilla-colorful - Copyright (c) 2020 Serhii Kulykov <iamkulykov@gmail.com>.
Trademarks
----------
**CKEditor** is a trademark of [CKSource Holding sp. z o.o.](https://cksource.com) All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More