mirror of
https://github.com/Spengreb/sync.git
synced 2026-06-09 23:02:05 +00:00
Add many UX improvements to channel schedule
This commit is contained in:
parent
4ec1e83337
commit
dd1bf9d55b
11 changed files with 571 additions and 31 deletions
|
|
@ -163,6 +163,64 @@ async function upsertExternalEvent({ channelId, showId, integrationId, provider,
|
|||
});
|
||||
}
|
||||
|
||||
function buildGoogleCalendarUrl(calendarId) {
|
||||
return `https://calendar.google.com/calendar/u/0/r?cid=${encodeURIComponent(calendarId)}`;
|
||||
}
|
||||
|
||||
function buildGoogleEventUrl(eventId, calendarId) {
|
||||
const raw = `${eventId} ${calendarId}`;
|
||||
const eid = Buffer.from(raw, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
return `https://calendar.google.com/calendar/u/0/r/eventedit/${eid}`;
|
||||
}
|
||||
|
||||
async function getGoogleLinksForShows(channelId, showIds) {
|
||||
if (!Array.isArray(showIds) || showIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const integration = await getByChannelProvider(channelId, 'google');
|
||||
if (!integration || integration.status !== 'connected' || !integration.last_sync_at) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const calendarId = integration.config && integration.config.calendar_id
|
||||
? String(integration.config.calendar_id).trim()
|
||||
: '';
|
||||
if (!calendarId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rows = await knex()('channel_show_external_events')
|
||||
.where({
|
||||
channel_id: channelId,
|
||||
integration_id: integration.id,
|
||||
provider: 'google'
|
||||
})
|
||||
.whereIn('show_id', showIds)
|
||||
.select('show_id', 'external_event_id');
|
||||
|
||||
const calendarUrl = buildGoogleCalendarUrl(calendarId);
|
||||
const links = {};
|
||||
showIds.forEach(showId => {
|
||||
links[showId] = { calendar_url: calendarUrl };
|
||||
});
|
||||
|
||||
rows.forEach(row => {
|
||||
if (!links[row.show_id]) {
|
||||
links[row.show_id] = { calendar_url: calendarUrl };
|
||||
}
|
||||
if (row.external_event_id) {
|
||||
links[row.show_id].event_url = buildGoogleEventUrl(row.external_event_id, calendarId);
|
||||
}
|
||||
});
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listByChannel,
|
||||
getByChannelProvider,
|
||||
|
|
@ -170,5 +228,6 @@ module.exports = {
|
|||
disconnectIntegration,
|
||||
updateIntegrationSyncResult,
|
||||
getExternalEvent,
|
||||
upsertExternalEvent
|
||||
upsertExternalEvent,
|
||||
getGoogleLinksForShows
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ function parseShowRow(row) {
|
|||
playlist,
|
||||
timezone: row.timezone,
|
||||
scheduled_for: row.scheduled_for,
|
||||
estimated_end_at: row.estimated_end_at || null,
|
||||
next_run_at: row.next_run_at,
|
||||
status: row.status,
|
||||
recurrence: row.recurrence,
|
||||
|
|
@ -56,6 +57,7 @@ function serializeShowInput(input) {
|
|||
playlist: JSON.stringify(input.playlist || []),
|
||||
timezone: input.timezone,
|
||||
scheduled_for: input.scheduled_for,
|
||||
estimated_end_at: input.estimated_end_at || null,
|
||||
next_run_at: input.next_run_at,
|
||||
status: input.status,
|
||||
recurrence: input.recurrence,
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ export async function initTables() {
|
|||
t.specificType('playlist', 'mediumtext character set utf8mb4 not null');
|
||||
t.string('timezone', 64).notNullable().defaultTo('UTC');
|
||||
t.bigInteger('scheduled_for').notNullable();
|
||||
t.bigInteger('estimated_end_at').nullable();
|
||||
t.bigInteger('next_run_at').notNullable();
|
||||
t.string('status', 20).notNullable().defaultTo('draft');
|
||||
t.string('recurrence', 20).notNullable().defaultTo('none');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Promise from 'bluebird';
|
|||
|
||||
const LOGGER = require('@calzoneman/jsli')('database/update');
|
||||
|
||||
const DB_VERSION = 15;
|
||||
const DB_VERSION = 16;
|
||||
var hasUpdates = [];
|
||||
|
||||
module.exports.checkVersion = function () {
|
||||
|
|
@ -59,6 +59,8 @@ function update(version, cb) {
|
|||
addCalendarIntegrationTables(cb);
|
||||
} else if (version < 15) {
|
||||
addCalendarIntegrationAuditColumns(cb);
|
||||
} else if (version < 16) {
|
||||
addShowsEstimatedEndColumn(cb);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,3 +260,17 @@ function addCalendarIntegrationAuditColumns(cb) {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
function addShowsEstimatedEndColumn(cb) {
|
||||
db.query(
|
||||
"ALTER TABLE channel_shows ADD COLUMN estimated_end_at BIGINT NULL",
|
||||
error => {
|
||||
if (error) {
|
||||
LOGGER.error(`Failed to add shows estimated_end_at column: ${error}`);
|
||||
cb(error);
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,7 +205,8 @@ function unpackTokens(integrationRow) {
|
|||
|
||||
async function upsertGoogleCalendarEvent(accessToken, calendarId, show) {
|
||||
const start = new Date(show.scheduled_for);
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
const endMs = Number(show.estimated_end_at || 0);
|
||||
const end = new Date(endMs > start.getTime() ? endMs : (start.getTime() + 60 * 60 * 1000));
|
||||
const body = {
|
||||
summary: show.name,
|
||||
description: show.notes || '',
|
||||
|
|
@ -230,7 +231,8 @@ async function upsertGoogleCalendarEvent(accessToken, calendarId, show) {
|
|||
|
||||
async function updateGoogleCalendarEvent(accessToken, calendarId, eventId, show) {
|
||||
const start = new Date(show.scheduled_for);
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
const endMs = Number(show.estimated_end_at || 0);
|
||||
const end = new Date(endMs > start.getTime() ? endMs : (start.getTime() + 60 * 60 * 1000));
|
||||
const body = {
|
||||
summary: show.name,
|
||||
description: show.notes || '',
|
||||
|
|
|
|||
89
src/util/markdown.js
Normal file
89
src/util/markdown.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
const XSS = require('../xss');
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function applyInlineMarkdown(text) {
|
||||
let out = escapeHtml(text);
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
out = out.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, '<img src="$2" alt="$1">');
|
||||
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
out = out.replace(/_([^_]+)_/g, '<em>$1</em>');
|
||||
return out;
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown) {
|
||||
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
let paragraph = [];
|
||||
let listItems = [];
|
||||
|
||||
function flushParagraph() {
|
||||
if (!paragraph.length) return;
|
||||
const rendered = applyInlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>');
|
||||
blocks.push('<p>' + rendered + '</p>');
|
||||
paragraph = [];
|
||||
}
|
||||
|
||||
function flushList() {
|
||||
if (!listItems.length) return;
|
||||
blocks.push('<ul>' + listItems.map(item => '<li>' + applyInlineMarkdown(item) + '</li>').join('') + '</ul>');
|
||||
listItems = [];
|
||||
}
|
||||
|
||||
lines.forEach(raw => {
|
||||
const line = raw.trim();
|
||||
if (!line) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
flushParagraph();
|
||||
listItems.push(line.replace(/^[-*]\s+/, ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^#{1,3}\s+/.test(line)) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = line.match(/^#{1,3}/)[0].length;
|
||||
const content = line.replace(/^#{1,3}\s+/, '');
|
||||
blocks.push('<h' + level + '>' + applyInlineMarkdown(content) + '</h' + level + '>');
|
||||
return;
|
||||
}
|
||||
|
||||
paragraph.push(raw);
|
||||
});
|
||||
|
||||
flushParagraph();
|
||||
flushList();
|
||||
|
||||
return blocks.join('');
|
||||
}
|
||||
|
||||
function looksLikeHtml(input) {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(input || '');
|
||||
}
|
||||
|
||||
function renderNotesHtml(input) {
|
||||
const notes = String(input || '').trim();
|
||||
if (!notes) return null;
|
||||
|
||||
const html = looksLikeHtml(notes) ? notes : markdownToHtml(notes);
|
||||
return XSS.sanitizeHTML(html);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderNotesHtml
|
||||
};
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
const express = require('express');
|
||||
const webserver = require('../../webserver');
|
||||
const showDB = require('../../../database/shows');
|
||||
const calendarDB = require('../../../database/calendar-integrations');
|
||||
const shows = require('../../../shows');
|
||||
const botDB = require('../../../database/bots');
|
||||
const infoGetter = require('../../../get-info');
|
||||
const XSS = require('../../../xss');
|
||||
const { renderNotesHtml } = require('../../../util/markdown');
|
||||
const { getChannelRow, getUserEffectiveRank, hashToken } = require('./middleware');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
|
@ -22,6 +23,25 @@ const ACTION_MIN_RANK = {
|
|||
};
|
||||
const PUBLIC_SHOW_STATUSES = new Set(['scheduled', 'running', 'paused', 'completed']);
|
||||
|
||||
async function attachGoogleCalendarLinks(channelId, showsList) {
|
||||
const ids = (showsList || []).map(show => show.id);
|
||||
const linksByShowId = await calendarDB.getGoogleLinksForShows(channelId, ids);
|
||||
return (showsList || []).map(show => {
|
||||
const withNotesHtml = Object.assign({}, show, {
|
||||
notes_html: renderNotesHtml(show.notes)
|
||||
});
|
||||
const googleLinks = linksByShowId[show.id];
|
||||
if (!googleLinks) {
|
||||
return withNotesHtml;
|
||||
}
|
||||
return Object.assign({}, withNotesHtml, {
|
||||
calendar_links: {
|
||||
google: googleLinks
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizePlaylist(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
|
|
@ -69,6 +89,20 @@ function validateShowPayload(body, old = null) {
|
|||
if (!scheduledFor) {
|
||||
return { error: 'scheduled_for must be a valid date or timestamp' };
|
||||
}
|
||||
const estimatedEndInput = body.estimated_end_at !== undefined
|
||||
? body.estimated_end_at
|
||||
: (old ? old.estimated_end_at : null);
|
||||
const estimatedEndAt = estimatedEndInput === null || estimatedEndInput === ''
|
||||
? null
|
||||
: (typeof estimatedEndInput === 'number' ? estimatedEndInput : parseSchedule(estimatedEndInput));
|
||||
if (estimatedEndAt !== null) {
|
||||
if (!estimatedEndAt) {
|
||||
return { error: 'estimated_end_at must be a valid date or timestamp' };
|
||||
}
|
||||
if (estimatedEndAt < scheduledFor) {
|
||||
return { error: 'estimated_end_at must be later than scheduled_for' };
|
||||
}
|
||||
}
|
||||
|
||||
const recurrence = String(body.recurrence || (old && old.recurrence) || 'none');
|
||||
if (!RECURRENCES.has(recurrence)) {
|
||||
|
|
@ -105,7 +139,7 @@ function validateShowPayload(body, old = null) {
|
|||
const notesRaw = body.notes !== undefined ? body.notes : (old ? old.notes : null);
|
||||
let notes = null;
|
||||
if (typeof notesRaw === 'string' && notesRaw.trim() !== '') {
|
||||
notes = XSS.sanitizeHTML(notesRaw.substring(0, 20000));
|
||||
notes = notesRaw.substring(0, 20000).trim();
|
||||
}
|
||||
|
||||
const colorRaw = body.color !== undefined ? body.color : (old ? old.color : null);
|
||||
|
|
@ -126,6 +160,7 @@ function validateShowPayload(body, old = null) {
|
|||
playlist,
|
||||
timezone,
|
||||
scheduled_for: scheduledFor,
|
||||
estimated_end_at: estimatedEndAt,
|
||||
next_run_at: nextRunAt,
|
||||
status,
|
||||
recurrence,
|
||||
|
|
@ -198,7 +233,8 @@ router.get('/', async (req, res) => {
|
|||
if (!auth) return;
|
||||
|
||||
const showsList = await showDB.listShows(auth.channelRow.id);
|
||||
res.json(showsList);
|
||||
const withLinks = await attachGoogleCalendarLinks(auth.channelRow.id, showsList);
|
||||
res.json(withLinks);
|
||||
});
|
||||
|
||||
router.get('/public', async (req, res) => {
|
||||
|
|
@ -210,7 +246,9 @@ router.get('/public', async (req, res) => {
|
|||
}
|
||||
|
||||
const showsList = await showDB.listShows(channelRow.id);
|
||||
res.json(showsList.filter(show => PUBLIC_SHOW_STATUSES.has(show.status)));
|
||||
const filtered = showsList.filter(show => PUBLIC_SHOW_STATUSES.has(show.status));
|
||||
const withLinks = await attachGoogleCalendarLinks(channelRow.id, filtered);
|
||||
res.json(withLinks);
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
|
|
@ -221,7 +259,9 @@ router.get('/:id', async (req, res) => {
|
|||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid show id' });
|
||||
const show = await showDB.getShowById(id, auth.channelRow.id);
|
||||
if (!show) return res.status(404).json({ error: 'Show not found' });
|
||||
res.json(show);
|
||||
res.json(Object.assign({}, show, {
|
||||
notes_html: renderNotesHtml(show.notes)
|
||||
}));
|
||||
});
|
||||
|
||||
router.post('/resolve-media', async (req, res) => {
|
||||
|
|
@ -278,7 +318,9 @@ router.post('/', async (req, res) => {
|
|||
});
|
||||
|
||||
const row = await showDB.getShowById(id, auth.channelRow.id);
|
||||
res.status(201).json(row);
|
||||
res.status(201).json(Object.assign({}, row, {
|
||||
notes_html: renderNotesHtml(row.notes)
|
||||
}));
|
||||
});
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
|
|
@ -304,7 +346,9 @@ router.put('/:id', async (req, res) => {
|
|||
});
|
||||
|
||||
const row = await showDB.getShowById(id, auth.channelRow.id);
|
||||
res.json(row);
|
||||
res.json(Object.assign({}, row, {
|
||||
notes_html: renderNotesHtml(row.notes)
|
||||
}));
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
|
|
@ -394,7 +438,9 @@ router.post('/:id/action', async (req, res) => {
|
|||
}
|
||||
|
||||
const row = await showDB.getShowById(id, auth.channelRow.id);
|
||||
res.json(row);
|
||||
res.json(Object.assign({}, row, {
|
||||
notes_html: renderNotesHtml(row.notes)
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -272,10 +272,18 @@ html(lang="en")
|
|||
strong Time:
|
||||
|
|
||||
span#showdetails-time
|
||||
p
|
||||
strong Estimated End:
|
||||
|
|
||||
span#showdetails-estimated-end
|
||||
p
|
||||
strong Status:
|
||||
|
|
||||
span#showdetails-status
|
||||
p#showdetails-calendar-links(style="display:none;")
|
||||
strong Calendar:
|
||||
|
|
||||
span#showdetails-calendar-links-content
|
||||
hr
|
||||
#showdetails-notes
|
||||
.modal-footer
|
||||
|
|
|
|||
|
|
@ -267,6 +267,10 @@ mixin shows
|
|||
label.control-label.col-sm-3(for="cs-shows-scheduled-for") Scheduled For
|
||||
.col-sm-9
|
||||
input#cs-shows-scheduled-for.form-control(type="datetime-local")
|
||||
.form-group
|
||||
label.control-label.col-sm-3(for="cs-shows-estimated-end-at") Estimated End
|
||||
.col-sm-9
|
||||
input#cs-shows-estimated-end-at.form-control(type="datetime-local")
|
||||
.form-group
|
||||
label.control-label.col-sm-3(for="cs-shows-color") Color
|
||||
.col-sm-3
|
||||
|
|
@ -305,8 +309,10 @@ mixin shows
|
|||
.form-group
|
||||
label.control-label.col-sm-3(for="cs-shows-notes") Notes
|
||||
.col-sm-9
|
||||
textarea#cs-shows-notes.form-control(rows="6", placeholder="Optional rich notes (same HTML support as MOTD)")
|
||||
p.text-muted.small(style="margin-top:6px") Supports MOTD-style HTML. Optional.
|
||||
button#cs-shows-notes-toggle.btn.btn-xs.btn-default.pull-right(type="button", style="margin-bottom:6px") Preview
|
||||
textarea#cs-shows-notes.form-control(rows="8", placeholder="Optional Markdown notes")
|
||||
#cs-shows-notes-rendered.form-control(style="display:none; height:auto; min-height:170px; overflow:auto;")
|
||||
p#cs-shows-notes-help.text-muted.small(style="margin-top:6px") Supports Markdown: headings, lists, bold, italics, inline code, images, and links.
|
||||
.form-group
|
||||
label.control-label.col-sm-3(for="cs-shows-mediaurl") Show Playlist
|
||||
.col-sm-9
|
||||
|
|
@ -329,8 +335,10 @@ mixin shows
|
|||
th Name
|
||||
th Status
|
||||
th Next Run
|
||||
th Est. End
|
||||
th Timezone
|
||||
th Recurrence
|
||||
th Calendar
|
||||
th Actions
|
||||
tbody#cs-shows-list
|
||||
|
||||
|
|
|
|||
|
|
@ -242,6 +242,11 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry {
|
|||
padding: 2px !important;
|
||||
}
|
||||
|
||||
.showschedule-block-cell {
|
||||
border-top: 2px solid rgba(0, 0, 0, 0.35) !important;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.35) !important;
|
||||
}
|
||||
|
||||
.showschedule-cell.showschedule-admin {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -258,6 +263,40 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.showschedule-block-cell .showschedule-show {
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.showschedule-notes {
|
||||
margin-top: 2px;
|
||||
max-height: 72px;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.showschedule-notes p,
|
||||
.showschedule-notes ul,
|
||||
.showschedule-notes ol {
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.showschedule-notes img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 42px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 2px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.showschedule-notes a {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.showschedule-show.status-scheduled { background: #337ab7; }
|
||||
.showschedule-show.status-running { background: #5cb85c; }
|
||||
.showschedule-show.status-paused { background: #f0ad4e; color: #222; }
|
||||
|
|
|
|||
304
www/js/ui.js
304
www/js/ui.js
|
|
@ -1436,6 +1436,7 @@ var CSTShows = (function () {
|
|||
var resolvingTitles = false;
|
||||
var weekOffset = 0;
|
||||
var cachedShows = [];
|
||||
var notesEditorMode = 'edit';
|
||||
|
||||
function apiBase() {
|
||||
return '/api/v1/channels/' + CHANNEL.name + '/shows';
|
||||
|
|
@ -1487,6 +1488,121 @@ var CSTShows = (function () {
|
|||
pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderMarkdownNotesPreview(markdown) {
|
||||
var lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
|
||||
var blocks = [];
|
||||
var paragraph = [];
|
||||
var listItems = [];
|
||||
|
||||
function inline(text) {
|
||||
var out = escapeHtml(text || '');
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
out = out.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, '<img src="$2" alt="$1">');
|
||||
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
out = out.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
out = out.replace(/_([^_]+)_/g, '<em>$1</em>');
|
||||
return out;
|
||||
}
|
||||
|
||||
function flushParagraph() {
|
||||
if (!paragraph.length) return;
|
||||
var rendered = inline(paragraph.join('\n')).replace(/\n/g, '<br>');
|
||||
blocks.push('<p>' + rendered + '</p>');
|
||||
paragraph = [];
|
||||
}
|
||||
|
||||
function flushList() {
|
||||
if (!listItems.length) return;
|
||||
blocks.push('<ul>' + listItems.map(function (item) { return '<li>' + inline(item) + '</li>'; }).join('') + '</ul>');
|
||||
listItems = [];
|
||||
}
|
||||
|
||||
lines.forEach(function (raw) {
|
||||
var line = raw.trim();
|
||||
if (!line) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
return;
|
||||
}
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
flushParagraph();
|
||||
listItems.push(line.replace(/^[-*]\s+/, ''));
|
||||
return;
|
||||
}
|
||||
if (/^#{1,3}\s+/.test(line)) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
var level = line.match(/^#{1,3}/)[0].length;
|
||||
blocks.push('<h' + level + '>' + inline(line.replace(/^#{1,3}\s+/, '')) + '</h' + level + '>');
|
||||
return;
|
||||
}
|
||||
paragraph.push(raw);
|
||||
});
|
||||
flushParagraph();
|
||||
flushList();
|
||||
return blocks.join('');
|
||||
}
|
||||
|
||||
function updateNotesPreview() {
|
||||
var notes = $('#cs-shows-notes').val() || '';
|
||||
if (!/\S/.test(notes)) {
|
||||
$('#cs-shows-notes-rendered').html('<span class="text-muted">Nothing to preview.</span>');
|
||||
return;
|
||||
}
|
||||
$('#cs-shows-notes-rendered').html(renderMarkdownNotesPreview(notes));
|
||||
}
|
||||
|
||||
function setNotesEditorMode(mode) {
|
||||
notesEditorMode = mode === 'preview' ? 'preview' : 'edit';
|
||||
if (notesEditorMode === 'preview') {
|
||||
$('#cs-shows-notes').hide();
|
||||
$('#cs-shows-notes-rendered').show();
|
||||
$('#cs-shows-notes-help').text('Preview mode');
|
||||
$('#cs-shows-notes-toggle').text('Edit');
|
||||
updateNotesPreview();
|
||||
return;
|
||||
}
|
||||
$('#cs-shows-notes-rendered').hide();
|
||||
$('#cs-shows-notes').show();
|
||||
$('#cs-shows-notes-help').text('Supports Markdown: headings, lists, bold, italics, inline code, images, and links.');
|
||||
$('#cs-shows-notes-toggle').text('Preview');
|
||||
}
|
||||
|
||||
function getShowOccurrenceEndMs(show, startMs) {
|
||||
if (!show || !show.estimated_end_at || !show.scheduled_for) {
|
||||
return startMs + 60 * 60 * 1000;
|
||||
}
|
||||
var baseStart = Number(show.scheduled_for);
|
||||
var baseEnd = Number(show.estimated_end_at);
|
||||
var duration = baseEnd - baseStart;
|
||||
if (!isFinite(duration) || duration <= 0) {
|
||||
return startMs + 60 * 60 * 1000;
|
||||
}
|
||||
return startMs + duration;
|
||||
}
|
||||
|
||||
function getShowBlockColor(show) {
|
||||
if (show && show.color) {
|
||||
return show.color;
|
||||
}
|
||||
var status = (show && show.status) || 'scheduled';
|
||||
if (status === 'running') return '#5cb85c';
|
||||
if (status === 'paused') return '#f0ad4e';
|
||||
if (status === 'completed') return '#777777';
|
||||
return '#337ab7';
|
||||
}
|
||||
|
||||
function renderDraftPlaylist() {
|
||||
var ul = $('#cs-shows-playlist-list').empty();
|
||||
if (!draftPlaylist.length) {
|
||||
|
|
@ -1627,6 +1743,7 @@ var CSTShows = (function () {
|
|||
|
||||
function readFormPayload() {
|
||||
var scheduledRaw = $('#cs-shows-scheduled-for').val();
|
||||
var estimatedEndRaw = $('#cs-shows-estimated-end-at').val();
|
||||
var timezone = $('#cs-shows-timezone').val().trim();
|
||||
var notes = $('#cs-shows-notes').val();
|
||||
var colorHex = ($('#cs-shows-color-hex').val() || '').trim();
|
||||
|
|
@ -1644,6 +1761,7 @@ var CSTShows = (function () {
|
|||
notes: notes && notes.trim() ? notes : null,
|
||||
color: colorHex ? colorHex.toUpperCase() : null,
|
||||
scheduled_for: scheduledRaw ? new Date(scheduledRaw).toISOString() : null,
|
||||
estimated_end_at: estimatedEndRaw ? new Date(estimatedEndRaw).toISOString() : null,
|
||||
timezone: timezone,
|
||||
recurrence: $('#cs-shows-recurrence').val(),
|
||||
fill_mode: $('#cs-shows-fill-mode').val(),
|
||||
|
|
@ -1662,6 +1780,7 @@ var CSTShows = (function () {
|
|||
$('#cs-shows-name').val('');
|
||||
$('#cs-shows-notes').val('');
|
||||
$('#cs-shows-scheduled-for').val('');
|
||||
$('#cs-shows-estimated-end-at').val('');
|
||||
var detectedTz = 'UTC';
|
||||
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
|
|
@ -1679,6 +1798,8 @@ var CSTShows = (function () {
|
|||
$('#cs-shows-mediaurl').val('');
|
||||
draftPlaylist = [];
|
||||
renderDraftPlaylist();
|
||||
updateNotesPreview();
|
||||
setNotesEditorMode('edit');
|
||||
}
|
||||
|
||||
function selectShow(show) {
|
||||
|
|
@ -1687,6 +1808,7 @@ var CSTShows = (function () {
|
|||
$('#cs-shows-name').val(show.name);
|
||||
$('#cs-shows-notes').val(show.notes || '');
|
||||
$('#cs-shows-scheduled-for').val(toLocalDateInput(show.scheduled_for));
|
||||
$('#cs-shows-estimated-end-at').val(toLocalDateInput(show.estimated_end_at));
|
||||
var showTz = show.timezone || 'UTC';
|
||||
if ($('#cs-shows-timezone option[value="' + showTz + '"]').length === 0) {
|
||||
$('<option>').attr('value', showTz).text(showTz).appendTo('#cs-shows-timezone');
|
||||
|
|
@ -1707,6 +1829,8 @@ var CSTShows = (function () {
|
|||
};
|
||||
});
|
||||
renderDraftPlaylist();
|
||||
updateNotesPreview();
|
||||
setNotesEditorMode('edit');
|
||||
resolveDraftTitles();
|
||||
}
|
||||
|
||||
|
|
@ -1751,26 +1875,106 @@ var CSTShows = (function () {
|
|||
|
||||
function openShowDetailsModal(show, when) {
|
||||
$('#showdetails-title').text(show.name || 'Show Details');
|
||||
$('#showdetails-time').text(when.toLocaleString());
|
||||
var startAt = when ? new Date(when) : (show.scheduled_for ? new Date(show.scheduled_for) : new Date());
|
||||
var endAt = new Date(getShowOccurrenceEndMs(show, startAt.getTime()));
|
||||
$('#showdetails-time').text(startAt.toLocaleString());
|
||||
$('#showdetails-estimated-end').text(endAt.toLocaleString());
|
||||
$('#showdetails-status').text(show.status || 'scheduled');
|
||||
var notes = (show.notes || '').trim();
|
||||
if (!notes) {
|
||||
var linksWrap = $('#showdetails-calendar-links');
|
||||
var linksContent = $('#showdetails-calendar-links-content').empty();
|
||||
var googleLinks = show && show.calendar_links && show.calendar_links.google
|
||||
? show.calendar_links.google
|
||||
: null;
|
||||
if (googleLinks && (googleLinks.event_url || googleLinks.calendar_url)) {
|
||||
if (googleLinks.event_url) {
|
||||
$('<a>')
|
||||
.attr('href', googleLinks.event_url)
|
||||
.attr('target', '_blank')
|
||||
.attr('rel', 'noopener noreferrer')
|
||||
.text('View in Google Calendar')
|
||||
.appendTo(linksContent);
|
||||
}
|
||||
if (googleLinks.calendar_url) {
|
||||
if (googleLinks.event_url) {
|
||||
linksContent.append(' | ');
|
||||
}
|
||||
$('<a>')
|
||||
.attr('href', googleLinks.calendar_url)
|
||||
.attr('target', '_blank')
|
||||
.attr('rel', 'noopener noreferrer')
|
||||
.text('Open Google Calendar')
|
||||
.appendTo(linksContent);
|
||||
}
|
||||
linksWrap.show();
|
||||
} else {
|
||||
linksWrap.hide();
|
||||
}
|
||||
var notesHtml = (show.notes_html || '').trim();
|
||||
if (!notesHtml) {
|
||||
$('#showdetails-notes').html('<p class="text-muted">No notes for this show.</p>');
|
||||
} else {
|
||||
$('#showdetails-notes').html(notes);
|
||||
$('#showdetails-notes').html(notesHtml);
|
||||
}
|
||||
$('#showdetails').modal();
|
||||
}
|
||||
|
||||
var byCell = {};
|
||||
var blocksByStart = {};
|
||||
var covered = {};
|
||||
var firstSegmentPlaced = {};
|
||||
function ensureDayMap(dayIdx) {
|
||||
if (!covered[dayIdx]) covered[dayIdx] = {};
|
||||
}
|
||||
function markCovered(dayIdx, startHour, span) {
|
||||
ensureDayMap(dayIdx);
|
||||
for (var h = startHour + 1; h < startHour + span; h++) {
|
||||
covered[dayIdx][h] = true;
|
||||
}
|
||||
}
|
||||
function dayIndexFor(date) {
|
||||
var midnight = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
var weekMidnight = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate());
|
||||
return Math.floor((midnight.getTime() - weekMidnight.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
shows.forEach(function (show) {
|
||||
var at = show.next_run_at || show.scheduled_for;
|
||||
if (!at) return;
|
||||
var d = new Date(at);
|
||||
if (d < weekStart || d > new Date(weekEnd.getTime() + 86399999)) return;
|
||||
var key = toCellKey(d);
|
||||
if (!byCell[key]) byCell[key] = [];
|
||||
byCell[key].push({ show: show, date: d });
|
||||
var start = new Date(at);
|
||||
var end = show.estimated_end_at ? new Date(show.estimated_end_at) : new Date(start.getTime() + 60 * 60 * 1000);
|
||||
if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||
|
||||
var occurrenceEnd = new Date(getShowOccurrenceEndMs(show, start.getTime()));
|
||||
var visibleStart = start < weekStart ? new Date(weekStart.getTime()) : start;
|
||||
var visibleEnd = occurrenceEnd > new Date(weekEnd.getTime() + 86399999) ? new Date(weekEnd.getTime() + 86399999) : occurrenceEnd;
|
||||
if (visibleEnd <= visibleStart) return;
|
||||
|
||||
var cursor = new Date(visibleStart.getTime());
|
||||
while (cursor < visibleEnd) {
|
||||
var dayStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), 0, 0, 0, 0);
|
||||
var dayEnd = new Date(dayStart.getTime() + (24 * 60 * 60 * 1000));
|
||||
var segEnd = visibleEnd < dayEnd ? visibleEnd : dayEnd;
|
||||
var dayIdx = dayIndexFor(cursor);
|
||||
if (dayIdx >= 0 && dayIdx < 7) {
|
||||
var slotStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), cursor.getHours(), 0, 0, 0);
|
||||
var startHour = slotStart.getHours();
|
||||
var span = Math.max(1, Math.ceil((segEnd.getTime() - slotStart.getTime()) / (60 * 60 * 1000)));
|
||||
if (startHour + span > 24) span = 24 - startHour;
|
||||
var startKey = dayIdx + '-' + startHour;
|
||||
if (!blocksByStart[startKey]) blocksByStart[startKey] = [];
|
||||
var isFirstVisibleSegment = !firstSegmentPlaced[show.id];
|
||||
blocksByStart[startKey].push({
|
||||
show: show,
|
||||
date: start,
|
||||
end: occurrenceEnd,
|
||||
span: span,
|
||||
isStart: isFirstVisibleSegment
|
||||
});
|
||||
if (isFirstVisibleSegment) firstSegmentPlaced[show.id] = true;
|
||||
if (span > 1) {
|
||||
markCovered(dayIdx, startHour, span);
|
||||
}
|
||||
}
|
||||
cursor = dayEnd;
|
||||
}
|
||||
});
|
||||
|
||||
var isAdmin = CLIENT.rank >= 2;
|
||||
|
|
@ -1791,7 +1995,9 @@ var CSTShows = (function () {
|
|||
var cellDate = new Date(weekStart.getTime());
|
||||
cellDate.setDate(weekStart.getDate() + col);
|
||||
cellDate.setHours(hour, 0, 0, 0);
|
||||
var key = toCellKey(cellDate);
|
||||
if (covered[col] && covered[col][hour]) {
|
||||
continue;
|
||||
}
|
||||
var cell = $('<td class="showschedule-cell">').appendTo(tr);
|
||||
if (isAdmin) {
|
||||
cell.addClass('showschedule-admin').attr('title', 'Click to create show at this time');
|
||||
|
|
@ -1804,23 +2010,53 @@ var CSTShows = (function () {
|
|||
})(new Date(cellDate.getTime()));
|
||||
}
|
||||
|
||||
var items = byCell[key] || [];
|
||||
var startKey = col + '-' + hour;
|
||||
var items = blocksByStart[startKey] || [];
|
||||
items.sort(function (a, b) { return a.date - b.date; });
|
||||
if (items.length === 1 && items[0].span > 1) {
|
||||
cell.attr('rowspan', String(items[0].span));
|
||||
cell
|
||||
.addClass('showschedule-block-cell')
|
||||
.css('background-color', getShowBlockColor(items[0].show));
|
||||
}
|
||||
items.forEach(function (item) {
|
||||
var label = item.date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' ' + item.show.name;
|
||||
var startLabel = item.date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
var endLabel = item.end
|
||||
? item.end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: null;
|
||||
var label = endLabel
|
||||
? (startLabel + '-' + endLabel + ' ' + item.show.name)
|
||||
: (startLabel + ' ' + item.show.name);
|
||||
var notesPreview = (item.show.notes || '').trim().replace(/\s+/g, ' ');
|
||||
if (notesPreview.length > 120) {
|
||||
notesPreview = notesPreview.substring(0, 117) + '...';
|
||||
}
|
||||
$('<a href="javascript:void(0)" class="showschedule-show">')
|
||||
.addClass('status-' + (item.show.status || 'scheduled'))
|
||||
.text(label)
|
||||
.css('background', item.show.color || '')
|
||||
.css('background', items.length === 1 ? 'transparent' : (item.show.color || ''))
|
||||
.css('opacity', item.isStart ? '1' : '0.85')
|
||||
.attr('title', notesPreview || label)
|
||||
.on('click', function () {
|
||||
if (isAdmin) {
|
||||
openShowsEditor();
|
||||
selectShow(item.show);
|
||||
} else {
|
||||
openShowDetailsModal(item.show, item.date);
|
||||
openShowDetailsModal(item.show, item.date.getTime());
|
||||
}
|
||||
})
|
||||
.appendTo(cell);
|
||||
if (item.isStart) {
|
||||
if (item.show.notes_html) {
|
||||
$('<div class="showschedule-notes small">')
|
||||
.html(item.show.notes_html)
|
||||
.appendTo(cell);
|
||||
} else if (notesPreview) {
|
||||
$('<div class="showschedule-notes small">')
|
||||
.text(notesPreview)
|
||||
.appendTo(cell);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1845,7 +2081,7 @@ var CSTShows = (function () {
|
|||
function render(shows) {
|
||||
var tbody = $('#cs-shows-list').empty();
|
||||
if (!shows.length) {
|
||||
tbody.append('<tr><td colspan="6" class="text-muted">No shows configured</td></tr>');
|
||||
tbody.append('<tr><td colspan="8" class="text-muted">No shows configured</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1856,8 +2092,37 @@ var CSTShows = (function () {
|
|||
));
|
||||
row.append($('<td>').text(show.status));
|
||||
row.append($('<td>').text(show.next_run_at ? new Date(show.next_run_at).toLocaleString(undefined, { timeZone: show.timezone || 'UTC' }) : 'N/A'));
|
||||
row.append($('<td>').text(show.estimated_end_at ? new Date(show.estimated_end_at).toLocaleString(undefined, { timeZone: show.timezone || 'UTC' }) : 'N/A'));
|
||||
row.append($('<td>').text(show.timezone || 'UTC'));
|
||||
row.append($('<td>').text(show.recurrence || 'none'));
|
||||
var calendarTd = $('<td>');
|
||||
var googleLinks = show && show.calendar_links && show.calendar_links.google
|
||||
? show.calendar_links.google
|
||||
: null;
|
||||
if (googleLinks && (googleLinks.event_url || googleLinks.calendar_url)) {
|
||||
if (googleLinks.event_url) {
|
||||
$('<a>')
|
||||
.attr('href', googleLinks.event_url)
|
||||
.attr('target', '_blank')
|
||||
.attr('rel', 'noopener noreferrer')
|
||||
.text('Event')
|
||||
.appendTo(calendarTd);
|
||||
}
|
||||
if (googleLinks.calendar_url) {
|
||||
if (googleLinks.event_url) {
|
||||
calendarTd.append(' | ');
|
||||
}
|
||||
$('<a>')
|
||||
.attr('href', googleLinks.calendar_url)
|
||||
.attr('target', '_blank')
|
||||
.attr('rel', 'noopener noreferrer')
|
||||
.text('Calendar')
|
||||
.appendTo(calendarTd);
|
||||
}
|
||||
} else {
|
||||
calendarTd.append($('<span class="text-muted">').text('Not synced'));
|
||||
}
|
||||
row.append(calendarTd);
|
||||
|
||||
var actions = $('<td>');
|
||||
$('<button class=\"btn btn-xs btn-primary\" style=\"margin-right:4px\">Run</button>')
|
||||
|
|
@ -1901,7 +2166,7 @@ var CSTShows = (function () {
|
|||
renderScheduleCalendar(cachedShows);
|
||||
}).fail(function () {
|
||||
if (CLIENT.rank >= 2) {
|
||||
$('#cs-shows-list').html('<tr><td colspan=\"6\" class=\"text-danger\">Failed to load shows</td></tr>');
|
||||
$('#cs-shows-list').html('<tr><td colspan=\"8\" class=\"text-danger\">Failed to load shows</td></tr>');
|
||||
}
|
||||
$('#showschedule-grid').html('<div class=\"text-danger\">Failed to load schedule</div>');
|
||||
});
|
||||
|
|
@ -1960,6 +2225,10 @@ var CSTShows = (function () {
|
|||
$('#cs-shows-color').val(v);
|
||||
}
|
||||
});
|
||||
$('#cs-shows-notes').on('input', updateNotesPreview);
|
||||
$('#cs-shows-notes-toggle').on('click', function () {
|
||||
setNotesEditorMode(notesEditorMode === 'edit' ? 'preview' : 'edit');
|
||||
});
|
||||
$('#cs-shows-playlist-list').sortable({
|
||||
update: function () {
|
||||
var nextDraft = [];
|
||||
|
|
@ -1989,6 +2258,7 @@ var CSTShows = (function () {
|
|||
});
|
||||
renderDraftPlaylist();
|
||||
clearForm();
|
||||
setNotesEditorMode('edit');
|
||||
load();
|
||||
|
||||
return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue