PageSourceSearch

https://mcalesterradio.com/news-desk.js?v=20260924a

js mcalesterradio.com collected 2026-10-08 09:09:36 UTC 101,600 bytes, 2,421 lines download raw bytes

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, '&lt;')
177            .replace(/>/g, '&gt;')
178            .replace(/"/g, '&quot;')
179            .replace(/'/g, '&#39;');
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(' &middot; ');
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.