diff --git a/src/database/shows.js b/src/database/shows.js index 7f0baaed..a588054d 100644 --- a/src/database/shows.js +++ b/src/database/shows.js @@ -26,6 +26,8 @@ function parseShowRow(row) { channel_name: row.channel_name, channel_id: row.channel_id, name: row.name, + notes: row.notes || null, + color: row.color || null, playlist, timezone: row.timezone, scheduled_for: row.scheduled_for, @@ -49,6 +51,8 @@ function parseShowRow(row) { function serializeShowInput(input) { return { name: input.name, + notes: input.notes || null, + color: input.color || null, playlist: JSON.stringify(input.playlist || []), timezone: input.timezone, scheduled_for: input.scheduled_for, diff --git a/src/database/tables.js b/src/database/tables.js index f8515be3..0737c857 100644 --- a/src/database/tables.js +++ b/src/database/tables.js @@ -184,6 +184,8 @@ export async function initTables() { .references('id').inTable('channels') .onDelete('cascade'); t.string('name', 100).notNullable(); + t.specificType('notes', 'mediumtext character set utf8mb4'); + t.string('color', 7).nullable(); t.specificType('playlist', 'mediumtext character set utf8mb4 not null'); t.string('timezone', 64).notNullable().defaultTo('UTC'); t.bigInteger('scheduled_for').notNullable(); diff --git a/src/database/update.js b/src/database/update.js index 3b600966..fa73e272 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 = 12; +const DB_VERSION = 13; var hasUpdates = []; module.exports.checkVersion = function () { @@ -53,6 +53,8 @@ function update(version, cb) { addChannelOwnerLastSeenColumn(cb); } else if (version < 12) { addUserInactiveColumn(cb); + } else if (version < 13) { + addShowsNotesAndColorColumns(cb); } } @@ -141,3 +143,29 @@ function addUserInactiveColumn(cb) { } }); } + +function addShowsNotesAndColorColumns(cb) { + db.query( + "ALTER TABLE channel_shows ADD COLUMN notes MEDIUMTEXT CHARACTER SET utf8mb4 NULL", + error => { + if (error) { + LOGGER.error(`Failed to add shows notes column: ${error}`); + cb(error); + return; + } + + db.query( + "ALTER TABLE channel_shows ADD COLUMN color VARCHAR(7) NULL", + error => { + if (error) { + LOGGER.error(`Failed to add shows color column: ${error}`); + cb(error); + return; + } + + cb(); + } + ); + } + ); +} diff --git a/src/web/routes/api/shows.js b/src/web/routes/api/shows.js index b4ada722..313120d3 100644 --- a/src/web/routes/api/shows.js +++ b/src/web/routes/api/shows.js @@ -4,6 +4,7 @@ const showDB = require('../../../database/shows'); const shows = require('../../../shows'); const botDB = require('../../../database/bots'); const infoGetter = require('../../../get-info'); +const XSS = require('../../../xss'); const { getChannelRow, getUserEffectiveRank, hashToken } = require('./middleware'); const router = express.Router({ mergeParams: true }); @@ -19,6 +20,7 @@ const ACTION_MIN_RANK = { run: 3, cancel: 3 }; +const PUBLIC_SHOW_STATUSES = new Set(['scheduled', 'running', 'paused', 'completed']); function sanitizePlaylist(list) { if (!Array.isArray(list)) return []; @@ -100,9 +102,27 @@ function validateShowPayload(body, old = null) { const nextRunAt = status === 'scheduled' ? scheduledFor : (old ? old.next_run_at : scheduledFor); + 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)); + } + + const colorRaw = body.color !== undefined ? body.color : (old ? old.color : null); + let color = null; + if (colorRaw !== null && colorRaw !== undefined && String(colorRaw).trim() !== '') { + const normalized = String(colorRaw).trim(); + if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) { + return { error: 'color must be a hex string like #1A2B3C' }; + } + color = normalized.toUpperCase(); + } + return { value: { name, + notes, + color, playlist, timezone, scheduled_for: scheduledFor, @@ -181,6 +201,18 @@ router.get('/', async (req, res) => { res.json(showsList); }); +router.get('/public', async (req, res) => { + let channelRow; + try { + channelRow = await getChannelRow(req.params.channel); + } catch (_err) { + return res.status(404).json({ error: 'Channel not found' }); + } + + const showsList = await showDB.listShows(channelRow.id); + res.json(showsList.filter(show => PUBLIC_SHOW_STATUSES.has(show.status))); +}); + router.get('/:id', async (req, res) => { const auth = await authorizeChannel(req, res); if (!auth) return; diff --git a/templates/channel.pug b/templates/channel.pug index 1f1aadb8..27c73296 100644 --- a/templates/channel.pug +++ b/templates/channel.pug @@ -15,6 +15,7 @@ html(lang="en") ul.nav.navbar-nav +navdefaultlinks() li: a(href="javascript:void(0)", onclick="javascript:showUserOptions()") Options + li: a#toggleschedule(href="javascript:void(0)") Show Schedule li: a#showchansettings(href="javascript:void(0)", onclick="javascript:showChannelSettings()") Channel Settings li.dropdown a.dropdown-toggle(href="#", data-toggle="dropdown") Layout @@ -28,6 +29,21 @@ html(lang="en") +navloginlogout() section#mainpage .container + #showschedule-row.row(style="display:none;") + .col-lg-12.col-md-12 + #showschedule.panel.panel-default + .panel-heading + .pull-right + .btn-group.btn-group-xs + button#showschedule-prev.btn.btn-default(type="button") Prev Week + button#showschedule-today.btn.btn-default(type="button") This Week + button#showschedule-next.btn.btn-default(type="button") Next Week + strong Channel Schedule + span#showschedule-week-label.text-muted(style="margin-left:8px") + .clear + .panel-body + p#showschedule-empty.text-muted(style="display:none; margin-bottom:8px;") No scheduled shows this week. + #showschedule-grid #motdrow.row .col-lg-12.col-md-12 #motdwrap.well @@ -88,7 +104,6 @@ html(lang="en") span.glyphicon.glyphicon-link button#voteskip.btn.btn-sm.btn-default(title="Voteskip") span.glyphicon.glyphicon-step-forward - #playlistrow.row #leftpane.col-lg-5.col-md-5 #leftpane-inner.row @@ -244,6 +259,25 @@ html(lang="en") +permeditor() .modal-footer button.btn.btn-default(type="button", data-dismiss="modal") Close + #showdetails.modal.fade(tabindex="-1", role="dialog", aria-hidden="true") + .modal-dialog + .modal-content + .modal-header + button.close(data-dismiss="modal", aria-hidden="true") × + h4#showdetails-title Show Details + .modal-body + p + strong Time: + | + span#showdetails-time + p + strong Status: + | + span#showdetails-status + hr + #showdetails-notes + .modal-footer + button.btn.btn-default(type="button", data-dismiss="modal") Close #pmbar include footer +footer() diff --git a/templates/channeloptions.pug b/templates/channeloptions.pug index f6a751ac..4d49e383 100644 --- a/templates/channeloptions.pug +++ b/templates/channeloptions.pug @@ -267,6 +267,12 @@ 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-color") Color + .col-sm-3 + input#cs-shows-color.form-control(type="color", value="#337AB7") + .col-sm-6 + input#cs-shows-color-hex.form-control(type="text", placeholder="#337AB7", maxlength="7") .form-group label.control-label.col-sm-3(for="cs-shows-timezone") Timezone .col-sm-9 @@ -296,6 +302,11 @@ mixin shows label(for="cs-shows-start-playback") input#cs-shows-start-playback(type="checkbox") | Skip current playback and start this show's first item immediately + .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. .form-group label.control-label.col-sm-3(for="cs-shows-mediaurl") Show Playlist .col-sm-9 diff --git a/www/css/cytube.css b/www/css/cytube.css index e6a7ae7a..01836152 100644 --- a/www/css/cytube.css +++ b/www/css/cytube.css @@ -221,6 +221,53 @@ li.ui-sortable-helper, li.ui-sortable-placeholder + li.queue_entry { clear: both; } +#showschedule-row { + margin-bottom: 10px; +} + +#showschedule-grid table { + margin-bottom: 0; + table-layout: fixed; +} + +.showschedule-time-col { + width: 60px; + white-space: nowrap; +} + +.showschedule-cell { + min-height: 30px; + cursor: default; + vertical-align: top; + padding: 2px !important; +} + +.showschedule-cell.showschedule-admin { + cursor: pointer; +} + +.showschedule-show { + display: block; + font-size: 11px; + line-height: 1.2; + margin-bottom: 2px; + padding: 2px 4px; + border-radius: 3px; + color: #fff; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4); + text-decoration: none; +} + +.showschedule-show.status-scheduled { background: #337ab7; } +.showschedule-show.status-running { background: #5cb85c; } +.showschedule-show.status-paused { background: #f0ad4e; color: #222; } +.showschedule-show.status-completed { background: #777; } + +#showdetails-notes img { + max-width: 100%; + height: auto; +} + .clear { clear: both; } diff --git a/www/js/ui.js b/www/js/ui.js index 6078ef95..adb12069 100644 --- a/www/js/ui.js +++ b/www/js/ui.js @@ -28,6 +28,24 @@ $("#togglemotd").on('click', function () { } }); +function updateScheduleToggleLabel() { + var row = $("#showschedule-row"); + if (!row.length) return; + $("#toggleschedule").text(row.is(":visible") ? "Hide Schedule" : "Show Schedule"); +} + +$("#toggleschedule").on('click', function () { + var row = $("#showschedule-row"); + if (!row.length) return; + var willShow = !row.is(":visible"); + row.toggle(); + if (willShow && window.CSTShows && typeof window.CSTShows.load === "function") { + window.CSTShows.load(); + } + updateScheduleToggleLabel(); +}); +updateScheduleToggleLabel(); + /* chatbox */ $("#modflair").on('click', function () { @@ -1416,11 +1434,17 @@ var CSTShows = (function () { var draftPlaylist = []; var timezoneOptionsLoaded = false; var resolvingTitles = false; + var weekOffset = 0; + var cachedShows = []; function apiBase() { return '/api/v1/channels/' + CHANNEL.name + '/shows'; } + function publicApiBase() { + return apiBase() + '/public'; + } + function loadTimezoneOptions() { if (timezoneOptionsLoaded) return; timezoneOptionsLoaded = true; @@ -1604,11 +1628,21 @@ var CSTShows = (function () { function readFormPayload() { var scheduledRaw = $('#cs-shows-scheduled-for').val(); var timezone = $('#cs-shows-timezone').val().trim(); + var notes = $('#cs-shows-notes').val(); + var colorHex = ($('#cs-shows-color-hex').val() || '').trim(); + if (!colorHex) { + colorHex = ($('#cs-shows-color').val() || '').trim(); + } + if (!/^#[0-9a-fA-F]{6}$/.test(colorHex || '')) { + colorHex = ''; + } if (!timezone) { timezone = 'UTC'; } return { name: $('#cs-shows-name').val().trim(), + notes: notes && notes.trim() ? notes : null, + color: colorHex ? colorHex.toUpperCase() : null, scheduled_for: scheduledRaw ? new Date(scheduledRaw).toISOString() : null, timezone: timezone, recurrence: $('#cs-shows-recurrence').val(), @@ -1626,6 +1660,7 @@ var CSTShows = (function () { loadTimezoneOptions(); selectedId = null; $('#cs-shows-name').val(''); + $('#cs-shows-notes').val(''); $('#cs-shows-scheduled-for').val(''); var detectedTz = 'UTC'; if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { @@ -1639,6 +1674,8 @@ var CSTShows = (function () { $('#cs-shows-fill-mode').val('append'); $('#cs-shows-conflict-skip').prop('checked', false); $('#cs-shows-start-playback').prop('checked', false); + $('#cs-shows-color').val('#337AB7'); + $('#cs-shows-color-hex').val(''); $('#cs-shows-mediaurl').val(''); draftPlaylist = []; renderDraftPlaylist(); @@ -1648,6 +1685,7 @@ var CSTShows = (function () { loadTimezoneOptions(); selectedId = show.id; $('#cs-shows-name').val(show.name); + $('#cs-shows-notes').val(show.notes || ''); $('#cs-shows-scheduled-for').val(toLocalDateInput(show.scheduled_for)); var showTz = show.timezone || 'UTC'; if ($('#cs-shows-timezone option[value="' + showTz + '"]').length === 0) { @@ -1658,6 +1696,8 @@ var CSTShows = (function () { $('#cs-shows-fill-mode').val(show.fill_mode || 'append'); $('#cs-shows-conflict-skip').prop('checked', (show.conflict_mode || 'force') === 'skip'); $('#cs-shows-start-playback').prop('checked', !!show.start_playback); + $('#cs-shows-color').val(show.color || '#337AB7'); + $('#cs-shows-color-hex').val(show.color || ''); draftPlaylist = (show.playlist || []).map(function (item) { return { id: item.id, @@ -1670,6 +1710,125 @@ var CSTShows = (function () { resolveDraftTitles(); } + function openShowsEditor() { + showChannelSettings(); + $("#channeloptions a[href='#cs-shows']").tab('show'); + } + + function prefillScheduledDate(date) { + clearForm(); + selectedId = null; + $('#cs-shows-scheduled-for').val(toLocalDateInput(date.getTime())); + } + + function weekStartFromOffset(offset) { + var d = new Date(); + d.setHours(0, 0, 0, 0); + var day = d.getDay(); + var mondayShift = (day + 6) % 7; + d.setDate(d.getDate() - mondayShift + (offset * 7)); + return d; + } + + function dayKey(date) { + return date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate(); + } + + function toCellKey(date) { + return dayKey(date) + '-' + date.getHours(); + } + + function renderScheduleCalendar(shows) { + var grid = $('#showschedule-grid'); + if (!grid.length) return; + + var weekStart = weekStartFromOffset(weekOffset); + var weekEnd = new Date(weekStart.getTime()); + weekEnd.setDate(weekEnd.getDate() + 6); + $('#showschedule-week-label').text( + weekStart.toLocaleDateString() + ' - ' + weekEnd.toLocaleDateString() + ); + + function openShowDetailsModal(show, when) { + $('#showdetails-title').text(show.name || 'Show Details'); + $('#showdetails-time').text(when.toLocaleString()); + $('#showdetails-status').text(show.status || 'scheduled'); + var notes = (show.notes || '').trim(); + if (!notes) { + $('#showdetails-notes').html('

No notes for this show.

'); + } else { + $('#showdetails-notes').html(notes); + } + $('#showdetails').modal(); + } + + var byCell = {}; + 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 isAdmin = CLIENT.rank >= 2; + var table = $(''); + var thead = $('').appendTo(table); + var hrow = thead.find('tr'); + for (var i = 0; i < 7; i++) { + var day = new Date(weekStart.getTime()); + day.setDate(weekStart.getDate() + i); + hrow.append($('').appendTo(table); + for (var hour = 0; hour < 24; hour++) { + var tr = $('').appendTo(tbody); + tr.append($(''); + var endpoint = CLIENT.rank >= 2 ? apiBase() : publicApiBase(); + $.getJSON(endpoint, function (shows) { + cachedShows = Array.isArray(shows) ? shows : []; + if (CLIENT.rank >= 2) { + render(cachedShows); + } + renderScheduleCalendar(cachedShows); + }).fail(function () { + if (CLIENT.rank >= 2) { + $('#cs-shows-list').html(''); + } + $('#showschedule-grid').html('
Failed to load schedule
'); }); } @@ -1782,6 +1951,15 @@ var CSTShows = (function () { } }); $('#cs-shows-clear').on('click', clearForm); + $('#cs-shows-color').on('change', function () { + $('#cs-shows-color-hex').val(($(this).val() || '').toUpperCase()); + }); + $('#cs-shows-color-hex').on('input', function () { + var v = ($(this).val() || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(v)) { + $('#cs-shows-color').val(v); + } + }); $('#cs-shows-playlist-list').sortable({ update: function () { var nextDraft = []; @@ -1797,8 +1975,21 @@ var CSTShows = (function () { } } }).disableSelection(); + $('#showschedule-prev').on('click', function () { + weekOffset--; + renderScheduleCalendar(cachedShows); + }); + $('#showschedule-next').on('click', function () { + weekOffset++; + renderScheduleCalendar(cachedShows); + }); + $('#showschedule-today').on('click', function () { + weekOffset = 0; + renderScheduleCalendar(cachedShows); + }); renderDraftPlaylist(); clearForm(); + load(); - return { load: load }; + return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate }; })();
Time
').text(day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))); + } + + var tbody = $('
').text(String(hour).padStart(2, '0') + ':00')); + for (var col = 0; col < 7; col++) { + var cellDate = new Date(weekStart.getTime()); + cellDate.setDate(weekStart.getDate() + col); + cellDate.setHours(hour, 0, 0, 0); + var key = toCellKey(cellDate); + var cell = $('').appendTo(tr); + if (isAdmin) { + cell.addClass('showschedule-admin').attr('title', 'Click to create show at this time'); + (function (prefill) { + cell.on('click', function (ev) { + if ($(ev.target).closest('.showschedule-show').length) return; + openShowsEditor(); + prefillScheduledDate(prefill); + }); + })(new Date(cellDate.getTime())); + } + + var items = byCell[key] || []; + items.sort(function (a, b) { return a.date - b.date; }); + items.forEach(function (item) { + var label = item.date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' ' + item.show.name; + $('') + .addClass('status-' + (item.show.status || 'scheduled')) + .text(label) + .css('background', item.show.color || '') + .on('click', function () { + if (isAdmin) { + openShowsEditor(); + selectShow(item.show); + } else { + openShowDetailsModal(item.show, item.date); + } + }) + .appendTo(cell); + }); + } + } + + grid.empty().append(table); + $('#showschedule-empty').toggle(shows.length === 0); + } + function action(id, actionName) { $.ajax({ url: apiBase() + '/' + id + '/action', @@ -1733,8 +1892,18 @@ var CSTShows = (function () { } function load() { - $.getJSON(apiBase(), render).fail(function () { - $('#cs-shows-list').html('
Failed to load shows
Failed to load shows