1/** 2 * Our News â the station's own reporting desk on the front page. 3 * 4 * A story on this desk is something filed from the scene and the script that 5 * goes with it. The script is the reason the section exists: the point is that 6 * somebody can open a story, read the copy, and go report it, so the reader 7 * shows the whole script rather than a teaser, and the API refuses to publish a 8 * story that has no script. 9 * 10 * What gets filed is whatever the reporter had. A clip, a photograph, a voice 11 * memo, a PDF of an incident report - all of them upload, and the desk presents 12 * each one as what it is: a player for a clip or a recording, the picture itself 13 * for a photograph, and a labelled download for anything else. The kind comes 14 * from the API rather than being guessed here, because the API is also what 15 * decided how those bytes are served. 16 * 17 * How it behaves, all of it deliberate: 18 * 19 * - Clicking a story opens it in place. The card grows into the reader in the 20 * same spot on the page, so the thing you clicked is the thing that opened 21 * and nothing jumps. Where the browser supports view transitions the poster 22 * morphs into the video frame; where it does not, the reader still expands 23 * on a height transition, so the effect degrades rather than breaking. 24 * A modal was the alternative and was rejected: it would cover the page, 25 * drop the scroll position, and make a story impossible to link to. 26 * 27 * - A story with no video shuffles through its pictures. A reporter often 28 * comes back with photographs rather than a clip, so a story can carry a 29 * set of stills, and where there is nothing to play they cross-fade in the 30 * spot instead: the illustration a written story would otherwise lack. A 31 * clip always wins the spot, because a moving picture is the better one. 32 * The shuffle stops for anybody who asked for reduced motion, and it can be 33 * paused by anybody who did not. 34 * 35 * - A story's clip is only fetched when its story is opened. A closed card 36 * costs one small still image, which is why the desk uploads a poster frame 37 * at all â twelve stories on a page would otherwise be twelve video streams. 38 * 39 * - A visitor sees no controls and no upload form, and the section hides 40 * itself entirely when there is nothing published. An empty heading reads 41 * as a broken page. Staff - an admin or a moderator, both of whom post and 42 * take down stories here - always see it, because that is where the first 43 * story gets added from. 44 * 45 * - Uploading walks the file up in parts. A function may only accept a 6 MB 46 * request, so a clip is sliced and sent a piece at a time, and the story is 47 * published only once the server has confirmed it holds them all. The 48 * progress line counts real completed requests rather than guessing. 49 * 50 * Standalone rather than part of main.js, matching notifications.js and 51 * sponsor-slots.js: it hooks the same `mst:auth-change` event instead of being 52 * wired in by name. 53 */ 54(function () { 55 'use strict'; 56 57 if (typeof window === 'undefined' || !window.document) return; 58 if (window.MSTNewsDesk) return; 59 60 var ADMIN_SESSION_KEY = 'mstAdminSession'; 61 var MODERATOR_SESSION_KEY = 'mstModeratorSession'; 62 var API_SESSION_TOKEN_KEY = 'mstSessionToken'; 63 var FILE_MODE_API_ORIGIN = 'https://macradio.netlify.app'; 64 65 /** Matches the beats the function accepts; anything else it rewrites. */ 66 var BEATS = [ 67 { key: 'police', label: 'Police' }, 68 { key: 'fire', label: 'Fire' }, 69 { key: 'courts', label: 'Courts' }, 70 { key: 'government', label: 'Government' }, 71 { key: 'schools', label: 'Schools' }, 72 { key: 'community', label: 'Community' }, 73 { key: 'weather', label: 'Weather' }, 74 { key: 'sports', label: 'Sports' } 75 ]; 76 77 /** The server's own floor for a script, repeated so the form can say so. */ 78 var MIN_SCRIPT = 40; 79 80 /* 81 The function's own ceilings, repeated so the form can refuse a file 82 before anybody waits on it. 83 84 Two of them, because the reason they differ is structural rather than a 85 policy: video and audio are pulled a range at a time by the player, so 86 they can be large, while a picture or a download has to be answered in 87 one response and a function may only stream about 20 MB in one. The 88 function enforces both regardless; this is only about saying so early. 89 */ 90 var MAX_STREAM_BYTES = 512 * 1024 * 1024; 91 var MAX_WHOLE_BYTES = 16 * 1024 * 1024; 92 93 /* 94 The gallery: the API's own two limits, and the size a picture is drawn 95 down to before it is sent. 96 97 Every picture is re-encoded here first, so what the limit applies to is 98 what this file produced rather than what came off the camera - a 12 MB 99 phone photograph lands as a few hundred kilobytes. 1600px is the long 100 edge, which is more than the spot ever shows and enough that a visitor
101 on a retina screen is not looking at mush. 102 */ 103 var MAX_PHOTOS = 8; 104 var MAX_PHOTO_BYTES = 4 * 1024 * 1024; 105 var PHOTO_EDGE = 1600; 106 107 /** How long a picture holds the spot before the next one fades in. */ 108 var GALLERY_MS = 5200; 109 110 /** The ceiling that applies to this file, and the sentence that explains it. */ 111 function limitFor(file) { 112 var type = String((file && file.type) || ''); 113 if (/^(video|audio)\//i.test(type)) { 114 return { 115 bytes: MAX_STREAM_BYTES, 116 message: 'That file is ' + fileSizeLabel(file.size) 117 + '. A video or a recording can be up to ' 118 + fileSizeLabel(MAX_STREAM_BYTES) + '.' 119 }; 120 } 121 return { 122 bytes: MAX_WHOLE_BYTES, 123 message: 'That file is ' + fileSizeLabel(file.size) 124 + '. A picture or a document can be up to ' 125 + fileSizeLabel(MAX_WHOLE_BYTES) 126 + ', because it is sent to the page in one piece rather than ' 127 + 'streamed. A video or a recording can be much larger.' 128 }; 129 } 130 131 var stories = []; 132 /* 133 How many published stories the front page shows. The API sends it, and 134 it is kept only so staff can see which of their stories have rolled into 135 the Local News archive - everything published past this many. 136 */ 137 var visibleLimit = 10; 138 var loaded = false; 139 /** True when the last fetch failed, as opposed to coming back empty. */ 140 var loadFailed = false; 141 var manageMode = false; 142 var busy = false; 143 /** The story currently expanded, so only one reader is open at a time. */ 144 /** The story in the video spot. Empty means "the first one". */ 145 var featuredId = ''; 146 /** Whether the script under the spot is unfolded. */ 147 var scriptOpen = false; 148 var formOpen = false; 149 var editingId = ''; 150 var progressText = ''; 151 var notice = ''; 152 var noticeKind = ''; 153 /** Kept so a redraw does not wipe half-typed copy out of the form. */ 154 var draft = null; 155 /** 156 * The pictures chosen in the form and not yet sent. 157 * 158 * Held here rather than read off the input at submit time because a redraw 159 * replaces the form, and a file input cannot be refilled from script. A 160 * progress line or a validation message would otherwise quietly drop the 161 * selection and leave the desk choosing the same pictures twice. 162 */ 163 var pendingPhotos = []; 164 /** Which picture of the featured story's gallery is on screen. */ 165 var galleryIndex = 0; 166 /** Set by the visitor pressing pause, and only by that. */ 167 var galleryPaused = false; 168 /** The pending advance. Cleared and re-armed by every redraw. */ 169 var galleryTimer = 0; 170 171 /* ------------------------------------------------------------ helpers ---- */ 172 173 function escapeHtml(value) { 174 return String(value == null ? '' : value) 175 .replace(/&/g, '&') 176 .replace(/</g, '<') 177 .replace(/>/g, '>') 178 .replace(/"/g, '"') 179 .replace(/'/g, '''); 180 } 181 182 function getEndpoint() { 183 try { 184 var params = new URLSearchParams(window.location.search || ''); 185 var override = (params.get('sharedApiOrigin') || '').trim() 186 || (window.localStorage.getItem('mstSharedApiOrigin') || '').trim(); 187 if (override) return override.replace(/\/+$/, '') + '/api/news-videos'; 188 if (window.location.protocol === 'file:') { 189 return FILE_MODE_API_ORIGIN + '/api/news-videos'; 190 } 191 } catch (error) { 192 // Fall through to the same-origin endpoint. 193 } 194 return '/api/news-videos'; 195 } 196 197 /** 198 * The clip and poster addresses come back page-relative, which is wrong when 199 * the page itself was opened from disk and the API lives on the deployed 200 * site. Resolved against whatever origin the endpoint is on. 201 */ 202 function resolveMediaUrl(raw) { 203 var value = String(raw || ''); 204 if (!value || /^https?:\/\//i.test(value)) return value; 205 var endpoint = getEndpoint(); 206 if (/^https?:\/\//i.test(endpoint)) { 207 try { 208 return new URL(value, endpoint).toString(); 209 } catch (error) { 210 return value; 211 } 212 } 213 return value; 214 } 215 216 function getSessionToken() { 217 try {
218 if (window.mstApi && typeof window.mstApi.getSessionToken === 'function') { 219 return window.mstApi.getSessionToken() || ''; 220 } 221 return window.sessionStorage.getItem(API_SESSION_TOKEN_KEY) || ''; 222 } catch (error) { 223 return ''; 224 } 225 } 226 227 /* 228 Both staff roles, not admins alone. 229 230 A moderator runs the day to day of the site with an admin, so the 231 controls this file draws belong to either of them. The check is 232 deliberately the same shape as main.js's isAdminOrModeratorSignedIn: 233 a role flag on its own proves nothing, so a session token has to be 234 present as well, and the server decides in the end regardless. 235 */ 236 function isStaffSession() { 237 try { 238 if (!getSessionToken()) return false; 239 // The admin flag is the string '1'; the moderator entry is the 240 // signed-in moderator as JSON, so it is tested for being there at 241 // all rather than for a value. 242 return window.sessionStorage.getItem(ADMIN_SESSION_KEY) === '1' 243 || Boolean(window.sessionStorage.getItem(MODERATOR_SESSION_KEY)); 244 } catch (error) { 245 return false; 246 } 247 } 248 249 function jsonHeaders() { 250 var headers = { 'Content-Type': 'application/json' }; 251 var token = getSessionToken(); 252 if (token) headers['X-Session-Token'] = token; 253 return headers; 254 } 255 256 function root() { 257 return document.getElementById('ourNews'); 258 } 259
260 function beatLabel(key) { 261 for (var index = 0; index < BEATS.length; index += 1) { 262 if (BEATS[index].key === key) return BEATS[index].label; 263 } 264 return 'Community'; 265 } 266 267 function storyById(id) { 268 for (var index = 0; index < stories.length; index += 1) { 269 if (stories[index].id === id) return stories[index]; 270 } 271 return null; 272 } 273 274 function setNotice(message, kind) { 275 notice = message || ''; 276 noticeKind = kind || 'info'; 277 } 278 279 function fileSizeLabel(bytes) { 280 var size = Number(bytes) || 0; 281 if (size >= 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'; 282 if (size >= 1024) return Math.round(size / 1024) + ' KB'; 283 return size + ' B'; 284 } 285 286 /** 287 * How to present a story: 'youtube', 'video', 'audio', 'image' or 'file'. 288 * 289 * The API sends `kind` for exactly this, and the fallback is only here for 290 * a page held in a browser cache from before it did - every story filed 291 * until now was a clip, so that is what an absent kind means. 292 */ 293 function storyKind(story) { 294 if (!story) return 'video'; 295 if (story.youtubeId) return 'youtube'; 296 var kind = String(story.kind || ''); 297 if (kind === 'audio' || kind === 'image' || kind === 'file') return kind; 298 // A story filed as a script with pictures behind it: nothing to play, 299 // nothing to download, and nothing for mediaHtml to draw. 300 if (kind === 'none') return 'none'; 301 return 'video'; 302 } 303 304 /** Is this story something that plays, rather than something that is shown? */ 305 function playsBack(story) { 306 var kind = storyKind(story); 307 return kind === 'youtube' || kind === 'video' || kind === 'audio'; 308 } 309 310 /** 311 * Picture formats every browser can draw. 312 * 313 * A photograph filed straight off an iPhone is often HEIC, and a scan is 314 * sometimes TIFF; both are stored and served untouched, but Chrome and 315 * Firefox will not draw either. For those the reader offers the original as 316 * a typed <source> and lets the browser fall back to the still frame the 317 * desk generated at upload time - which every browser can draw, because a 318 * canvas made it. See featureHtml. 319 */ 320 var UNIVERSAL_IMAGE_TYPES = [ 321 'image/png', 'image/jpeg', 'image/gif', 'image/webp' 322 ]; 323 324 function isUniversalImage(contentType) { 325 var type = String(contentType || '').toLowerCase(); 326 for (var index = 0; index < UNIVERSAL_IMAGE_TYPES.length; index += 1) { 327 if (UNIVERSAL_IMAGE_TYPES[index] === type) return true; 328 } 329 return false; 330 } 331 332 /** Is there something moving to watch, or is the spot free for pictures? */ 333 function hasVideo(story) { 334 var kind = storyKind(story); 335 return kind === 'youtube' || kind === 'video'; 336 } 337 338 /** 339 * The pictures that shuffle through the spot for this story, in order. 340 * 341 * Empty for a story with a clip: a gallery under a playing video competes 342 * with it, and the clip is the better picture. A recording or a document 343 * keeps its player or its download tile and gets the gallery above it, 344 * because neither of those is anything to look at. 345 * 346 * For a story that is itself a photograph, that photograph is the first 347 * slide. It was already the illustration before any of this existed, and 348 * dropping it in favour of the extras would change what the story shows. 349 */ 350 function galleryPhotos(story) { 351 if (!story || hasVideo(story)) return []; 352 353 var out = []; 354 355 if (storyKind(story) === 'image') { 356 var own = resolveMediaUrl(story.videoUrl); 357 var poster = story.posterUrl ? resolveMediaUrl(story.posterUrl) : ''; 358 // The same fallback the single-picture spot makes: a HEIC or a TIFF 359 // is shown as the still the desk generated, which every browser can 360 // draw. Here it has to be one <img>, so the choice is made now 361 // rather than left to a <picture> element. 362 var url = isUniversalImage(story.contentType) || !poster ? own : poster; 363 if (url) out.push({ id: '', url: url, caption: '' }); 364 } 365 366 var photos = (story && story.photos) || []; 367 for (var index = 0;
367 index < photos.length; index += 1) { 368 var photo = photos[index]; 369 var href = resolveMediaUrl(photo && photo.url); 370 if (!href) continue; 371 out.push({ 372 id: String((photo && photo.id) || ''), 373 url: href, 374 caption: String((photo && photo.caption) || '') 375 }); 376 } 377 378 // A story that is itself a photograph and carries no extras is not a 379 // gallery of one - it falls back to the plain still it always had. 380 // Anything else is glad of a single picture above its player. 381 var minimum = storyKind(story) === 'image' ? 2 : 1; 382 return out.length >= minimum ? out.slice(0, MAX_PHOTOS + 1) : []; 383 } 384 385 /** The visitor asked their system for less movement, so nothing moves. */ 386 function reducedMotion() { 387 try { 388 return Boolean(window.matchMedia 389 && window.matchMedia('(prefers-reduced-motion: reduce)').matches); 390 } catch (error) { 391 return false; 392 } 393 } 394 395 /** 396 * A short, upper-case label for a download tile: 'PDF', 'DOCX', 'ZIP'. 397 * 398 * Taken from the file name first, because that is what the reporter and 399 * everybody else calls it, and from the content type only when the name has 400 * no extension to read. 401 */ 402 function formatLabel(story) { 403 var name = String((story && story.fileName) || ''); 404 var dot = name.lastIndexOf('.'); 405 if (dot > -1 && dot < name.length - 1) { 406 var extension = name.slice(dot + 1); 407 if (/^[a-z0-9]{1,6}$/i.test(extension)) return extension.toUpperCase(); 408 } 409 410 var type = String((story && story.contentType) || ''); 411 var subtype = type.split('/')[1] || ''; 412 subtype = subtype.split('+')[0].replace(/^x-/, ''); 413 if (/^[a-z0-9.-]{1,12}$/i.test(subtype) && subtype !== 'octet-stream') { 414 return subtype.toUpperCase(); 415 } 416 return 'FILE'; 417 } 418 419 function durationLabel(seconds) { 420 var total = Math.max(0, Math.round(Number(seconds) || 0)); 421 if (!total) return ''; 422 var minutes = Math.floor(total / 60); 423 var rest = total % 60; 424 return minutes + ':' + (rest < 10 ? '0' : '') + rest; 425 } 426 427 /** 428 * Wraps a change of state in a view transition where the browser has them, 429 * so the poster and the video frame are treated as the same box growing 430 * rather than one element vanishing and another appearing. Everywhere else 431 * the change simply applies and the CSS height transition carries it. 432 */ 433 function withTransition(mutate, after) { 434 function follow() { 435 if (typeof after === 'function') after(); 436 } 437 438 var reduced = false; 439 try { 440 reduced = window.matchMedia 441 && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 442 } catch (error) { 443 reduced = false; 444 } 445 446 if (reduced || typeof document.startViewTransition !== 'function') { 447 mutate(); 448 follow(); 449 return; 450 } 451 452 try { 453 var transition = document.startViewTransition(mutate); 454 // The callback runs when the browser is ready to snapshot, not 455 // immediately, so anything that needs the new DOM has to wait for 456 // it rather than run on the next line. 457 if (transition && transition.updateCallbackDone 458 && typeof transition.updateCallbackDone.then === 'function') { 459 transition.updateCallbackDone.then(follow, follow); 460 } else { 461 follow(); 462 } 463 } catch (error) { 464 mutate(); 465 follow(); 466 } 467 } 468 469 /** 470 * Keeps the spot pointing at a story that is actually on the desk. A story 471 * can be deleted or hidden out from under the pointer, and an empty spot 472 * would leave the top of the page blank, so it falls back to the lead. 473 */ 474 function settleFeatured() { 475 if (featuredId && storyById(featuredId)) return; 476 featuredId = stories.length ? stories[0].id : ''; 477 scriptOpen = false;
478 } 479 480 function featuredStory() { 481 return storyById(featuredId) || (stories.length ? stories[0] : null); 482 } 483 484 /* ------------------------------------------------------------ reading ---- */ 485 486 async function load(force) { 487 if (loaded && !force) return; 488 489 var endpoint = getEndpoint(); 490 var url = manageMode ? endpoint + '?manage=1' : endpoint; 491 492 try { 493 var response = await fetch(url, { 494 method: 'GET', 495 headers: manageMode ? jsonHeaders() : { Accept: 'application/json' }, 496 cache: 'no-store' 497 }); 498 499 if (!response.ok) { 500 // A visitor never needs to be told the desk is unreachable: the 501 // section simply stays hidden. An admin does, because they are 502 // about to try to upload into it. 503 stories = []; 504 loaded = true; 505 loadFailed = true; 506 if (manageMode) setNotice('Could not load the news desk right now.', 'error'); 507 return; 508 } 509 510 var payload = await response.json(); 511 stories = Array.isArray(payload && payload.stories) ? payload.stories : []; 512 if (payload && Number(payload.visibleLimit) > 0) visibleLimit = Number(payload.visibleLimit); 513 loaded = true; 514 loadFailed = false; 515 settleFeatured(); 516 } catch (error) { 517 stories = []; 518 loaded = true; 519 loadFailed = true; 520 if (manageMode) setNotice('Could not reach the news desk.', 'error'); 521 } 522 } 523 524 /* ------------------------------------------------------------ drawing ---- */ 525 526 function scriptHtml(text) { 527 var raw = String(text || '').replace(/\r\n?/g, '\n'); 528 var blocks = raw.split(/\n{2,}/); 529 var out = ''; 530 531 for (var index = 0; index < blocks.length; index += 1) { 532 var block = blocks[index].trim(); 533 if (!block) continue; 534 // Single newlines inside a paragraph are kept as line breaks: a 535 // script is often written a spoken line at a time. 536 out += '<p>' + escapeHtml(block).replace(/\n/g, '<br>') + '</p>'; 537 } 538 539 return out || '<p class="ournews-script-empty">No script on file yet.</p>'; 540 } 541 542 function thumbHtml(story, small) { 543 var poster = resolveMediaUrl(story.posterUrl); 544 var length = durationLabel(story.durationSeconds); 545 var kind = storyKind(story); 546 547 // A still with no poster has to say something. For a clip that is the 548 // station's initials behind a play triangle; for a download it is the 549 // format, which is the one useful thing about a file you cannot preview. 550 var blank = kind === 'file' 551 ? '<span class="ournews-thumb-blank" aria-hidden="true">' 552 + escapeHtml(formatLabel(story)) + '</span>' 553 : '<span class="ournews-thumb-blank" aria-hidden="true">MST</span>'; 554 555 var inner = poster 556 ? '<img src="' + escapeHtml(poster) + '" alt="" loading="lazy" decoding="async">' 557 : blank; 558 559 return '<span class="ournews-thumb' + (small ? ' ournews-thumb-small' : '') + '">' 560 + inner 561 // The triangle is a promise that pressing this plays something, so 562 // it is drawn only where that is true. 563 + (playsBack(story) 564 ? '<span class="ournews-play" aria-hidden="true"></span>' 565 : '') 566 + (length ? '<span class="ournews-length">' + escapeHtml(length) + '</span>' : '') 567 + '</span>'; 568 } 569 570 /** 571 * Whether a story has rolled off the front page into the archive: it is 572 * published, and more than `visibleLimit` published stories sit ahead of 573 * it. The same rule the API applies, so what staff are told matches what 574 * visitors get. 575 */ 576 function isArchived(story) { 577 if (!story.published || story.status !== 'ready') return false; 578 var ahead = 0; 579 for (var index = 0; index < stories.length; index += 1) { 580 var other = stories[index]; 581 if (other === story) break; 582 if (other.published && other.status === 'ready') ahead += 1; 583 } 584 return ahead >= visibleLimit; 585 } 586 587 function adminRowHtml(story, index) { 588 if (!manageMode) return ''; 589 590 var unfinished = story.status !== 'ready'; 591 var state = unfinished 592 ? '<span class="ournews-state ournews-state-wait">Upload unfinished</span>' 593 : (!story.published 594 ? '<span class="ournews-state ournews-state-off">Hidden</span>' 595 : (isArchived(story) 596 ? '<span class="ournews-state ournews-state-archive" ' 597 + 'title="Past the front page\'s ' + visibleLimit + ' stories. ' 598 + 'Readers find it under Local News. Move it up to bring it back.">' 599 + 'In archive</span>' 600 : '<span class="ournews-state ournews-state-live">Showing</span>')); 601 602 return '<div class="ournews-admin-row">' 603 + state 604 + '<span class="ournews-admin-space"></span>' 605 + '<button type="button" class="ournews-mini" data-news-action="move" ' 606 + 'data-id="' + escapeHtml(story.id) + '" data-direction="up" ' 607 + 'aria-label="Move earlier"' + (index <= 0 ? ' disabled' : '') + '>Up</button>' 608 + '<button type="button" class="ournews-mini" data-news-action="move" ' 609 + 'data-id="' + escapeHtml(story.id) + '" data-direction="down" ' 610 + 'aria-label="Move later"' 611 + (index === stories.length - 1 ? ' disabled' : '') + '>Down</button>' 612 + '<button type="button" class="ournews-mini" data-news-action="edit" ' 613 + 'data-id="' + escapeHtml(story.id) + '">Edit</button>' 614 + (unfinished ? '' : '<button type="button" class="ournews-mini" ' 615 + 'data-news-action="publish" data-id="' + escapeHtml(story.id) + '" ' 616 + 'data-next="' + (story.published ? '0' : '1') + '">' 617 + (story.published ? 'Hide' : 'Show') + '</button>') 618 + '<button type="button" class="ournews-mini ournews-mini-danger" ' 619 + 'data-news-action="delete" data-id="' + escapeHtml(story.id) + '">Delete</button>' 620 + '</div>'; 621 } 622 623 /** 624 * Whatever is in the spot at the top of the segment, drawn as what it is. 625 * 626 * One function per kind would be five near-copies of the same wrapper, so 627 * the branch is here and everything around it - the headline, the tags, the 628 * fold - is written once below. 629 * 630 * The media element is always present rather than appearing on a click, 631 * because this segment is the top of the page and the story is the point of 632 * it. A clip still asks for metadata only, so the poster and the duration 633 * arrive without the whole file being pulled down by somebody who scrolls 634 * straight past. 635 */ 636 function mediaHtml(story) { 637 var kind = storyKind(story); 638 // Nothing was uploaded with this one. Its gallery is the spot, and if it 639 // has no pictures either then the headline and the script are the story. 640 if (kind === 'none') return ''; 641 var source = resolveMediaUrl(story.videoUrl); 642 var poster = story.posterUrl ? resolveMediaUrl(story.posterUrl) : ''; 643 644 if (kind === 'youtube') { 645 // Built from the id the server handed back, never from anything that 646 // was pasted, and pointed at the no-cookie host so a visitor who only 647 // scrolls past is not tracked by it. The frame is lazy for the same 648 // reason the uploaded player only asks for metadata: the clip is the 649 // point of the segment, but it is not the point of the whole page. 650 return '<iframe class="ournews-embed" ' 651 + 'src="https://www.youtube-nocookie.com/embed/' 652 + encodeURIComponent(story.youtubeId) + '?rel=0" ' 653 + 'title="' + escapeHtml(story.title || 'News clip') + '" ' 654 + 'loading="lazy" allowfullscreen ' 655 + 'allow="accelerometer; autoplay; clipboard-write; encrypted-media; ' 656 + 'gyroscope; picture-in-picture; web-share" '
657 + 'referrerpolicy="strict-origin-when-cross-origin"></iframe>'; 658 } 659 660 if (kind === 'image') { 661 var alt = escapeHtml(story.title || 'News photograph'); 662 663 // A format this browser can definitely draw: one tag, the original 664 // file, nothing clever. 665 if (isUniversalImage(story.contentType) || !poster) { 666 return '<img class="ournews-still" src="' + escapeHtml(source) + '" ' 667 + 'alt="' + alt + '" loading="lazy" decoding="async">'; 668 } 669 670 // A HEIC off a phone or a TIFF off a scanner. A typed <source> is 671 // skipped outright by a browser that does not support the type, so 672 // Safari gets the full-resolution original and everything else falls 673 // back to the still frame - no scripting, no broken image. 674 return '<picture class="ournews-still-wrap">' 675 + '<source srcset="' + escapeHtml(source) + '" type="' 676 + escapeHtml(story.contentType) + '">' 677 + '<img class="ournews-still" src="' + escapeHtml(poster) + '" ' 678 + 'alt="' + alt + '" loading="lazy" decoding="async">' 679 + '</picture>'; 680 } 681 682 if (kind === 'audio') { 683 return '<div class="ournews-audio">' 684 + '<span class="ournews-audio-mark" aria-hidden="true"></span>' 685 + '<p class="ournews-audio-label">Recording from the scene</p>' 686 + '<audio class="ournews-audio-player" controls preload="metadata" ' 687 + 'src="' + escapeHtml(source) + '">' 688 + 'Your browser cannot play this recording.</audio>' 689 + '</div>'; 690 } 691 692 if (kind === 'file') { 693 // Served with an attachment disposition, so this is a download 694 // however it is pressed. `download` is still set for the name, and 695 // rel is there because the address is treated as a separate document. 696 return '<div class="ournews-file">' 697 + '<span class="ournews-file-badge" aria-hidden="true">' 698 + escapeHtml(formatLabel(story)) + '</span>' 699 + '<p class="ournews-file-name">' 700 + escapeHtml(story.fileName || 'Filed document') + '</p>' 701 + (story.mediaSize 702 ? '<p class="ournews-file-size">' 703 + escapeHtml(fileSizeLabel(story.mediaSize)) + '</p>' 704 : '') 705 + '<a class="ournews-file-link" href="' + escapeHtml(source) + '" ' 706 + 'download="' + escapeHtml(story.fileName || 'news-file') + '" ' 707 + 'rel="noopener">Download this file</a>' 708 + '</div>'; 709 } 710 711 return '<video class="ournews-video" controls playsinline preload="metadata"' 712 + (poster ? ' poster="' + escapeHtml(poster) + '"' : '') + '>' 713 + '<source src="' + escapeHtml(source) + '" type="' 714 + escapeHtml(story.contentType || 'video/mp4') + '">' 715 + 'Your browser cannot play this clip.</video>'; 716 } 717 718 /** 719 * The shuffle itself: every picture stacked in one frame, one of them lit. 720 * 721 * All of them are in the markup from the start and cross-faded by class, 722 * rather than one <img> whose src is swapped. Swapping the source shows a 723 * blank frame the first time round each picture, and a slide show that 724 * flashes white every five seconds is worse than no slide show. The cost is 725 * a handful of requests, and they are lazy below the first. 726 * 727 * The controls are real buttons, so the shuffle is reachable by keyboard 728 * and by a screen reader, and the pause button exists because an animation 729 * a visitor cannot stop is an accessibility failure rather than a flourish. 730 */ 731 function galleryHtml(story, photos) { 732 var slides = ''; 733 var dots = ''; 734 var captioned = false; 735 736 for (var index = 0; index < photos.length; index += 1) { 737 var photo = photos[index]; 738 var current = index === galleryIndex;
739 if (photo.caption) captioned = true; 740 741 var lazily = (index === 0 ? '' : 'loading="lazy" ') + 'decoding="async"'; 742 743 slides += '<div class="ournews-slide' + (current ? ' is-current' : '') 744 + '" data-slide="' + index + '"' 745 + (current ? '' : ' aria-hidden="true"') + '>' 746 // The same picture twice: once blown up, blurred and cropped to 747 // fill the frame, and once whole on top of it. A photograph held 748 // upright - which is how a phone takes one - is narrower than 749 // the 16:9 spot, and the bars either side of it used to be flat 750 // navy. Now they are the picture's own colours, out of focus. 751 // Empty alt and hidden: it carries nothing the one above it does 752 // not, and a screen reader should not read the story twice. 753 + '<img class="ournews-fill" src="' + escapeHtml(photo.url) 754 + '" alt="" aria-hidden="true" ' + lazily + '>' 755 + '<img class="ournews-shot" src="' + escapeHtml(photo.url) + '" ' 756 // The caption when there is one, and the headline when there is 757 // not: a picture illustrating a story is described by the story. 758 + 'alt="' + escapeHtml(photo.caption || story.title 759 || 'News photograph') + '" ' + lazily + '>' 760 + '</div>'; 761 762 dots += '<button type="button" class="ournews-dot' 763 + (current ? ' is-current' : '') + '" data-news-action="photoDot" ' 764 + 'data-slide="' + index + '"' 765 + (current ? ' aria-current="true"' : '') 766 + ' aria-label="Show picture ' + (index + 1) + ' of ' 767 + photos.length + '"></button>'; 768 } 769 770 var many = photos.length > 1; 771 772 return '<div class="ournews-gallery" data-count="' + photos.length + '">' 773 + '<div class="ournews-slides">' + slides + '</div>' 774 + (captioned 775 ? '<p class="ournews-gallery-caption">' 776 + escapeHtml(photos[galleryIndex] ? photos[galleryIndex].caption : '') 777 + '</p>' 778 : '') 779 + (many 780 ? '<div class="ournews-gallery-bar">' 781 + '<button type="button" class="ournews-gallery-arrow" ' 782 + 'data-news-action="photoPrev" aria-label="Previous picture">' 783 + '<span aria-hidden="true">\u2039</span></button>' 784 + '<div class="ournews-dots">' + dots + '</div>' 785 + '<button type="button" class="ournews-gallery-arrow" ' 786 + 'data-news-action="photoNext" aria-label="Next picture">' 787 + '<span aria-hidden="true">\u203a</span></button>' 788 + (reducedMotion() 789 ? '' 790 : '<button type="button" class="ournews-gallery-pause" ' 791 + 'data-news-action="photoPause" aria-pressed="' 792 + (galleryPaused ? 'true' : 'false') + '">' 793 + (galleryPaused ? 'Play' : 'Pause') + '</button>') 794 + '</div>' 795 : '') 796 + '</div>'; 797 } 798 799 /* ------------------------------------------------------- the shuffle ---- */ 800 801 function stopGallery() { 802 if (!galleryTimer) return; 803 window.clearTimeout(galleryTimer); 804 galleryTimer = 0; 805 } 806 807 /** 808 * Arms the next advance, and is called at the end of every redraw. 809 * 810 * A redraw replaces the whole segment's markup, so a timer left running 811 * from before it would be pointing at elements that no longer exist. One 812 * timeout at a time, re-armed from whatever is on the page now. 813 */ 814 function syncGallery() { 815 stopGallery(); 816 817 var host = root(); 818 var gallery = host ? host.querySelector('.ournews-gallery') : null; 819 if (!gallery) return; 820 821 var count = Number(gallery.getAttribute('data-count')) || 0; 822 if (count < 2 || galleryPaused || reducedMotion()) return; 823 824 galleryTimer = window.setTimeout(function () { 825 galleryTimer = 0; 826 showSlide(galleryIndex + 1); 827 }, GALLERY_MS); 828 } 829 830 /** 831 * Lights a different picture. 832 * 833 * The DOM is touched directly rather than through render(), because a 834 * redraw every five seconds would restart a playing recording, rebuild the 835 * admin form under whoever was typing in it, and throw away the focus ring. 836 * Nothing outside the gallery changes, so nothing outside it is rebuilt. 837 */ 838 function showSlide(next) { 839 var host = root(); 840 var gallery = host ? host.querySelector('.ournews-gallery') : null; 841 if (!gallery) return; 842
843 var slides = gallery.querySelectorAll('.ournews-slide'); 844 if (!slides.length) return; 845 846 var count = slides.length; 847 galleryIndex = ((Number(next) || 0) % count + count) % count; 848 849 for (var index = 0; index < count; index += 1) { 850 var current = index === galleryIndex; 851 slides[index].classList.toggle('is-current', current); 852 if (current) slides[index].removeAttribute('aria-hidden'); 853 else slides[index].setAttribute('aria-hidden', 'true'); 854 } 855 856 var dots = gallery.querySelectorAll('.ournews-dot'); 857 for (var dot = 0; dot < dots.length; dot += 1) { 858 var lit = dot === galleryIndex; 859 dots[dot].classList.toggle('is-current', lit); 860 if (lit) dots[dot].setAttribute('aria-current', 'true'); 861 else dots[dot].removeAttribute('aria-current'); 862 } 863 864 var caption = gallery.querySelector('.ournews-gallery-caption'); 865 if (caption) { 866 var photos = galleryPhotos(featuredStory()); 867 var photo = photos[galleryIndex]; 868 caption.textContent = photo ? photo.caption : ''; 869 } 870 871 syncGallery(); 872 } 873 874 function featureHtml(story, index) { 875 var where = [story.location, story.reporter ? 'by ' + story.reporter : ''] 876 .filter(function (part) { return Boolean(part); }) 877 .join(' - '); 878 879 var photos = galleryPhotos(story); 880 var kind = storyKind(story); 881 // With a gallery, a photograph story's own picture is already the first 882 // slide, so drawing the still as well would show it twice. A recording 883 // or a download keeps its own element underneath: the gallery is what 884 // there is to look at, and that is what there is to press. 885 var video = photos.length 886 ? galleryHtml(story, photos) + (kind === 'image' ? '' : mediaHtml(story)) 887 : mediaHtml(story); 888 var length = durationLabel(story.durationSeconds); 889 890 return '<article class="ournews-feature ournews-beat-' 891 + escapeHtml(story.category) + '" data-id="' + escapeHtml(story.id) + '">' 892 // A written story with no pictures yet has nothing to put in the 893 // spot, and an empty black rectangle over the headline reads as a 894 // clip that failed to load. So the spot is left out entirely. 895 + (video 896 ? '<div class="ournews-spot ournews-spot-' + escapeHtml(kind) 897 + (photos.length ? ' ournews-spot-gallery' : '') + '">' 898 + video + '</div>' 899 : '') 900 + '<div class="ournews-feature-text">' 901 + '<p class="ournews-tags">' 902 + '<span class="ournews-beat">' + escapeHtml(beatLabel(story.category)) 903 + '</span>' 904 + (where ? '<span class="ournews-where">' + escapeHtml(where) + '</span>' : '') 905 + (length ? '<span class="ournews-where">' + escapeHtml(length) + '</span>' : '') 906 + '</p>' 907 + '<h3 class="ournews-headline">' + escapeHtml(story.title) + '</h3>' 908 + (story.summary 909 ? '<p class="ournews-summary">' + escapeHtml(story.summary) + '</p>' 910 : '') 911 + '<button type="button" class="ournews-more' 912 + (scriptOpen ? ' is-open' : '') + '" data-news-action="script" ' 913 + 'aria-expanded="' + (scriptOpen ? 'true' : 'false') + '" ' 914 + 'aria-controls="ournewsScript">' 915 + '<span>' + (scriptOpen ? 'Hide the script' : 'Read the script') 916 + '</span>' 917 + '<span class="ournews-caret" aria-hidden="true"></span>' 918 + '</button>' 919 + '<div class="ournews-drop' + (scriptOpen ? ' is-open' : '') 920 + '" id="ournewsScript"' + (scriptOpen ? '' : ' hidden') + '>' 921 + '<div class="ournews-drop-inner">' 922 + '<h4 class="ournews-script-title">Script</h4>' 923 + '<div class="ournews-script">' + scriptHtml(story.script) + '</div>' 924 + '</div>' 925 + '</div>' 926 + adminRowHtml(story, index) 927 + '</div>' 928 + '</article>'; 929 } 930 931 /** 932 * The rest of the desk, as a row of stills under the spot. Pressing one 933 * moves that story into the spot, so there is only ever one player on the 934 * page and only one clip being fetched. 935 */ 936 function pickerHtml(featured) { 937 if (stories.length < 2) return ''; 938 939 var items = ''; 940 for (var index = 0; index < stories.length; index += 1) { 941 var story = stories[index]; 942 var current = story.id === featured.id; 943 944 items += '<li>' 945 + '<button type="button" class="ournews-pick' 946 + (current ? ' is-current' : '') + '" data-news-action="feature" ' 947 + 'data-id="' + escapeHtml(story.id) + '"' 948 + (current ? ' aria-current="true"' : '') + '>' 949 + thumbHtml(story, true) 950 + '<span class="ournews-pick-text">' 951 + '<span class="ournews-pick-beat">' 952 + escapeHtml(beatLabel(story.category)) + '</span>' 953 + '<span class="ournews-pick-title">' + escapeHtml(story.title) 954 + '</span>' 955 + '</span>' 956 + '</button>' 957 + '</li>'; 958 } 959 960 return '<div class="ournews-picker">' 961 + '<h4 class="ournews-picker-title">More from the newsroom</h4>' 962 + '<ul class="ournews-picker-list">' + items + '</ul>' 963 + '</div>'; 964 } 965 966 /** 967 * The pictures already on a story, with the three things the desk needs to 968 * do to them: retitle one, move one, drop one. 969 * 970 * Ordered exactly as they shuffle, and numbered, because "picture 3" is how 971 * somebody looking at the spot describes the one they want changed. 972 */ 973 function photoAdminHtml(story) { 974 var photos = (story && story.photos) || []; 975 if (!photos.length) { 976 return '<p class="ournews-hint">No pictures on this story yet. Add some ' 977 + 'above and they shuffle through the spot whenever there is no ' 978 + 'video to play.</p>'; 979 } 980 981 var rows = ''; 982 for (var index = 0;
982 index < photos.length; index += 1) { 983 var photo = photos[index]; 984 var id = String(photo.id || ''); 985 var url = resolveMediaUrl(photo.url); 986 987 rows += '<li class="ournews-photo-row">' 988 + '<img class="ournews-photo-thumb" src="' + escapeHtml(url) + '" ' 989 + 'alt="" loading="lazy" decoding="async">' 990 + '<span class="ournews-photo-main">' 991 + '<span class="ournews-photo-count">Picture ' + (index + 1) 992 + ' of ' + photos.length + '</span>' 993 + '<input type="text" class="ournews-photo-caption" maxlength="200" ' 994 + 'id="ournewsCaption-' + escapeHtml(id) + '" ' 995 + 'placeholder="Caption (optional)" ' 996 + 'value="' + escapeHtml(photo.caption || '') + '">' 997 + '</span>' 998 + '<span class="ournews-photo-actions">' 999 + '<button type="button" class="ournews-mini" ' 1000 + 'data-news-action="photoMove" data-id="' + escapeHtml(story.id) + '" ' 1001 + 'data-photo="' + escapeHtml(id) + '" data-direction="up" ' 1002 + 'aria-label="Move this picture earlier"' 1003 + (index <= 0 ? ' disabled' : '') + '>Up</button>' 1004 + '<button type="button" class="ournews-mini" ' 1005 + 'data-news-action="photoMove" data-id="' + escapeHtml(story.id) + '" ' 1006 + 'data-photo="' + escapeHtml(id) + '" data-direction="down" ' 1007 + 'aria-label="Move this picture later"' 1008 + (index === photos.length - 1 ? ' disabled' : '') + '>Down</button>' 1009 + '<button type="button" class="ournews-mini" ' 1010 + 'data-news-action="photoCaption" data-id="' + escapeHtml(story.id) + '" ' 1011 + 'data-photo="' + escapeHtml(id) + '">Save caption</button>' 1012 + '<button type="button" class="ournews-mini ournews-mini-danger" ' 1013 + 'data-news-action="photoDelete" data-id="' + escapeHtml(story.id) + '" ' 1014 + 'data-photo="' + escapeHtml(id) + '">Remove</button>' 1015 + '</span>' 1016 + '</li>'; 1017 } 1018 1019 return '<div class="ournews-photo-admin">' 1020 + '<h4 class="ournews-photo-title">Pictures on this story</h4>' 1021 + '<ul class="ournews-photo-list">' + rows + '</ul>' 1022 + '</div>'; 1023 } 1024 1025 function beatOptionsHtml(selected) { 1026 var out = ''; 1027 for (var index = 0; index < BEATS.length; index += 1) { 1028 var beat = BEATS[index]; 1029 out += '<option value="' + escapeHtml(beat.key) + '"' 1030 + (beat.key === selected ? ' selected' : '') + '>' 1031 + escapeHtml(beat.label) + '</option>'; 1032 } 1033 return out; 1034 } 1035 1036 function formHtml() { 1037 if (!manageMode || !formOpen) return ''; 1038 1039 var editing = editingId ? storyById(editingId) : null; 1040 var values = draft || editing || {}; 1041 // Read off the story rather than the draft: the draft holds what is 1042 // typed in the form, and no text field says whether bytes are stored. 1043 var hasUpload = Boolean(editing && Number(editing.videoSize) > 0); 1044 1045 return '<form class="ournews-form" id="ournewsForm"' 1046 + (editing ? ' data-editing="' + escapeHtml(editing.id) + '"' : '') + '>' 1047 + '<h3 class="ournews-form-title">' 1048 + (editing ? 'Edit this story' : 'Add a story to the desk') + '</h3>' 1049 + '<div class="ournews-field-row">' 1050 + '<label class="ournews-field">' 1051 + '<span>Headline</span>' 1052 + '<input type="text" name="title" maxlength="160" required ' 1053 + 'value="' + escapeHtml(values.title || '') + '">' 1054 + '</label>' 1055 + '<label class="ournews-field ournews-field-small">' 1056 + '<span>Beat</span>' 1057 + '<select name="category">' + beatOptionsHtml(values.category || 'community') 1058 + '</select>' 1059 + '</label>' 1060 + '</div>' 1061 + '<div class="ournews-field-row">' 1062 + '<label class="ournews-field">' 1063 + '<span>Where</span>' 1064 + '<input type="text" name="location" maxlength="120" ' 1065 + 'placeholder="McAlester, Krebs, Hartshorne" ' 1066 + 'value="' + escapeHtml(values.location || '') + '">' 1067 + '</label>' 1068 + '<label class="ournews-field">' 1069 + '<span>Filed by</span>' 1070 + '<input type="text" name="reporter" maxlength="120" ' 1071 + 'value="' + escapeHtml(values.reporter || '') + '">' 1072 + '</label>' 1073 + '</div>' 1074 + '<label class="ournews-field">' 1075 + '<span>Summary</span>' 1076 + '<input type="text" name="summary" maxlength="400" '
1077 + 'placeholder="A line or two under the headline" ' 1078 + 'value="' + escapeHtml(values.summary || '') + '">' 1079 + '</label>' 1080 + '<label class="ournews-field">' 1081 + '<span>Script</span>' 1082 + '<textarea name="script" rows="8" required ' 1083 + 'placeholder="What happened, where, and the wording somebody can read on air.">' 1084 + escapeHtml(values.script || '') + '</textarea>' 1085 + '<small class="ournews-hint">At least ' + MIN_SCRIPT 1086 + ' characters. This is the copy a reporter picks the story up from.</small>' 1087 + '</label>' 1088 // Two ways to give the story a clip, and either one is enough. Neither 1089 // input is marked required, because the pair of them is what is 1090 // required - the browser cannot express that, so the check is done on 1091 // submit where it can name which one is missing. 1092 + '<label class="ournews-field">' 1093 + '<span>YouTube link' + (editing ? '' : ' (instead of a file)') + '</span>' 1094 + '<input type="url" name="youtubeUrl" maxlength="300" ' 1095 + 'placeholder="https://www.youtube.com/watch?v=..." ' 1096 + 'value="' + escapeHtml( 1097 values.youtubeUrl != null 1098 ? values.youtubeUrl 1099 : (values.youtubeId 1100 ? 'https://www.youtube.com/watch?v=' + values.youtubeId 1101 : '') 1102 ) + '">' 1103 + '<small class="ournews-hint">Paste a watch, share or Shorts link and the ' 1104 + 'clip plays from YouTube - nothing is uploaded here and there is no size ' 1105 + 'limit.</small>' 1106 + '</label>' 1107 // Offered while editing as well, so a clip can be swapped without 1108 // retyping the story. Leaving it empty on an edit changes nothing: 1109 // the file already behind the story stays where it is. 1110 + '<label class="ournews-field">' 1111 + '<span>' + (editing 1112 ? (hasUpload ? 'Replace the file' : 'Upload a file') 1113 : 'Or upload a file') + '</span>' 1114 + '<input type="file" name="video">' 1115 + '<small class="ournews-hint">' 1116 + (editing && hasUpload 1117 ? 'Now: ' + escapeHtml(editing.fileName || 'a file') + '. Choosing ' 1118 + 'another one replaces it - the old file and its still frame ' 1119 + 'are deleted, and the story comes off the page until the new ' 1120 + 'one has finished uploading. Leave this empty to keep it. ' 1121 : '') 1122 + 'Any format at all - a video, a photograph, a recording or a ' 1123 + 'document. Video and audio can be up to 512 MB and play on the ' 1124 + 'page; a picture is shown on the page and anything else is offered ' 1125 + 'as a download, both up to 16 MB. It uploads in pieces, so leave ' 1126 + 'the page open until it finishes.</small>' 1127 + '</label>' 1128 // Offered on a new story and on an existing one alike, unlike the 1129 // file above: pictures are added to a story that has been on the 1130 // desk for a week as often as they are filed with it. 1131 + '<label class="ournews-field">' 1132 + '<span>Pictures for the story</span>' 1133 // The extensions are listed beside image/* because Windows names no 1134 // type for a HEIC off an iPhone, and a picker filtered to image/* 1135 // alone will not offer a file the system could not classify. 1136 + '<input type="file" name="photos" ' 1137 + 'accept="image/*,.heic,.heif,.tif,.tiff,.avif" multiple>' 1138 + '<span class="ournews-photo-ready" id="ournewsPhotoReady" role="status">' 1139 + escapeHtml(pendingLabel()) + '</span>' 1140 + '<small class="ournews-hint">Up to ' + MAX_PHOTOS + ' pictures, any ' 1141 + 'format - each one is shrunk and converted here before it is sent, so ' 1142 + 'a photograph straight off a phone is fine. When the story has no ' 1143 + 'video, these shuffle through the spot at the top of the page. A ' 1144 + 'story can be filed with pictures alone, or with nothing but its ' 1145 + 'script - no clip and no link are needed.</small>' 1146 + '</label>' 1147 + (editing ? photoAdminHtml(editing) : '') 1148 + (progressText 1149 ? '<p class="ournews-progress" role="status">' + escapeHtml(progressText) + '</p>' 1150 : '') 1151 + '<div class="ournews-form-actions">' 1152 + '<button type="submit" class="ournews-primary"' + (busy ? ' disabled' : '') + '>' 1153 + (busy ? 'Working...' : (editing ? 'Save changes' : 'Upload story')) + '</button>' 1154 + '<button type="button" class="ournews-secondary" data-news-action="cancel"' 1155 + (busy ? ' disabled' : '') + '>Cancel</button>' 1156 + '</div>' 1157 + '</form>'; 1158 } 1159 1160 /** 1161 * The part of the segment that does not depend on the fetch. 1162 * 1163 * Shared with the placeholder below so the heading a visitor sees a moment 1164 * after the page arrives is character for character the heading that was 1165 * already there, and nothing shifts when the stories land. 1166 */ 1167 function headHtml() { 1168 return '<div class="ournews-head">' 1169 + '<p class="ournews-kicker">Our News</p>' 1170 + '<h2>Happening in <span>McAlester</span></h2>' 1171 + '<p class="ournews-lede">Local emergency stories and the events that ' 1172 + 'shape our town - reported, filmed and scripted by our own ' 1173 + 'newsroom.</p>' 1174 + (manageMode && !formOpen 1175 ? '<button type="button" class="ournews-primary" data-news-action="new">' 1176 + 'Add a story</button>' 1177 : '') 1178 + '</div>'; 1179 } 1180 1181 /** 1182 * Draw the segment before its stories exist. 1183 * 1184 * The section used to be empty markup with `hidden` on it until the fetch 1185 * landed, so on any visit that did not play the intro - every visit after 1186 * the first in a session - the page arrived without this segment and grew 1187 * it a moment later. Now the heading and a placeholder for the story are 1188 * painted with the rest of the page, and the story replaces the placeholder 1189 * in the space already reserved for it. The section is still taken off the 1190 * page afterwards if the desk turns out to be unreachable. 1191 */ 1192 function skeleton() { 1193 var host = root(); 1194 if (!host || host.innerHTML.trim()) return; 1195 1196 host.hidden = false;
1197 host.innerHTML = '<div class="ournews-inner">' 1198 + headHtml() 1199 + '<div class="ournews-skeleton" aria-hidden="true">' 1200 + '<div class="ournews-skeleton-video"></div>' 1201 + '<div class="ournews-skeleton-lines">' 1202 + '<span></span><span></span><span></span>' 1203 + '</div>' 1204 + '</div>' 1205 + '<p class="ournews-skeleton-status" role="status">Loading today\'s stories...</p>' 1206 + '</div>'; 1207 } 1208 1209 function render() { 1210 var host = root(); 1211 if (!host) return; 1212 1213 // The segment is public, so it is drawn for everyone - signed in or not, 1214 // with stories on the desk or none yet. The only thing that takes it off 1215 // the page is the desk being unreachable: an admin gets told why, and a 1216 // visitor gets the rest of the page rather than a heading over an error 1217 // we cannot explain to them. 1218 if (loadFailed && !stories.length && !manageMode) { 1219 host.hidden = true; 1220 host.innerHTML = ''; 1221 return; 1222 } 1223 1224 host.hidden = false; 1225 1226 settleFeatured(); 1227 1228 var featured = featuredStory(); 1229 var index = featured ? stories.indexOf(featured) : -1; 1230 1231 // The story in the spot may have lost pictures since the last draw - a 1232 // deleted one, or a story swapped underneath - so the index is brought 1233 // back inside the list before anything is drawn from it. 1234 var slideCount = galleryPhotos(featured).length; 1235 if (galleryIndex >= slideCount) galleryIndex = 0; 1236 1237 var body = featured 1238 ? featureHtml(featured, index) + pickerHtml(featured) 1239 : '<p class="ournews-empty">' 1240 + (manageMode 1241 ? 'No stories on the desk yet. Add the first one and it takes ' 1242 + 'the spot at the top of the page.' 1243 : 'No stories on the desk right now. Check back soon - this is ' 1244 + 'where our McAlester coverage lands.') 1245 + '</p>'; 1246 1247 host.innerHTML = '<div class="ournews-inner">' 1248 + headHtml() 1249 + (notice 1250 ? '<p class="ournews-notice ournews-notice-' + escapeHtml(noticeKind) + '" ' 1251 + 'role="status">' + escapeHtml(notice) + '</p>' 1252 : '') 1253 + formHtml() 1254 + body 1255 + '</div>'; 1256 1257 if (draft && formOpen) restoreDraft(); 1258 1259 // Last, and after the markup exists: the advance is armed against the 1260 // elements that were just written, never against the ones replaced. 1261 syncGallery(); 1262 } 1263 1264 /* -------------------------------------------------------------- drafts --- */ 1265 1266 function formEl() { 1267 return document.getElementById('ournewsForm'); 1268 } 1269 1270 function readDraft() { 1271 var form = formEl(); 1272 if (!form) return null; 1273 return { 1274 title: form.elements.title ? form.elements.title.value : '', 1275 summary: form.elements.summary ? form.elements.summary.value : '', 1276 category: form.elements.category ? form.elements.category.value : 'community', 1277 location: form.elements.location ? form.elements.location.value : '', 1278 reporter: form.elements.reporter ? form.elements.reporter.value : '', 1279 script: form.elements.script ? form.elements.script.value : '', 1280 youtubeUrl: form.elements.youtubeUrl ? form.elements.youtubeUrl.value : '' 1281 }; 1282 } 1283 1284 /** 1285 * A redraw replaces the form, and a progress update redraws on every part of 1286 * a long upload, so the typed copy is put back rather than lost. 1287 */ 1288 function restoreDraft() { 1289 var form = formEl(); 1290 if (!form || !draft) return; 1291 var fields = [ 1292 'title', 'summary', 'category', 'location', 'reporter', 'script', 'youtubeUrl' 1293 ]; 1294 for (var index = 0; index < fields.length; index += 1) { 1295 var name = fields[index]; 1296 if (form.elements[name] && draft[name] != null) {
1297 form.elements[name].value = draft[name]; 1298 } 1299 } 1300 } 1301 1302 /* --------------------------------------------------------- the poster ---- */ 1303 1304 /** 1305 * The still every card shows, taken in the browser before anything is 1306 * uploaded. 1307 * 1308 * The cards need one small image each so a page of stories does not open a 1309 * media stream per card, and the only place a frame can be had without 1310 * decoding on a server is here. A clip is seeked and drawn; a photograph is 1311 * decoded and shrunk, which doubles as the fallback that lets a HEIC or a 1312 * TIFF story show up in a browser that cannot draw the original at all. 1313 * 1314 * Best effort by design, for every kind: a file whose still cannot be read 1315 * still uploads, and its card falls back to a plain tile. 1316 */ 1317 function grabStill(file) { 1318 var type = String((file && file.type) || ''); 1319 if (/^image\//i.test(type)) return grabImagePoster(file); 1320 if (/^video\//i.test(type) || !type) return grabVideoPoster(file); 1321 // A recording or a document has no frame in it to find. 1322 return Promise.resolve({ poster: null, duration: 0 }); 1323 } 1324 1325 /** 1326 * A photograph, decoded and drawn down to a bounded JPEG. 1327 * 1328 * `createImageBitmap` is tried first because it handles the formats a phone 1329 * produces and needs nothing in the document; an `img` element is the 1330 * fallback, and between them they cover everything a browser can decode at 1331 * all. A format this browser cannot decode - a TIFF in Chrome - resolves 1332 * with nothing rather than failing whatever asked. 1333 * 1334 * Used twice, at two sizes: for the small still every card shows, and for 1335 * the pictures in a story's gallery. Both want the same thing - a file any 1336 * browser can draw, no larger than it needs to be - so both come from here 1337 * rather than from two canvases that would drift apart. 1338 */ 1339 function shrinkImageToJpeg(file, maxEdge, quality) { 1340 function drawFrom(source, width, height) { 1341 if (!width || !height) return null; 1342 // The long edge is what is bounded, so a tall picture is brought 1343 // down as far as a wide one rather than being left full size. 1344 var scale = Math.min(1, maxEdge / Math.max(width, height)); 1345 var canvas = document.createElement('canvas'); 1346 canvas.width = Math.max(1, Math.round(width * scale)); 1347 canvas.height = Math.max(1, Math.round(height * scale)); 1348 var context = canvas.getContext('2d'); 1349 // Flattened onto white, so a transparent PNG does not become a 1350 // black rectangle on a dark card. 1351 context.fillStyle = '#ffffff'; 1352 context.fillRect(0, 0, canvas.width, canvas.height); 1353 context.imageSmoothingQuality = 'high'; 1354 context.drawImage(source, 0, 0, canvas.width, canvas.height); 1355 return canvas; 1356 } 1357 1358 function encode(canvas) { 1359 return new Promise(function (resolve) { 1360 if (!canvas || typeof canvas.toBlob !== 'function') { 1361 resolve(null); 1362 return; 1363 } 1364 canvas.toBlob(function (blob) { 1365 resolve(blob || null); 1366 }, 'image/jpeg', quality); 1367 }); 1368 } 1369 1370 if (typeof window.createImageBitmap === 'function') { 1371 return window.createImageBitmap(file).then(function (bitmap) { 1372 var canvas = drawFrom(bitmap, bitmap.width, bitmap.height); 1373 if (typeof bitmap.close === 'function') bitmap.close(); 1374 return encode(canvas); 1375 }, function () { 1376 return shrinkViaElement(file, drawFrom, encode); 1377 }); 1378 } 1379 1380 return shrinkViaElement(file, drawFrom, encode); 1381 } 1382 1383 function shrinkViaElement(file, drawFrom, encode) { 1384 return new Promise(function (resolve) { 1385 var url = ''; 1386 var image = new Image(); 1387 1388 function finish(result) { 1389 try { 1390 if (url) URL.revokeObjectURL(url); 1391 } catch (error) { 1392 // The URL goes with the page. 1393 } 1394 resolve(result || null); 1395 } 1396 1397 image.onload = function () { 1398 var canvas = null; 1399 try {
1400 canvas = drawFrom(image, image.naturalWidth, image.naturalHeight); 1401 } catch (error) { 1402 canvas = null; 1403 } 1404 encode(canvas).then(finish, function () { finish(null); }); 1405 }; 1406 image.onerror = function () { finish(null); }; 1407 1408 try { 1409 url = URL.createObjectURL(file); 1410 image.src = url; 1411 } catch (error) { 1412 finish(null); 1413 } 1414 }); 1415 } 1416 1417 function grabImagePoster(file) { 1418 function plain() { 1419 return { poster: null, duration: 0 }; 1420 } 1421 return shrinkImageToJpeg(file, 640, 0.82).then(function (blob) { 1422 return { poster: blob || null, duration: 0 }; 1423 }, plain); 1424 } 1425 1426 function grabVideoPoster(file) { 1427 return new Promise(function (resolve) { 1428 var objectUrl = ''; 1429 var video = document.createElement('video'); 1430 var settled = false; 1431 var timer = 0; 1432 1433 function done(poster, duration) { 1434 if (settled) return; 1435 settled = true; 1436 if (timer) window.clearTimeout(timer); 1437 try { 1438 if (objectUrl) URL.revokeObjectURL(objectUrl); 1439 } catch (error) { 1440 // Nothing to do; the URL goes with the page. 1441 } 1442 resolve({ poster: poster || null, duration: Math.max(0, Math.round(duration || 0)) }); 1443 } 1444 1445 function draw() { 1446 if (settled) return; 1447 var seconds = Number(video.duration) || 0; 1448 try { 1449 var width = video.videoWidth; 1450 var height = video.videoHeight; 1451 if (!width || !height) return done(null, seconds); 1452 1453 var scale = Math.min(1, 640 / width); 1454 var canvas = document.createElement('canvas'); 1455 canvas.width = Math.max(1, Math.round(width * scale)); 1456 canvas.height = Math.max(1, Math.round(height * scale)); 1457 canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); 1458 1459 if (typeof canvas.toBlob !== 'function') return done(null, seconds); 1460 canvas.toBlob(function (blob) { 1461 done(blob, seconds); 1462 }, 'image/jpeg', 0.82); 1463 } catch (error) { 1464 done(null, seconds); 1465 } 1466 } 1467 1468 video.muted = true; 1469 video.preload = 'metadata'; 1470 video.playsInline = true; 1471 1472 video.addEventListener('error', function () { 1473 done(null, 0); 1474 }); 1475 1476 video.addEventListener('loadeddata', function () { 1477 // A phone clip often starts on a black or blurred frame, so the 1478 // still is taken a moment in rather than at zero. 1479 var seconds = Number(video.duration) || 0; 1480 var at = Math.min(1.5, seconds / 10); 1481 if (at > 0.05) { 1482 try { 1483 video.currentTime = at; 1484 return; 1485 } catch (error) { 1486 // Seeking refused; take whatever frame is loaded. 1487 } 1488 } 1489 draw(); 1490 }); 1491 1492 video.addEventListener('seeked', draw); 1493 1494 // A file the browser will neither decode nor error on would 1495 // otherwise hang the upload before it starts. 1496 timer = window.setTimeout(function () { 1497 done(null, Number(video.duration) || 0); 1498 }, 15000); 1499 1500 try { 1501 objectUrl = URL.createObjectURL(file); 1502 video.src = objectUrl; 1503 video.load(); 1504 } catch (error) { 1505 done(null, 0); 1506 } 1507 }); 1508 } 1509 1510 /* --------------------------------------------------------- API writes ---- */ 1511 1512 async function postJson(payload) { 1513 var response = await fetch(getEndpoint(), { 1514 method: 'POST', 1515 headers: jsonHeaders(), 1516 body: JSON.stringify(payload) 1517 }); 1518 1519 var data = null; 1520 try { 1521 data = await response.json(); 1522 } catch (error) { 1523 data = null; 1524 } 1525 1526 if (!response.ok) { 1527 throw new Error((data && data.error) || 'That did not go through.'); 1528 } 1529 return data || {}; 1530 } 1531 1532 async function postPart(videoId, kind, index, blob, fileName) { 1533 var form = new FormData(); 1534 form.append('videoId', videoId); 1535 form.append('kind', kind); 1536 if (index != null) form.append('index', String(index)); 1537 // The body is built before the headers here, so the token travels as a 1538 // field; the function reads it from either place. 1539 form.append('sessionToken', getSessionToken()); 1540 form.append('file', blob, fileName); 1541 1542 var response = await fetch(getEndpoint(), { method: 'POST', body: form }); 1543 1544 var data = null; 1545 try { 1546 data = await response.json(); 1547 } catch (error) { 1548 data = null; 1549 } 1550 1551 if (!response.ok) { 1552 throw new Error((data && data.error) || 'A piece of the upload failed.'); 1553 } 1554 return data || {}; 1555 } 1556 1557 /** 1558 * Files a story with nothing to transfer: one that lives on YouTube, and one 1559 * that is a script illustrated by pictures. 1560 * 1561 * Both are a single request - the server publishes the story on the spot, 1562 * and none of the chunk, still-frame or finish machinery below has anything 1563 * to do. Pictures are sent afterwards, against the id this returns. 1564 */ 1565 async function fileStory(fields) { 1566 setNotice('', ''); 1567 progressText = 'Filing the story...'; 1568 render(); 1569 1570 var started = await postJson({ 1571 op: 'begin', 1572 title: fields.title, 1573 summary: fields.summary, 1574 category: fields.category, 1575 location: fields.location, 1576 reporter: fields.reporter, 1577 script: fields.script, 1578 youtubeUrl: fields.youtubeUrl 1579 }); 1580 1581 var videoId = started.story && started.story.id; 1582 if (!videoId) throw new Error('The story could not be filed.'); 1583 return videoId; 1584 } 1585 1586 async function uploadStory(fields, file) { 1587 setNotice('', ''); 1588 progressText = 'Reading the file...'; 1589 render(); 1590 1591 var frame = await grabStill(file); 1592 1593 progressText = 'Starting the upload...'; 1594 render(); 1595 1596 var started = await postJson({ 1597 op: 'begin', 1598 title: fields.title, 1599 summary: fields.summary, 1600 category: fields.category, 1601 location: fields.location, 1602 reporter: fields.reporter, 1603 script: fields.script, 1604 // Sent as the browser reported it, empty included: the API turns a 1605 // file it cannot name into a download rather than refusing it, and 1606 // guessing 'video/mp4' here would make it try to play a spreadsheet. 1607 contentType: file.type || '', 1608 fileName: file.name || 'filed-file', 1609 videoSize: file.size, 1610 durationSeconds: frame.duration 1611 }); 1612 1613 var videoId = started.story && started.story.id; 1614 var chunkSize = Number(started.chunkSize) || 0; 1615 var chunkCount = Number(started.chunkCount) || 0; 1616 if (!videoId || !chunkSize || !chunkCount) { 1617 throw new Error('The upload could not be started.'); 1618 } 1619 1620 await sendBytes(videoId, file, frame, chunkSize, chunkCount); 1621 return videoId; 1622 } 1623 1624 /** 1625 * The pieces, the still and the word that it is done. 1626 * 1627 * Identical whether the story is new or is having its file swapped, so both 1628 * paths hand the bytes over here rather than keeping a copy of the loop. 1629 */ 1630 async function sendBytes(videoId, file, frame, chunkSize, chunkCount) { 1631 for (var index = 0; index < chunkCount; index += 1) { 1632 var from = index * chunkSize; 1633 var to = Math.min(file.size, from + chunkSize); 1634 progressText = 'Uploading ' + (index + 1) + ' of ' + chunkCount 1635 + ' (' + fileSizeLabel(file.size) + ')'; 1636 render(); 1637 await postPart(videoId, 'chunk', index, file.slice(from, to), 'part'); 1638 } 1639 1640 if (frame.poster) { 1641 progressText = 'Uploading the still frame...'; 1642 render(); 1643 try { 1644 await postPart(videoId, 'poster', null, frame.poster, 'poster.jpg'); 1645 } catch (error) { 1646 // A missing still costs a plain tile, not the story. 1647 } 1648 } 1649 1650 progressText = 'Publishing...'; 1651 render(); 1652 await postJson({ op: 'finish', videoId: videoId }); 1653 } 1654 1655 /** 1656 * Puts a different file behind a story that is already on the desk. 1657 * 1658 * Everything else about the story - its place in the order, its pictures, 1659 * the day it was filed - is left alone. The story is taken off the page b
1659y 1660 * the API while the pieces are in flight and put back by 'finish', so a 1661 * visitor never meets a half-replaced clip. 1662 */ 1663 async function replaceStory(videoId, file) { 1664 progressText = 'Reading the new file...'; 1665 render(); 1666 1667 var frame = await grabStill(file); 1668 1669 progressText = 'Starting the replacement...'; 1670 render(); 1671 1672 var started = await postJson({ 1673 op: 'replace', 1674 videoId: videoId, 1675 contentType: file.type || '', 1676 fileName: file.name || 'filed-file', 1677 videoSize: file.size, 1678 durationSeconds: frame.duration 1679 }); 1680 1681 var chunkSize = Number(started.chunkSize) || 0; 1682 var chunkCount = Number(started.chunkCount) || 0; 1683 if (!chunkSize || !chunkCount) { 1684 throw new Error('The replacement could not be started.'); 1685 } 1686 1687 await sendBytes(videoId, file, frame, chunkSize, chunkCount); 1688 } 1689 1690 /* ---------------------------------------------------------- pictures ---- */ 1691 1692 /** What the form says about pictures chosen but not yet sent. */ 1693 function pendingLabel() { 1694 if (!pendingPhotos.length) return ''; 1695 var names = []; 1696 for (var index = 0; index < pendingPhotos.length; index += 1) { 1697 names.push(pendingPhotos[index].name || 'picture'); 1698 } 1699 return (pendingPhotos.length === 1 1700 ? '1 picture ready to add: ' 1701 : pendingPhotos.length + ' pictures ready to add: ') + names.join(', '); 1702 } 1703 1704 /** 1705 * Records a new selection without redrawing. 1706 * 1707 * A redraw here would throw away whatever file is in the input above - 1708 * choosing pictures would silently cancel a chosen clip - so the one line 1709 * that has to change is changed where it stands. 1710 */ 1711 function handleChange(event) { 1712 var input = event.target; 1713 if (!input || input.name !== 'photos' || input.type !== 'file') return; 1714 if (!root() || !root().contains(input)) return; 1715 1716 pendingPhotos = filesFrom(input); 1717 var label = document.getElementById('ournewsPhotoReady'); 1718 if (label) label.textContent = pendingLabel(); 1719 } 1720 1721 /** A FileList, as a plain array that survives the form being redrawn. */ 1722 function filesFrom(input) { 1723 var picked = input && input.files ? input.files : null; 1724 var out = []; 1725 for (var index = 0; picked && index < picked.length; index += 1) { 1726 out.push(picked[index]); 1727 } 1728 return out; 1729 } 1730 1731 /** The name a prepared picture is filed under, with the new extension. */ 1732 function photoName(file) { 1733 var name = String((file && file.name) || 'picture'); 1734 return name.replace(/\.[^.]+$/, '') + '.jpg'; 1735 } 1736 1737 /** 1738 * One picture, made into something every browser can draw. 1739 * 1740 * Re-encoded here rather than uploaded as it came, which is what lets the 1741 * gallery accept a photograph in any format at all: a HEIC off an iPhone or 1742 * a TIFF off a scanner is a JPEG by the time it leaves this page, and the 1743 * API only ever stores the four formats a slide can safely be. 1744 * 1745 * The original is kept instead when it is already one of those four and is 1746 * the smaller of the two - re-encoding a small PNG can easily make it 1747 * bigger, and there is no sense in sending the worse copy. A picture this 1748 * browser cannot decode at all resolves as nothing and is reported as 1749 * skipped rather than failing the whole story. 1750 */ 1751 function preparePhoto(file) { 1752 var usable = isUniversalImage(file && file.type) 1753 && file.size > 0 1754 && file.size <= MAX_PHOTO_BYTES; 1755 1756 return shrinkImageToJpeg(file, PHOTO_EDGE, 0.85).then(function (blob) { 1757 if (blob && blob.size > 0 && blob.size <= MAX_PHOTO_BYTES) { 1758 if (usable && file.size <= blob.size) { 1759 return { blob: file, name: file.name || 'picture' }; 1760 } 1761 return { blob: blob, name: photoName(file) }; 1762 } 1763 if (usable) return { blob: file, name: file.name || 'picture' }; 1764 return null; 1765 }, function () { 1766 return usable ? { blob: file, name: file.name || 'picture' } : null; 1767 }); 1768 } 1769 1770 /** 1771 * Walks the chosen pictures up, one request each, and returns how many were 1772 * skipped. 1773 * 1774 * Sequential on purpose: each one is a separate request against the same 1775 * story, and the API appends them in the order they arrive. Sending them at 1776 * once would shuffle the order the desk chose them in. 1777 * 1778 * A picture that fails does not take the story with it - the story is 1779 * already filed by the time this runs, and the desk is told what is 1780 * missing so it can try that one again. 1781 */ 1782 async function uploadPhotos(videoId, files) { 1783 var total = Math.min(files.length, MAX_PHOTOS); 1784 var skipped = 0; 1785 1786 for (var index = 0; index < total; index += 1) { 1787 progressText = 'Adding picture ' + (index + 1) + ' of ' + total + '...'; 1788 render(); 1789 1790 var prepared = null; 1791 try { 1792 prepared = await preparePhoto(files[index]); 1793 } catch (error) { 1794 prepared = null; 1795 } 1796 1797 if (!prepared) { 1798 skipped += 1; 1799 continue; 1800 } 1801 1802 try { 1803 await postPart(videoId, 'photo', null, prepared.blob, prepared.name); 1804 } catch (error) { 1805 skipped += 1; 1806 } 1807 } 1808 1809 return { attempted: total, skipped: skipped, ignored: files.length - total }; 1810 } 1811 1812 /** What to tell the desk after a batch of pictures went up. */ 1813 function photoOutcome(result) { 1814 var parts = []; 1815 if (result.skipped) { 1816 parts.push(result.skipped === 1 1817 ? 'One picture could not be added.' 1818 : result.skipped + ' pictures could not be added.'); 1819 } 1820 if (result.ignored > 0) { 1821 parts.push('A story shuffles through ' + MAX_PHOTOS 1822 + ' pictures at most, so the rest were left off.'); 1823 } 1824 return parts.join(' '); 1825 } 1826 1827 /* ------------------------------------------------------------ actions ---- */ 1828 1829 /** 1830 * Moves a story into the spot. The script folds shut on the way, because 1831 * an open script belonging to the previous story would read as this one's. 1832 */ 1833 function featureStory(id) { 1834 if (!storyById(id) || featuredId === id) return; 1835 1836 withTransition(function () { 1837 featuredId = id; 1838 scriptOpen = false;
1839 // A new story in the spot starts at its first picture, and starts 1840 // shuffling again: a pause was aimed at the story being left. 1841 galleryIndex = 0; 1842 galleryPaused = false; 1843 stopGallery(); 1844 render(); 1845 }, function () { 1846 var host = root(); 1847 var video = host ? host.querySelector('.ournews-video') : null; 1848 if (video && typeof video.play === 'function') { 1849 // Swapping the spot was a click, so playing is allowed here; a 1850 // refusal is harmless because the controls are right there. 1851 var attempt = video.play(); 1852 if (attempt && typeof attempt.catch === 'function') { 1853 attempt.catch(function () {}); 1854 } 1855 } 1856 }); 1857 } 1858 1859 /** 1860 * Unfolds or folds the script under the spot. The clip is left alone: the 1861 * point of the fold is to read along while it plays. 1862 */ 1863 function toggleScript(open) { 1864 if (scriptOpen === open) return; 1865 1866 withTransition(function () { 1867 scriptOpen = open; 1868 render(); 1869 }, function () { 1870 var host = root(); 1871 var button = host ? host.querySelector('.ournews-more') : null; 1872 if (button && typeof button.focus === 'function') button.focus(); 1873 1874 if (!open) return; 1875 var drop = host ? host.querySelector('.ournews-drop') : null; 1876 if (drop && typeof drop.scrollIntoView === 'function') { 1877 try { 1878 drop.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 1879 } catch (error) { 1880 drop.scrollIntoView(); 1881 } 1882 } 1883 }); 1884 } 1885 1886 async function runAction(work, successMessage) { 1887 if (busy) return; 1888 busy = true; 1889 render(); 1890 1891 try { 1892 await work(); 1893 loaded = false; 1894 await load(true); 1895 if (successMessage) setNotice(successMessage, 'ok'); 1896 } catch (error) { 1897 setNotice((error && error.message) || 'That did not work.', 'error'); 1898 } finally { 1899 busy = false; 1900 progressText = ''; 1901 render(); 1902 } 1903 } 1904 1905 function handleClick(event) { 1906 var trigger = event.target && event.target.closest 1907 ? event.target.closest('[data-news-action]') 1908 : null; 1909 if (!trigger) return; 1910 if (!root() || !root().contains(trigger)) return; 1911 1912 var action = trigger.getAttribute('data-news-action'); 1913 var id = trigger.getAttribute('data-id') || ''; 1914 1915 if (action === 'feature') { 1916 event.preventDefault(); 1917 featureStory(id); 1918 return; 1919 } 1920 1921 if (action === 'script') { 1922 event.preventDefault(); 1923 toggleScript(!scriptOpen); 1924 return; 1925 } 1926 1927 if (action === 'photoNext' || action === 'photoPrev') { 1928 event.preventDefault(); 1929 showSlide(galleryIndex + (action === 'photoNext' ? 1 : -1)); 1930 return; 1931 } 1932 1933 if (action === 'photoDot') { 1934 event.preventDefault(); 1935 showSlide(Number(trigger.getAttribute('data-slide')) || 0); 1936 return; 1937 } 1938 1939 if (action === 'photoPause') { 1940 event.preventDefault(); 1941 galleryPaused = !galleryPaused; 1942 // The button is relabelled where it stands rather than by redrawing 1943 // the segment, for the same reason the slides are: a redraw here 1944 // would take the focus off the button that was just pressed. 1945 trigger.setAttribute('aria-pressed', galleryPaused ? 'true' : 'false'); 1946 trigger.textContent = galleryPaused ? 'Play' : 'Pause'; 1947 syncGallery(); 1948 return; 1949 } 1950 1951 if (!manageMode) return; 1952 1953 if (action === 'new') { 1954 event.preventDefault(); 1955 editingId = ''; 1956 draft = null; 1957 pendingPhotos = []; 1958 formOpen = true; 1959 setNotice('', ''); 1960 render(); 1961 var form = formEl(); 1962 if (form && form.elements.title) form.elements.title.focus(); 1963 return; 1964 } 1965 1966 if (action === 'cancel') { 1967 event.preventDefault(); 1968 if (busy) return; 1969 formOpen = false;
1970 editingId = ''; 1971 draft = null; 1972 pendingPhotos = []; 1973 progressText = ''; 1974 render(); 1975 return; 1976 } 1977 1978 if (action === 'edit') { 1979 event.preventDefault(); 1980 editingId = id; 1981 draft = null; 1982 pendingPhotos = []; 1983 formOpen = true; 1984 setNotice('', ''); 1985 render(); 1986 return; 1987 } 1988 1989 if (action === 'publish') { 1990 event.preventDefault(); 1991 var next = trigger.getAttribute('data-next') === '1'; 1992 runAction(function () { 1993 return postJson({ op: 'update', videoId: id, published: next }); 1994 }, next ? 'The story is showing on the page.' : 'The story is hidden.'); 1995 return; 1996 } 1997 1998 if (action === 'move') { 1999 event.preventDefault(); 2000 var direction = trigger.getAttribute('data-direction') || 'up'; 2001 runAction(function () { 2002 return postJson({ op: 'move', videoId: id, direction: direction }); 2003 }); 2004 return; 2005 } 2006 2007 if (action === 'photoMove') { 2008 event.preventDefault(); 2009 var way = trigger.getAttribute('data-direction') || 'up'; 2010 runAction(function () { 2011 return postJson({ 2012 op: 'photoMove', 2013 videoId: id, 2014 photoId: trigger.getAttribute('data-photo') || '', 2015 direction: way 2016 }); 2017 }); 2018 return; 2019 } 2020 2021 if (action === 'photoCaption') { 2022 event.preventDefault(); 2023 var photoId = trigger.getAttribute('data-photo') || ''; 2024 var field = document.getElementById('ournewsCaption-' + photoId); 2025 var text = field ? field.value : ''; 2026 runAction(function () { 2027 return postJson({ 2028 op: 'photoCaption', 2029 videoId: id, 2030 photoId: photoId, 2031 caption: text 2032 }); 2033 }, 'The caption was saved.'); 2034 return; 2035 } 2036 2037 if (action === 'photoDelete') { 2038 event.preventDefault(); 2039 if (!window.confirm('Remove this picture from the story?')) return; 2040 runAction(function () { 2041 return postJson({ 2042 op: 'photoDelete', 2043 videoId: id, 2044 photoId: trigger.getAttribute('data-photo') || '' 2045 }); 2046 }, 'The picture was removed.'); 2047 return; 2048 } 2049 2050 if (action === 'delete') { 2051 event.preventDefault(); 2052 var story = storyById(id); 2053 var name = story && story.title ? story.title : 'this story'; 2054 // Deleting takes the clip with it, so it asks first. 2055 if (!window.confirm('Delete "' + name + '" and its video for good?')) return; 2056 if (featuredId === id) featuredId = ''; 2057 runAction(function () { 2058 return postJson({ op: 'delete', videoId: id }); 2059 }, 'The story and its video were removed.'); 2060 } 2061 } 2062 2063 function handleSubmit(event) { 2064 var form = event.target; 2065 if (!form || form.id !== 'ournewsForm') return; 2066 2067 event.preventDefault(); 2068 if (busy) return; 2069 2070 var fields = readDraft() || {}; 2071 draft = fields; 2072 2073 if (!String(fields.title || '').trim()) { 2074 setNotice('Give the story a headline.', 'error'); 2075 render(); 2076 return; 2077 } 2078 2079 if (String(fields.script || '').trim().length < MIN_SCRIPT) { 2080 setNotice( 2081 'Write the script that goes with the clip - at least ' + MIN_SCRIPT
2082 + ' characters. That is what this section is for.', 2083 'error' 2084 ); 2085 render(); 2086 return; 2087 } 2088 2089 var editing = form.getAttribute('data-editing') || ''; 2090 // Read off the form before anything redraws it: a progress update 2091 // replaces the markup, and with it the input holding the selection. 2092 var photoFiles = filesFrom(form.elements.photos); 2093 // The input is empty after a redraw, so the held selection stands in. 2094 if (!photoFiles.length) photoFiles = pendingPhotos.slice(); 2095 var photoNote = ''; 2096 2097 var input = form.elements.video; 2098 var file = input && input.files && input.files[0]; 2099 var link = String(fields.youtubeUrl || '').trim(); 2100 2101 if (file) { 2102 var chosen = limitFor(file); 2103 if (file.size > chosen.bytes) { 2104 setNotice(chosen.message, 'error'); 2105 render(); 2106 return; 2107 } 2108 } 2109 2110 // Both given: the link wins, and it says so rather than quietly picking. 2111 // Uploading a file that is then never played would waste a long wait. 2112 if (file && link) { 2113 setNotice( 2114 'Use one or the other - clear the YouTube link to upload the file ' 2115 + 'instead.', 2116 'error' 2117 ); 2118 render(); 2119 return; 2120 } 2121 2122 if (editing) { 2123 var patch = { 2124 op: 'update', 2125 videoId: editing, 2126 title: fields.title, 2127 summary: fields.summary, 2128 category: fields.category, 2129 location: fields.location, 2130 reporter: fields.reporter, 2131 script: fields.script 2132 }; 2133 2134 // Sent unless a file is replacing the clip, so an admin can move a 2135 // story onto YouTube or onto a different clip by editing the one 2136 // field. Emptying it on a story with an uploaded file is a no-op; 2137 // emptying it on one that has nothing else to play is refused, with 2138 // a reason. It is left out when a file is coming because that same 2139 // refusal would land on a YouTube story being turned into an 2140 // uploaded one - the link is cleared by the replacement itself, 2141 // which happens after this and knows there are bytes behind it. 2142 if (!file) patch.youtubeUrl = fields.youtubeUrl; 2143 2144 runAction(function () { 2145 return postJson(patch).then(function () { 2146 // The words first, then the bytes. A refused headline costs 2147 // nothing; a refused headline after a 400 MB upload costs 2148 // the upload, and the file would have to be chosen again. 2149 if (!file) return null; 2150 return replaceStory(editing, file); 2151 }).then(function () { 2152 if (!photoFiles.length) return null; 2153 return uploadPhotos(editing, photoFiles); 2154 }).then(function (result) { 2155 photoNote = result ? photoOutcome(result) : ''; 2156 }); 2157 }, file ? 'The story and its file were updated.' : 'The story was updated.') 2158 .then(function () { 2159 if (noticeKind === 'ok' && photoNote) { 2160 setNotice(notice + ' ' + photoNote, 'ok'); 2161 } 2162 // Only cleared on success: a rejected save has to leave the 2163 // copy on screen, or the edit is gone along with the error. 2164 if (noticeKind !== 'ok') return; 2165 formOpen = false; 2166 editingId = ''; 2167 draft = null; 2168 pendingPhotos = []; 2169 render(); 2170 }); 2171 return; 2172 } 2173 2174 // Nothing else is demanded of a new story. It is a headline and a 2175 // script, and both of those were checked above; a clip, a link and a 2176 // set of pictures are all ways of illustrating one. A written story
2177 // goes on the desk with none of them and takes pictures later, which 2178 // is what the Pictures field on this form is for. 2179 2180 runAction(function () { 2181 return Promise.resolve(file ? uploadStory(fields, file) : fileStory(fields)) 2182 .then(function (videoId) { 2183 // After the story exists, because a picture is filed against 2184 // a story id. A picture that fails here leaves the story on 2185 // the desk and says which one is missing. 2186 if (!videoId || !photoFiles.length) return null; 2187 return uploadPhotos(videoId, photoFiles); 2188 }) 2189 .then(function (result) { 2190 photoNote = result ? photoOutcome(result) : ''; 2191 }); 2192 }, 'The story is on the desk.').then(function () { 2193 if (noticeKind === 'ok' && photoNote) setNotice(notice + ' ' + photoNote, 'ok'); 2194 if (noticeKind === 'ok') { 2195 formOpen = false; 2196 draft = null; 2197 pendingPhotos = []; 2198 render(); 2199 } 2200 }); 2201 } 2202 2203 function handleKeyDown(event) { 2204 if (event.key !== 'Escape' || !scriptOpen) return; 2205 var host = root(); 2206 if (!host || !host.contains(document.activeElement)) return; 2207 toggleScript(false); 2208 } 2209 2210 /* --------------------------------------------------------------- boot ---- */ 2211 2212 async function start() { 2213 var host = root(); 2214 if (!host) return; 2215 2216 var wasAdmin = manageMode; 2217 manageMode = isStaffSession(); 2218 if (wasAdmin !== manageMode) { 2219 loaded = false; 2220 formOpen = false; 2221 editingId = ''; 2222 draft = null; 2223 } 2224 2225 await load(true); 2226 render(); 2227 } 2228 2229 /** 2230 * Hold the page's reveal until the first load is done. 2231 * 2232 * This section is empty markup until its fetch lands, so without this the 2233 * intro lifts on a page with a gap in it and the news grows into that gap a 2234 * moment later. A page with no gate - and a browser where main.js never 2235 * ran - gets a no-op, which is exactly the behaviour there was before. 2236 */ 2237 function claimReveal() { 2238 var gate = window.MSTPageReady; 2239 if (!gate || typeof gate.claim !== 'function') return function () {}; 2240 try { 2241 return gate.claim(); 2242 } catch (error) { 2243 return function () {}; 2244 } 2245 } 2246 2247 function init() { 2248 document.addEventListener('click', handleClick); 2249 document.addEventListener('submit', handleSubmit); 2250 document.addEventListener('change', handleChange); 2251 document.addEventListener('keydown', handleKeyDown); 2252 2253 document.addEventListener('mst:auth-change', function () { 2254 scriptOpen = false; 2255 notice = ''; 2256 start(); 2257 }); 2258 2259 // Released either way: a desk that found nothing, or could not be 2260 // reached at all, still has to let the page through. 2261 // Painted first, so the segment is part of the page from the first 2262 // frame rather than something that appears after it. 2263 skeleton(); 2264 2265 var release = claimReveal(); 2266 start().then(release, release); 2267 } 2268 2269 /* ------------------------------------------------------------ archive --- */ 2270 2271 /* 2272 The news bank: every story that has rolled off the front page, read 2273 under Local News. 2274 2275 Drawn by this file rather than main.js because a story is this file's 2276 shape - its beats, its script paragraphs, its media addresses - and a 2277 second copy of those rules would drift. main.js only says where. 2278 2279 Deliberately plainer than the desk. The archive is somewhere to look a 2280 story up, not to watch it, so a card is the still, the headline, the 2281 filing details and the script folded under a <details>; the media opens 2282 in a new tab from its own address rather than as another player. 2283 */ 2284 2285 function filedLabel(iso) { 2286 var date = new Date(iso); 2287 if (Number.isNaN(date.getTime())) return ''; 2288 return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); 2289 } 2290 2291 function archiveMediaLink(story) { 2292 if (story.youtubeId) { 2293 return 'https://www.youtube.com/watch?v=' + encodeURIComponent(story.youtubeId); 2294 } 2295 return story.videoUrl ? resolveMediaUrl(story.videoUrl) : ''; 2296 } 2297 2298 function archiveMediaLabel(story) { 2299 var kind = storyKind(story); 2300 if (kind === 'video' || kind === 'youtube') return 'Watch the clip'; 2301 if (kind === 'audio') return 'Listen to the recording'; 2302 if (kind === 'image') return 'View the picture'; 2303 return 'Open ' + formatLabel(story); 2304 } 2305 2306 function archiveItemHtml(story) { 2307 var poster = resolveMediaUrl(story.posterUrl); 2308 var meta = [beatLabel(story.category), story.location, filedLabel(story.filedAt)] 2309 .filter(function (part) { return Boolean(part); }) 2310 .map(escapeHtml) 2311 .join(' · '); 2312 var media = archiveMediaLink(story); 2313 2314 return '<article class="newsbank-item">' 2315 + (poster 2316 ? '<img class="newsbank-thumb" src="' + escapeHtml(poster) + '" alt="" loading="lazy" decoding="async">' 2317 : '<span class="newsbank-thumb newsbank-thumb-blank" aria-hidden="true">MST</span>') 2318 + '<div class="newsbank-text">' 2319 + '<p class="newsbank-meta">' + meta + '</p>' 2320 + '<h4 class="newsbank-title">' + escapeHtml(story.title) + '</h4>' 2321 + (story.summary ? '<p class="newsbank-summary">' + escapeHtml(story.summary) + '</p>' : '') 2322 + (story.reporter ? '<p class="newsbank-byline">Reported by ' + escapeHtml(story.reporter) + '</p>' : '') 2323 + '<details class="newsbank-script"><summary>Read the full story</summary>' 2324 + '<div class="newsbank-script-body">' + scriptHtml(story.script) + '</div>' 2325 + '</details>' 2326 + (media 2327 ? '<a class="newsbank-media" href="' + escapeHtml(media) + '" target="_blank" rel="noopener">' 2328 + escapeHtml(archiveMediaLabel(story)) + '</a>' 2329 : '') 2330 + '</div>' 2331 + '</article>'; 2332 } 2333 2334 async function fetchArchive(offset) { 2335 var response = await fetch(getEndpoint() + '?archive=1&offset=' + encodeURIComponent(offset), { 2336 method: 'GET', 2337 headers: { Accept: 'application/json' }, 2338 cache: 'no-store' 2339 }); 2340 if (!response.ok) throw new Error('Archive request failed'); 2341 return response.json(); 2342 } 2343 2344 /** 2345 * Fill `container` with the archive and a "Show older stories" button for 2346 * the pages after the first. Safe to call again on the same container: each 2347 * call starts over, and a stale response from an earlier call is dropped. 2348 */ 2349 async function renderArchive(container) { 2350 if (!container) return; 2351 var token = String(Date.now()) + Math.random(); 2352 container.setAttribute('data-newsbank-token', token); 2353 container.innerHTML = '<p class="newsbank-status" role="status">Loading the news archive...</p>'; 2354 2355 var offset = 0; 2356 2357 async function loadPage() { 2358 var payload = await fetchArchive(offset); 2359 if (container.getAttribute('data-newsbank-token') !== token) return; 2360 2361 var list = container.querySelector('.newsbank-list'); 2362 var page = Array.isArray(payload && payload.stories) ? payload.stories : []; 2363 2364 if (!list) { 2365 if (!page.length) { 2366 container.innerHTML = '<p class="newsbank-status">Stories move here once they roll off ' 2367 + 'the front page. Nothing has been archived yet.</p>'; 2368 return; 2369 } 2370 container.innerHTML = '<p class="newsbank-count"></p>' 2371 + '<div class="newsbank-list"></div>' 2372 + '<button type="button" class="newsbank-more">Show older stories</button>'; 2373 list = container.querySelector('.newsbank-list'); 2374 } 2375 2376 list.insertAdjacentHTML('beforeend', page.map(archiveItemHtml).join('')); 2377 offset += page.length; 2378 2379 var total = Number(payload.total) || offset; 2380 container.querySelector('.newsbank-count').textContent = total === 1 2381 ? '1 archived story' 2382 : total + ' archived stories'; 2383 container.querySelector('.newsbank-more').hidden = !payload.hasMore; 2384 } 2385 2386 container.onclick = function (event) { 2387 var more = event.target.closest && event.target.closest('.newsbank-more'); 2388 if (!more) return; 2389 // Kept out of the surrounding modal's own click handling. 2390 event.preventDefault(); 2391 event.stopPropagation(); 2392 more.disabled = true; 2393 more.textContent = 'Loading...'; 2394 loadPage().catch(function () {}).then(function () { 2395 more.disabled = false;
2396 more.textContent = 'Show older stories'; 2397 }); 2398 }; 2399 2400 try { 2401 await loadPage(); 2402 } catch (error) { 2403 if (container.getAttribute('data-newsbank-token') !== token) return; 2404 container.innerHTML = '<p class="newsbank-status">The news archive could not be loaded right now.</p>'; 2405 } 2406 } 2407 2408 window.MSTNewsDesk = { 2409 refresh: function () { 2410 loaded = false; 2411 return start(); 2412 }, 2413 renderArchive: renderArchive 2414 }; 2415 2416 if (document.readyState === 'loading') { 2417 document.addEventListener('DOMContentLoaded', init, { once: true }); 2418 } else { 2419 init(); 2420 } 2421})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.