1// Lightbox: images in article content, and links to video files (played in a 2// centered <dialog> with the Bunny Stream embed player). 3// CSP: requires frame-src https://iframe.mediadelivery.net (set in app.ts). 4 5const BUNNY_LIBRARY = "733733"; 6const embed = (id) => `https://iframe.mediadelivery.net/embed/${BUNNY_LIBRARY}/${id}`; 7 8const VIDEOS = { 9 "/flexq.mov": { src: embed("d970269b-ade1-438a-91aa-88bf285bd1a6"), w: 1080, h: 720, inset: 12 }, 10 "/syom.mp4": { src: embed("019ee1db-9207-48a2-b252-c4365fd99e2d"), w: 1716, h: 1080 }, 11 "/odara.com.mp4": { src: embed("fc708a86-0d03-47bc-b174-63637e8cb250"), w: 1022, h: 720 }, 12 "/odara.com.webm": { src: embed("fc708a86-0d03-47bc-b174-63637e8cb250"), w: 1022, h: 720 }, 13 "/psi-vision.mp4": { src: embed("fbe4338f-f4d2-4cd9-9730-1fa965b28559"), w: 1048, h: 720 }, 14 "/vibelab-welcome.mov": { src: embed("8b76791e-84c3-45b8-a8ab-4362b83f3629"), w: 640, h: 403 }, 15 "/vibelab-aiproxy.mov": { src: embed("87f3ba06-8217-4cb5-a59d-00a7d0c70245"), w: 640, h: 363 }, 16 "/vibelab-mds.mov": { src: embed("4c0cdfaa-290f-47ad-a19e-fa406b9e7db6"), w: 640, h: 413 }, 17 "/vibelab-postlaunch.mov": { src: embed("56a3773c-24d3-44b4-8c5f-fa1b6d034c48"), w: 640, h: 404 }, 18 "/vibelab-multi.mov": { src: embed("3a6c3fb5-36a5-4716-98f3-fa9ba6cdbaf0"), w: 640, h: 405 }, 19 "/ade.mov": { src: embed("972e8ecb-a3aa-4a75-849e-5e43756093c3"), w: 640, h: 403 }, 20}; 21 22const IMAGE_LIGHTBOX = ` 23dialog.image-lightbox { 24 border: none; 25 padding: 0; 26 background: transparent; 27 max-width: 92vw; 28 max-height: 86vh; 29 inset: 0; 30 margin: auto; 31 overflow: visible; 32 cursor: zoom-out; 33} 34dialog.image-lightbox img { 35 display: block; 36 max-width: 92vw; 37 max-height: 86vh; 38 width: auto; 39 height: auto; 40 border-radius: 1rem; 41 box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); 42} 43dialog.image-lightbox[open], 44dialog.video-lightbox[open] { 45 animation: lightbox-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1); 46} 47dialog.image-lightbox[open]::backdrop, 48dialog.video-lightbox[open]::backdrop { 49 animation: lightbox-fade 200ms ease; 50} 51dialog.image-lightbox.closing, 52dialog.video-lightbox.closing { 53 animation: lightbox-in 160ms ease reverse; 54} 55dialog.image-lightbox.closing::backdrop, 56dialog.video-lightbox.closing::backdrop { 57 animation: lightbox-fade 160ms ease reverse; 58} 59@keyframes lightbox-in { 60 from { opacity: 0; transform: scale(0.92); } 61} 62@keyframes lightbox-fade { 63 from { opacity: 0; } 64} 65@media (prefers-reduced-motion: reduce) { 66 dialog.image-lightbox[open], dialog.video-lightbox[open], 67 dialog.image-lightbox[open]::backdrop, dialog.video-lightbox[open]::backdrop { 68 animation: none; 69 } 70} 71`; 72 73const VIDEO_LIGHTBOX = ` 74dialog.video-lightbox { 75 border: none; 76 padding: 0; 77 background: transparent; 78 max-width: 92vw; 79 max-height: 86vh; 80 inset: 0; 81 margin: auto; 82 position: relative; 83 overflow: hidden; 84 border-radius: 0.75rem; 85 background: #000; 86} 87dialog.video-lightbox iframe { 88 position: absolute; 89 border: none; 90 background: #000; 91 display: block; 92} 93dialog.video-lightbox .close { 94 position: absolute; 95 top: 0.5rem; 96 right: 0.75rem; 97 z-index: 1; 98 border: none; 99 background: none; 100 color: #fff; 101 font-size: 2rem; 102 line-height: 1; 103 cursor: pointer; 104 opacity: 0.8; 105 text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); 106} 107dialog.video-lightbox .close:hover { opacity: 1; } 108dialog.image-lightbox::backdrop, 109dialog.video-lightbox::backdrop { 110 background: transparent; 111 backdrop-filter: blur(9px); 112 -webkit-backdrop-filter: blur(9px); 113} 114article .content img { cursor: zoom-in; } 115`; 116 117document.head.appendChild(Object.assign(document.createElement("style"), { textContent: IMAGE_LIGHTBOX + VIDEO_LIGHTBOX })); 118 119const autoplay = document.body.dataset.play; 120if (autoplay && VIDEOS[autoplay]) openVideoDialog(VIDEOS[autoplay]); 121 122// Animated close: play the reverse animation before removing the dialog. 123function closeAnimated(dialog) { 124 if (dialog.classList.contains("closing")) return; 125 dialog.classList.add("closing"); 126 dialog.addEventListener("animationend", () => dialog.close(), { once: true }); 127 // Reduced motion: no animation fires, so close straight away. 128 setTimeout(() => dialog.open && dialog.close(), 400); 129} 130
131// Click anywhere closes: the image itself, the letterbox area, the backdrop. 132function dismissOnClick(dialog) { 133 dialog.addEventListener("click", () => closeAnimated(dialog)); 134 dialog.addEventListener("close", () => dialog.remove()); 135} 136 137function openImageDialog(source) { 138 const dialog = document.createElement("dialog"); 139 dialog.className = "image-lightbox"; 140 const image = document.createElement("img"); 141 image.src = source.currentSrc || source.src; 142 image.alt = source.alt; 143 image.width = source.naturalWidth; 144 image.height = source.naturalHeight; 145 dialog.appendChild(image); 146 dismissOnClick(dialog); 147 document.body.appendChild(dialog); 148 dialog.showModal(); 149} 150 151function openVideoDialog(video) { 152 const i = video.inset ?? 0; 153 const w = video.w - 2 * i; 154 const h = video.h - 2 * i; 155 const dialog = document.createElement("dialog"); 156 dialog.className = "video-lightbox"; 157 dialog.style.aspectRatio = `${w} / ${h}`; 158 dialog.style.width = `min(92vw, ${(86 * w) / h}vh)`; 159 dialog.innerHTML = ` 160 <iframe src="${video.src}?autoplay=true" style="width: ${(100 * video.w) / w}%; height: ${(100 * video.h) / h}%; left: ${(-100 * i) / w}%; top: ${(-100 * i) / h}%;" allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture" allowfullscreen></iframe> 161 <button class="close" type="button" aria-label="Close video">×</button> 162 `; 163 dismissOnClick(dialog); 164 document.body.appendChild(dialog); 165 dialog.showModal(); 166} 167 168document.addEventListener("click", (event) => { 169 const image = event.target.closest("article .content img"); 170 if (image) { 171 event.preventDefault(); 172 openImageDialog(image); 173 return; 174 } 175 const link = event.target.closest("a[href]"); 176 if (!link) return; 177 const url = new URL(link.href, location.origin); 178 const video = VIDEOS[url.pathname]; 179 if (!video) return; 180 event.preventDefault(); 181 openVideoDialog(video); 182});
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.