From dd1bf9d55b6ca47d146a72aed65abb824c4a0c8f Mon Sep 17 00:00:00 2001 From: Speng Reb Date: Mon, 1 Jun 2026 01:46:00 +0200 Subject: [PATCH] Add many UX improvements to channel schedule --- src/database/calendar-integrations.js | 61 +++++- src/database/shows.js | 2 + src/database/tables.js | 1 + src/database/update.js | 18 +- src/integrations/google-calendar.js | 6 +- src/util/markdown.js | 89 ++++++++ src/web/routes/api/shows.js | 62 +++++- templates/channel.pug | 8 + templates/channeloptions.pug | 12 +- www/css/cytube.css | 39 ++++ www/js/ui.js | 304 ++++++++++++++++++++++++-- 11 files changed, 571 insertions(+), 31 deletions(-) create mode 100644 src/util/markdown.js diff --git a/src/database/calendar-integrations.js b/src/database/calendar-integrations.js index 07027647..0c3f1bdd 100644 --- a/src/database/calendar-integrations.js +++ b/src/database/calendar-integrations.js @@ -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 }; diff --git a/src/database/shows.js b/src/database/shows.js index a588054d..37c56647 100644 --- a/src/database/shows.js +++ b/src/database/shows.js @@ -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, diff --git a/src/database/tables.js b/src/database/tables.js index e4794d24..62318384 100644 --- a/src/database/tables.js +++ b/src/database/tables.js @@ -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'); diff --git a/src/database/update.js b/src/database/update.js index 8e7bb848..7fb8dff7 100644 --- a/src/database/update.js +++ b/src/database/update.js @@ -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(); + } + ); +} diff --git a/src/integrations/google-calendar.js b/src/integrations/google-calendar.js index 7d22d9f9..cd7e4b44 100644 --- a/src/integrations/google-calendar.js +++ b/src/integrations/google-calendar.js @@ -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 || '', diff --git a/src/util/markdown.js b/src/util/markdown.js new file mode 100644 index 00000000..b44ac492 --- /dev/null +++ b/src/util/markdown.js @@ -0,0 +1,89 @@ +const XSS = require('../xss'); + +function escapeHtml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function applyInlineMarkdown(text) { + let out = escapeHtml(text); + out = out.replace(/`([^`]+)`/g, '$1'); + out = out.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); + out = out.replace(/__([^_]+)__/g, '$1'); + out = out.replace(/\*([^*]+)\*/g, '$1'); + out = out.replace(/_([^_]+)_/g, '$1'); + 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, '
'); + blocks.push('

' + rendered + '

'); + paragraph = []; + } + + function flushList() { + if (!listItems.length) return; + blocks.push(''); + 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('' + applyInlineMarkdown(content) + ''); + 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 +}; diff --git a/src/web/routes/api/shows.js b/src/web/routes/api/shows.js index 313120d3..f60290a7 100644 --- a/src/web/routes/api/shows.js +++ b/src/web/routes/api/shows.js @@ -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; diff --git a/templates/channel.pug b/templates/channel.pug index 3ccc564f..c56030d0 100644 --- a/templates/channel.pug +++ b/templates/channel.pug @@ -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 diff --git a/templates/channeloptions.pug b/templates/channeloptions.pug index e994bb22..9859adb4 100644 --- a/templates/channeloptions.pug +++ b/templates/channeloptions.pug @@ -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 diff --git a/www/css/cytube.css b/www/css/cytube.css index 01836152..6fbc0b33 100644 --- a/www/css/cytube.css +++ b/www/css/cytube.css @@ -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; } diff --git a/www/js/ui.js b/www/js/ui.js index c2e7b081..ac654747 100644 --- a/www/js/ui.js +++ b/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, '''); + } + + 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, '$1'); + out = out.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); + out = out.replace(/__([^_]+)__/g, '$1'); + out = out.replace(/\*([^*]+)\*/g, '$1'); + out = out.replace(/_([^_]+)_/g, '$1'); + return out; + } + + function flushParagraph() { + if (!paragraph.length) return; + var rendered = inline(paragraph.join('\n')).replace(/\n/g, '
'); + blocks.push('

' + rendered + '

'); + paragraph = []; + } + + function flushList() { + if (!listItems.length) return; + blocks.push(''); + 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('' + inline(line.replace(/^#{1,3}\s+/, '')) + ''); + 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('Nothing to preview.'); + 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) { $('