1/* Shared rendering functions. The static build uses the same templates as the browser. */ 2function searchValueText(value){ 3 if(value == null) return ''; 4 if(Array.isArray(value)) return value.map(searchValueText).join(' '); 5 if(typeof value === 'object'){ 6 return [value.label, value.url, value.text].filter(Boolean).join(' '); 7 } 8 return String(value); 9} 10 11function catalogueSlug(work){ 12 return work.slug || work.title.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); 13} 14 15function filmSlug(work){ 16 return work.slug || work.title.toLowerCase() 17 .normalize('NFD').replace(/[\u0300-\u036f]/g,'') 18 .replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); 19} 20 21function flatCatalogueWorks(){ 22 const seen = new Set(); 23 const all = []; 24 Object.entries(MUSIC_WORKS).forEach(([category, works]) => { 25 works.forEach(work => { 26 // De-duplicate cross-listed works by title+year for the full catalogue 27 const key = `${work.title}__${work.year || ''}`; 28 if(seen.has(key)) return; 29 seen.add(key); 30 all.push({...work, __category: category}); 31 }); 32 }); 33 return all; 34} 35 36function filmUniqueWorks(){ 37 const map = new Map();
38 Object.entries(FILM_WORKS).forEach(([category,works]) => { 39 works.forEach(work => { 40 const slug = filmSlug(work); 41 if(!map.has(slug)){ 42 map.set(slug, {...work, __categories:[category], __primaryCategory:category}); 43 } else { 44 const item = map.get(slug); 45 if(!item.__categories.includes(category)) item.__categories.push(category); 46 } 47 }); 48 }); 49 return [...map.values()]; 50} 51 52function fieldHTML(value, field){ 53 const escapeHTML = str => String(str) 54 .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') 55 .replace(/"/g,'"').replace(/'/g,'''); 56 57 const linkHTML = item => { 58 const label = escapeHTML(item.label || 'Open â'); 59 const url = escapeHTML(item.url || '#'); 60 return `<a class="media-fallback" href="${url}" target="_blank" rel="noreferrer">${label}</a>`; 61 }; 62 63 const performanceDate = label => { 64 const head = String(label || '').split('·')[0].trim(); 65 const months = {january:0,february:1,march:2,april:3,may:4,june:5,july:6,august:7,september:8,october:9,november:10,december:11}; 66 const monthPattern = Object.keys(months).join('|'); 67 let match = head.match(new RegExp(`^(?:\\d{1,2}[â-])?(\\d{1,2}) (${monthPattern}) (\\d{4})$`,'i')); 68 if(match){ 69 const year=Number(match[3]),month=months[match[2].toLowerCase()],day=Number(match[1]); 70 const date=new Date(Date.UTC(year,month,day)); return {start:date,end:date}; 71 } 72 match = head.match(new RegExp(`^(${monthPattern}) (\\d{4})$`,'i')); 73 if(match){ 74 const year=Number(match[2]),month=months[match[1].toLowerCase()]; 75 return {start:new Date(Date.UTC(year,month,1)),end:new Date(Date.UTC(year,month+1,0))}; 76 } 77 match = head.match(/^(\\d{4})$/); 78 if(match){const year=Number(match[1]);return {start:new Date(Date.UTC(year,0,1)),end:new Date(Date.UTC(year,11,31))};} 79 return null; 80 }; 81 82 const performanceHTML = entries => { 83 const todayParts = new Intl.DateTimeFormat('en-GB',{timeZone:'Europe/Vienna',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date()); 84 const part = type => Number(todayParts.find(item=>item.type===type)?.value || 0); 85 const today = new Date(Date.UTC(part('year'),part('month')-1,part('day'))); 86 const rows = entries.map((item,index)=>{ 87 const object = item && typeof item === 'object' ? item : {label:item}; 88 const label = String(object.label || ''); 89 return {object,label,index,bounds:performanceDate(label)}; 90 }); 91 const sort = group => group.sort((a,b)=>(a.bounds?.start-a.bounds?.start)||a.index-b.index); 92 const upcoming = sort(rows.filter(row=>row.bounds && row.bounds.end>=today)); 93 const past = sort(rows.filter(row=>row.bounds && row.bounds.end<today)); 94 const undated = rows.filter(row=>!row.bounds); 95 const rowHTML = (row,isUpcoming) => { 96 const parts = row.label.split('·').map(text=>text.trim()).filter(Boolean); 97 const date = parts.shift() || row.label; 98 const body = `<span class="performance-date">${escapeHTML(date)}</span>${isUpcoming?'<small class="performance-status">Upcoming performance</small>':''}${parts.length?`<span class="performance-details">${escapeHTML(parts.join(' · '))}</span>`:''}`; 99 return row.object.url 100 ? `<a class="performance-row" href="${escapeHTML(row.object.url)}" target="_blank" rel="noreferrer">${body}</a>` 101 : `<span class="performance-row">${body}</span>`; 102 }; 103 return `<div class="performance-list">${upcoming.map(row=>rowHTML(row,true)).join('')}${past.map(row=>rowHTML(row,false)).join('')}${undated.map(row=>rowHTML(row,false)).join('')}</div>`; 104 }; 105 106 107 if(field === 'gallery'){ 108 const images = value && Array.isArray(value.images) ? value.images : []; 109 if(!images.length) return ''; 110 const credit = value.credit ? `<p class="work-gallery-credit">${escapeHTML(value.credit)}</p>` : ''; 111 return `<div class="work-gallery${value.kind === 'score' ? ' work-gallery--score' : value.kind === 'mixed' ? ' work-gallery--mixed' : ''}">${images.map((img, i) => { 112 const image = window.PrivacyMedia.galleryItem(img.src || '', img.alt || `Gallery image ${i+1}`, 'work-gallery-item' + (img.kind === 'score' ? ' work-gallery-item--score' : '')); 113 if(!img.credit) return image; 114 const caption = img.creditUrl ? `<a href="${escapeHTML(img.creditUrl)}" target="_blank" rel="noreferrer">${escapeHTML(img.credit)}</a>` : escapeHTML(img.credit); 115 return `<figure class="work-gallery-figure">${image}<figcaption>${caption}</figcaption></figure>`; 116 }).join('')}</div>${credit}`; 117 } 118 119 if(field === 'score'){ 120 if(Array.isArray(value) && value.length){ 121 return `<div class="score-links">${value.map(linkHTML).join('')}</div>`; 122 } 123 if(value && typeof value === 'object' && value.url){ 124 return `<div class="score-links">${linkHTML(value)}</div>`; 125 } 126 const existing = value ? `<div class="score-existing">${escapeHTML(value)}</div>` : ''; 127 return `${existing}<a class="composer-material-link" href="mailto:[email protected]">${composerMaterialNote}</a>`; 128 } 129 130 if(!value) return ''; 131 if(field === 'movements'){ 132 if(!Array.isArray(value) || !value.length) return ''; 133 return `<ol class="work-movements" type="I">${value.map(title => `<li>${escapeHTML(title)}</li>`).join('')}</ol>`; 134 } 135 if(field === 'performances'){ 136 const entries = Array.isArray(value) ? value : [value];
137 return performanceHTML(entries.filter(Boolean)); 138 } 139 if(field === 'description' || field === 'instrumentation' || field === 'production') return String(value); 140 141 function youtubeEmbed(u){ 142 try{ 143 const parsed = new URL(u); 144 const playlist = parsed.searchParams.get('list') || ''; 145 if(playlist) return `https://www.youtube-nocookie.com/embed/videoseries?list=${encodeURIComponent(playlist)}`; 146 let id = ''; 147 if(parsed.hostname.includes('youtu.be')) id = parsed.pathname.replace('/',''); 148 else if(parsed.hostname.includes('youtube.com')) id = parsed.searchParams.get('v') || ''; 149 if(!id && parsed.pathname.includes('/embed/')) id = parsed.pathname.split('/embed/')[1].split('/')[0]; 150 return id ? `https://www.youtube-nocookie.com/embed/${id}` : ''; 151 }catch(e){ return ''; } 152 } 153 154 function vimeoEmbed(u){ 155 try{ 156 const parsed = new URL(u); 157 if(!parsed.hostname.includes('vimeo.com')) return ''; 158 const id = parsed.pathname.split('/').filter(Boolean).find(part => /^\d+$/.test(part)); 159 return id ? `https://player.vimeo.com/video/${id}` : ''; 160 }catch(e){ return ''; } 161 } 162 163 function mediaItemHTML(item, mediaField){ 164 if(typeof item === 'string') item = {label:item}; 165 const url = item.url || ''; 166 const embedUrl = item.embedUrl || ''; 167 const label = item.label || String(item); 168 169 if(item.linkOnly && url){ 170 return `<a class="media-fallback" href="${escapeHTML(url)}" target="_blank" rel="noreferrer">${escapeHTML(label || 'Open â')}</a>`; 171 } 172 173 if(mediaField === 'audio' && Array.isArray(item.tracks) && item.tracks.length){ 174 const duration = seconds => { const n = Math.max(0, Math.round(Number(seconds) || 0)); return `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}`; }; 175 return `<section class="soundtrack-playlist media-embed" aria-label="${escapeHTML(label)}"> 176 <h3 class="soundtrack-title">${escapeHTML(label)}</h3> 177 <p class="soundtrack-now" aria-live="polite">Choose a track to listen</p> 178 <audio controls preload="none" aria-label="Soundtrack audio player"></audio> 179 <div class="soundtrack-navigation"><button type="button" data-soundtrack-step="-1" disabled aria-label="Previous track">â Previous</button><button type="button" data-soundtrack-step="1" aria-label="Next track">Next â</button></div> 180 <p class="soundtrack-status" role="status"></p> 181 <ol class="soundtrack-tracks">${item.tracks.map((track, i) => `<li><button type="button" data-soundtrack-track="${i}" data-audio-src="${escapeHTML(track.url || '')}" aria-label="Play ${escapeHTML(track.label || `Track ${i+1}`)}"><span class="soundtrack-number">${String(i+1).padStart(2, '0')}</span><span class="soundtrack-track-title">${escapeHTML(track.label || `Track ${i+1}`)}</span><span class="soundtrack-duration">${duration(track.duration)}</span></button></li>`).join('')}</ol> 182 ${url ? `<a class="soundtrack-open" href="${escapeHTML(url)}" target="_blank" rel="noreferrer">Open soundtrack â</a>` : ''} 183 </section>`; 184 } 185 186 if(item.localVideo && url){ 187 const poster = typeof item.poster === 'string' && /^media\/[a-zA-Z0-9/_%.-]+\.(?:webp|jpe?g|png|avif)$/i.test(item.poster) 188 ? ` poster="${escapeHTML(item.poster)}"` : ''; 189 return `<figure class="media-embed video local-video"><video controls playsinline preload="none"${poster}><source src="${escapeHTML(url)}" type="video/mp4"></video><figcaption>${escapeHTML(label)}</figcaption></figure>`; 190 } 191 192 if(mediaField === 'audio' && /^media\/[a-zA-Z0-9/_%.-]+\.mp3$/i.test(url)){ 193 return `<figure class="media-embed audio local-audio"><audio controls preload="none" aria-label="${escapeHTML(label)}"><source src="${escapeHTML(url)}" type="audio/mpeg"></audio><figcaption>${escapeHTML(label)}</figcaption></figure>`; 194 } 195 196 if(embedUrl){ 197 const kind = mediaField === 'video' ? 'video' : 'audio'; 198 return window.PrivacyMedia.player(embedUrl, kind, label, url); 199 } 200 201 if(url){ 202 const yt = youtubeEmbed(url); 203 if(yt){ 204 return window.PrivacyMedia.player(yt, 'video', label, url); 205 } 206 const vm = vimeoEmbed(url); 207 if(vm){ 208 return window.PrivacyMedia.player(vm, 'video', label, url); 209 } 210 if(url.includes('soundcloud.com/')){ 211 const player = 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(url) + '&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&visual=false'; 212 return window.PrivacyMedia.player(player, 'audio', label, url); 213 } 214 return `<a class="media-fallback" href="${escapeHTML(url)}" target="_blank" rel="noreferrer">${escapeHTML(label || 'Open â')}</a>`; 215 } 216 return `<span>${escapeHTML(label)}</span>`;
217 } 218 219 if(field === 'videoSnippets' && Array.isArray(value)){ 220 return `<div class="media-snippet-row">${value.map(item => mediaItemHTML(item, 'video')).join('')}</div>`; 221 } 222 223 if((field === 'video' || field === 'audio') && Array.isArray(value)){ 224 if(field === 'video' && value.some(item => item && typeof item === 'object' && item.snippet)){ 225 const regular = value.filter(item => !(item && typeof item === 'object' && item.snippet)); 226 const snippets = value.filter(item => item && typeof item === 'object' && item.snippet); 227 const regularHTML = regular.length ? `<div class="media-stack">${regular.map(item => mediaItemHTML(item, field)).join('')}</div>` : ''; 228 const snippetHTML = snippets.length ? `<div class="media-snippet-row">${snippets.map(item => mediaItemHTML(item, field)).join('')}</div>` : ''; 229 return regularHTML + snippetHTML; 230 } 231 return `<div class="media-stack">${value.map(item => mediaItemHTML(item, field)).join('')}</div>`; 232 } 233 234 if(field === 'video' || field === 'audio'){ 235 return mediaItemHTML(value, field); 236 } 237 238 if(Array.isArray(value)){ 239 return `<div class="field-list">${value.map(item => { 240 if(item && typeof item === 'object' && item.url) return linkHTML(item); 241 const label = item && typeof item === 'object' ? (item.label || '') : item; 242 return `<span>${escapeHTML(label)}</span>`; 243 }).join('')}</div>`; 244 } 245 246 if(value && typeof value === 'object'){ 247 if(value.url) return linkHTML(value); 248 return escapeHTML(value.label || ''); 249 } 250 251 return escapeHTML(value); 252} 253 254function latestYearValue(year){ 255 const s = String(year || ''); 256 const values = [...s.matchAll(/\b(?:19|20)\d{2}\b/g)].map(m => Number(m[0])); 257 const shortRange = s.match(/((?:19|20)\d{2})\s*[\/â-]\s*(\d{2})(?!\d)/); 258 if(shortRange){ 259 const first = Number(shortRange[1]); 260 const second = Math.floor(first / 100) * 100 + Number(shortRange[2]); 261 values.push(second < first ? second + 100 : second); 262 } 263 return values.length ? Math.max(...values) : 0; 264} 265 266function sortWorksNewest(a,b){ 267 return latestYearValue(b.year) - latestYearValue(a.year) || a.title.localeCompare(b.title); 268} 269 270function renderCategory(category){ 271 activeCategory = category; 272 activeWorkMode = 'music'; 273 categoryTitle.innerHTML = category === 'Solo, Chamber Music and Ensemble' 274 ? 'SOLO<br>CHAMBER MUSIC<br>ENSEMBLE' : newsEscape(category); 275 const works = [...(MUSIC_WORKS[category] || [])].sort(sortWorksNewest); 276 277 if(!works.length){ 278 categoryList.innerHTML = `<div class="category-empty">No works are currently published in this category.</div>`; 279 return; 280 } 281 282 const workButton = (work, index) => ` 283 <a class="category-work-row" href="${SiteRoutes.pathFor(`#music/${encodeURIComponent(category)}/${encodeURIComponent(catalogueSlug(work))}`)}" data-work-index="${index}"> 284 <span>${work.title}</span> 285 <small>${work.year || ''}</small> 286 <b aria-hidden="true">â</b> 287 </a>`; 288 289 const sectionOrders = { 290 "Orchestra": ["Solo & Orchestra", "Orchestra"], 291 "String Orchestra": ["Soloists & String Orchestra", "String Orchestra"], 292 "Voices": ["Choir", "Solo voice (ad lib. accompaniment)"], 293 "Solo, Chamber Music and Ensemble": ["Solo", "Chamber Music", "Ensemble"] 294 }; 295 296 if(sectionOrders[category]){ 297 const indexByWork = new Map(works.map((w,i)=>[w,i])); 298 categoryList.innerHTML = sectionOrders[category].map(section => { 299 const sectionWorks = works.filter(w => w.section === section); 300 if(!sectionWorks.length) return ''; 301 return ` 302 <section class="work-subsection"> 303 <h3 class="${section.split(/\s+/).some(word => word.replace(/[^\p{L}]/gu, '').length >= 12) ? 'is-long-title' : ''}">${section}</h3> 304 <div class="work-subsection-list"> 305 ${sectionWorks.map(w => workButton(w, indexByWork.get(w))).join('')} 306 </div> 307 </section>`; 308 }).join(''); 309 } else { 310 categoryList.innerHTML = works.map((work, index) => workButton(work, index)).join(''); 311 } 312 313 categoryList.querySelectorAll('.category-work-row').forEach(btn => { 314 btn.addEventListener('click', () => openWork(category, works[Number(btn.dataset.workIndex)])); 315 }); 316} 317 318function configureWorkLabels(mode, work = {}){ 319 const stage = mode === 'film' && !!work.stageCategory; 320 const labels = mode === 'film' ? { 321 year:'Year', 322 duration:'Runtime', 323 format:'Format', 324 director:'Director', 325 commission:'Commission',
326 award:'Awards / nominations', 327 production:'Credits / production', 328 instrumentation:'Instrumentation / setup', 329 performers:stage ? 'Premiere / first performance' : 'Premiere / release', 330 performances:stage ? 'Further performances' : 'Festivals / screenings', 331 recording:stage ? 'Recording / release' : 'Soundtrack / release', 332 source:'Sources' 333 } : { 334 year:'Year of composition', 335 duration:'Duration', 336 format:'Format', 337 director:'Director', 338 commission:'Commission', 339 award:'Awards / prizes', 340 production:'Production', 341 instrumentation:'Instrumentation', 342 performers:'Premiere', 343 performances:'Further performances', 344 recording:'Recording / release', 345 source:'Production / source' 346 }; 347 Object.entries(labels).forEach(([field,label])=>{ 348 const span = workPage.querySelector(`[data-info-field="${field}"] > span`); 349 if(span) span.textContent = label; 350 }); 351} 352 353function populateWorkPage(work, mode='music'){ 354 window.EditorialLayout?.resetWork(); 355 activeWorkMode = mode; 356 configureWorkLabels(mode, work); 357 workTitle.textContent = work.title; 358 359 const videoItems = Array.isArray(work.video) ? work.video : []; 360 const videoSnippets = videoItems.filter(item => item && typeof item === 'object' && item.snippet); 361 const regularVideo = videoItems.length 362 ? videoItems.filter(item => !(item && typeof item === 'object' && item.snippet)) 363 : work.video; 364 365 workPage.classList.toggle('film-mode', mode === 'film'); 366 workPage.classList.toggle('has-portrait-snippets', videoSnippets.length > 0); 367 368 const values = { 369 year: work.year, 370 duration: work.duration, 371 format: work.format, 372 director: work.director, 373 instrumentation: work.instrumentation, 374 dedication: work.dedication, 375 commission: work.commission, 376 award: work.award, 377 status: work.status, 378 altTitle: work.altTitle, 379 text: work.text, 380 description: work.description, 381 movements: work.movements, 382 production: work.production, 383 performers: work.performers, 384 recording: work.recording, 385 performances: work.performances, 386 score: work.score, 387 audio: work.audio, 388 video: regularVideo, 389 videoSnippets: videoSnippets.length ? videoSnippets : null, 390 review: work.review, 391 source: work.source, 392 gallery: work.gallery 393 }; 394 395 workPage.querySelectorAll('[data-field]').forEach(el => { 396 const field = el.dataset.field; 397 const value = values[field]; 398 const cell = workPage.querySelector(`[data-info-field="${field}"]`); 399 const hideScore = mode === 'film' && !value; 400 if((!value && field !== 'score') || hideScore){ 401 if(cell) cell.hidden = true; 402 el.innerHTML = ''; 403 return; 404 } 405 if(cell) cell.hidden = false; 406 el.innerHTML = fieldHTML(value, field); 407 }); 408 window.EditorialLayout?.arrangeWork(work, mode); 409} 410 411const filmCategoryDisplay = { 412 'Feature Film':'FEATURE<br>FILM', 413 'Documentary':'DOCUMENTARY', 414 'Short / Experimental Film':'SHORT<br>+EXPERIMENTAL FILM', 415 'Series & Television':'SERIES<br>+TELEVISION', 416 'Theatre / Dance':'THEATRE<br>+DANCE', 417 'Multimedia':'MULTIMEDIA' 418}; 419const stageCategoryOrder = ['Opera', 'Dance', 'Theatre']; 420function stageWorkCategory(work){ 421 if(stageCategoryOrder.includes(work.stageCategory)) return work.stageCategory; 422 if(/opera/i.test(work.format || work.subgroup || '')) return 'Opera'; 423 if(/dance/i.test(work.format || '')) return 'Dance'; 424 return 'Theatre'; 425} 426function renderFilmCategory(category){ 427 activeFilmCategory = category; 428 activeCategory = null; 429 activeWorkMode = 'film'; 430 filmCategoryTitle.innerHTML = filmCategoryDisplay[category] || newsEscape(category); 431 const works = [...(FILM_WORKS[category] || [])].sort(sortWorksNewest); 432 433 if(!works.length){ 434 filmCategoryList.innerHTML = `<div class="category-empty">No works are currently published in this category.</div>`; 435 return; 436 } 437 438 const workRow = (work, index) => ` 439 <a class="category-work-row" href="${SiteRoutes.pathFor(`#film/${encodeURIComponent(category)}/${encodeURIComponent(filmSlug(work))}`)}" data-film-index="${index}"> 440 <span>${work.title}</span> 441 <small>${work.year || ''}</small> 442 <b aria-hidden="true">â</b> 443 </a>`; 444 filmCategoryList.innerHTML = category === 'Theatre / Dance' 445 ? stageCategoryOrder.map(group => { 446 const groupWorks = works.filter(work => stageWorkCategory(work) === group); 447 return groupWorks.length ? `<section class="work-subsection" data-stage-category="${group}"><h3>${group}</h3><div class="work-subsection-list">${groupWorks.map(work => workRow(work, works.indexOf(work))).join('')}</div></section>` : ''; 448 }).join('') 449 : works.map(workRow).join(''); 450 451 filmCategoryList.querySelectorAll('[data-film-index]').forEach(btn => { 452 btn.addEventListener('click', () =>
452 openFilmWork(category, works[Number(btn.dataset.filmIndex)])); 453 }); 454} 455 456function uniqueCatalogueMeta(values){ 457 const seen = new Set(); 458 return values.filter(value => { 459 const key = String(value || '').trim().replace(/\s+/g,' ').toLocaleLowerCase('en'); 460 if(!key || seen.has(key)) return false; 461 seen.add(key); 462 return true; 463 }); 464} 465 466function renderCatalogue(){ 467 const query = (catalogueSearch.value || '').trim().toLowerCase(); 468 const category = catalogueCategoryFilter.value; 469 const instrument = catalogueInstrumentFilter.value; 470 471 const all = flatCatalogueWorks(); 472 const filtered = all.filter(work => { 473 const text = [ 474 work.title, work.altTitle, work.year, work.instrumentation, 475 work.section, work.subgroup, 476 ...(work.catalogueTags || []), ...(work.searchTerms || []) 477 ].filter(Boolean).join(' ').toLowerCase(); 478 479 const categoryOK = !category || work.__category === category; 480 const instrumentOK = !instrument || (work.catalogueTags || []).includes(instrument); 481 const searchOK = !query || text.includes(query); 482 return categoryOK && instrumentOK && searchOK; 483 }); 484 485 catalogueSummary.textContent = `${filtered.length} ${filtered.length === 1 ? 'work' : 'works'}`; 486 487 if(!filtered.length){ 488 catalogueList.innerHTML = `<div class="catalogue-empty">No works match the current filters.</div>`; 489 return; 490 } 491 492 const categoryOrder = [ 493 "Orchestra", 494 "String Orchestra", 495 "Voices", 496 "Solo, Chamber Music and Ensemble", 497 "Opera", 498 "Self-Playing Instruments and Disklavier" 499 ]; 500 501 catalogueList.innerHTML = categoryOrder.map(cat => { 502 const works = filtered.filter(w => w.__category === cat); 503 if(!works.length) return ''; 504 works.sort(sortWorksNewest); 505 return ` 506 <section class="catalogue-group"> 507 <h3 class="catalogue-group-title">${cat === 'Solo, Chamber Music and Ensemble' ? 'SOLO<br>CHAMBER MUSIC<br>ENSEMBLE' : newsEscape(cat)}</h3> 508 ${works.map(work => { 509 const meta = uniqueCatalogueMeta([ 510 work.section, 511 ...(work.catalogueTags || []).filter(t => ![cat, work.title].some(value => String(t).toLocaleLowerCase('en') === value.toLocaleLowerCase('en'))) 512 ]).slice(0,6); 513 return ` 514 <a class="catalogue-work-row" href="${SiteRoutes.pathFor(`#music/${encodeURIComponent(work.__category)}/${encodeURIComponent(catalogueSlug(work))}`)}" 515 data-category="${encodeURIComponent(work.__category)}" 516 data-tags="${newsEscape((work.catalogueTags || []).join('|'))}" 517 data-slug="${encodeURIComponent(catalogueSlug(work))}"> 518 <span class="catalogue-work-title">${work.title}</span> 519 <span class="catalogue-work-meta">${meta.map(x => `<span>${newsEscape(x)}</span>`).join('')}</span> 520 <span class="catalogue-work-year">${work.year || ''}</span> 521 </a>`; 522 }).join('')} 523 </section>`; 524 }).join(''); 525 526 catalogueList.querySelectorAll('.catalogue-work-row').forEach(btn => { 527 btn.addEventListener('click', () => { 528 const cat = decodeURIComponent(btn.dataset.category); 529 const slug = decodeURIComponent(btn.dataset.slug); 530 const work = (MUSIC_WORKS[cat] || []).find(w => catalogueSlug(w) === slug); 531 if(!work) return; 532 cataloguePage.classList.remove('open'); 533 cataloguePage.setAttribute('aria-hidden','true'); 534 openWork(cat, work); 535 }); 536 }); 537 window.EditorialLayout?.arrangeCatalogue(); 538} 539 540function renderFilmCatalogue(){ 541 const query = (filmCatalogueSearch.value || '').trim().toLowerCase(); 542 const category = filmCatalogueCategoryFilter.value; 543 const medium = filmCatalogueMediumFilter.value; 544 545 const all = filmUniqueWorks(); 546 const filtered = all.filter(work => { 547 const text = [ 548 work.title, work.altTitle, work.year, work.format, work.stageCategory, work.director, work.production, 549 work.performers, work.performances, ...(work.catalogueTags || []), ...(work.searchTerms || []), 550 ...(work.__categories || []) 551 ].map(searchValueText).filter(Boolean).join(' ').toLowerCase(); 552
553 const categoryOK = !category || (work.__categories || []).includes(category); 554 const mediumOK = !medium || work.stageCategory === medium || (work.catalogueTags || []).some(tag => String(tag).toLowerCase() === medium.toLowerCase()); 555 const searchOK = !query || text.includes(query); 556 return categoryOK && mediumOK && searchOK; 557 }).sort(sortWorksNewest); 558 559 filmCatalogueSummary.textContent = `${filtered.length} ${filtered.length === 1 ? 'work' : 'works'}`; 560 561 if(!filtered.length){ 562 filmCatalogueList.innerHTML = `<div class="catalogue-empty">No works match the current filters.</div>`; 563 return; 564 } 565 566 const categoryOrder = ["Feature Film","Documentary","Short / Experimental Film","Series & Television","Theatre / Dance","Multimedia"]; 567 filmCatalogueList.innerHTML = categoryOrder.map(cat => { 568 const works = filtered.filter(w => w.__primaryCategory === cat); 569 if(!works.length) return ''; 570 const rows = groupWorks => groupWorks.map(work => { 571 const categories = uniqueCatalogueMeta(work.__categories || []); 572 const meta = uniqueCatalogueMeta([...categories, work.stageCategory,...(work.catalogueTags || []).filter(tag => String(tag).toLocaleLowerCase('en') !== work.title.toLocaleLowerCase('en'))]).slice(0,categories.length + 3); 573 return ` 574 <a class="catalogue-work-row" href="${SiteRoutes.pathFor(`#film/${encodeURIComponent(work.__primaryCategory)}/${encodeURIComponent(filmSlug(work))}`)}" 575 data-film-catalogue-category="${encodeURIComponent(work.__primaryCategory)}" 576 data-categories="${newsEscape((work.__categories || []).join('|'))}" 577 data-tags="${newsEscape((work.catalogueTags || []).join('|'))}" 578 data-film-catalogue-slug="${encodeURIComponent(filmSlug(work))}"> 579 <span class="catalogue-work-title">${work.title}</span> 580 <span class="catalogue-work-meta">${meta.map(x => `<span>${newsEscape(x)}</span>`).join('')}</span> 581 <span class="catalogue-work-year">${work.year || ''}</span> 582 </a>`; 583 }).join(''); 584 return ` 585 <section class="catalogue-group film-catalogue-group"> 586 <h3 class="catalogue-group-title">${filmCategoryDisplay[cat] || newsEscape(cat)}</h3> 587 ${cat === 'Theatre / Dance' ? stageCategoryOrder.map(group => { 588 const groupWorks = works.filter(work => stageWorkCategory(work) === group); 589 return groupWorks.length ? `<section class="catalogue-subgroup" data-stage-category="${group}"><h4 class="catalogue-subgroup-title">${group}</h4>${rows(groupWorks)}</section>` : ''; 590 }).join('') : rows(works)} 591 </section>`; 592 }).join(''); 593 594 filmCatalogueList.querySelectorAll('[data-film-catalogue-slug]').forEach(btn => { 595 btn.addEventListener('click', () => {
596 const category = decodeURIComponent(btn.dataset.filmCatalogueCategory); 597 const slug = decodeURIComponent(btn.dataset.filmCatalogueSlug); 598 const work = (FILM_WORKS[category] || []).find(w => filmSlug(w) === slug); 599 if(!work) return; 600 filmCataloguePage.classList.remove('open'); 601 filmCataloguePage.setAttribute('aria-hidden','true'); 602 openFilmWork(category, work); 603 }); 604 }); 605 window.EditorialLayout?.arrangeCatalogue(filmCatalogueList); 606} 607 608function newsEscape(value){ 609 return String(value ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') 610 .replace(/"/g,'"').replace(/'/g,'''); 611} 612 613function newsItemBySlug(slug){ return NEWS_ITEMS.find(item => item.slug === slug); } 614 615function renderNewsHome(){ 616 if(!newsGrid) return; 617 newsGrid.innerHTML = NEWS_ITEMS.slice(0,4).map(item => ` 618 <a class="date-row news-home-row" href="${SiteRoutes.pathFor(`#news/${item.slug}`)}" 619 data-news-slug="${newsEscape(item.slug)}"> 620 <time>${newsEscape(item.date)}</time> 621 <span class="date-work"><strong>${newsEscape(item.title)}</strong><small>${newsEscape(item.panelSubtitle || item.type)}</small></span> 622 <span class="date-venue">${newsEscape(item.preview || item.summary || '')}</span> 623 <span class="date-action">Read â</span> 624 </a>`).join(''); 625 newsGrid.querySelectorAll('[data-news-slug]').forEach(button => { 626 button.addEventListener('click', () => openNewsItem(button.dataset.newsSlug)); 627 }); 628} 629 630function renderNewsCatalogue(){ 631 const query = (newsCatalogueSearch?.value || '').trim().toLowerCase(); 632 const type = newsCatalogueType?.value || ''; 633 const year = newsCatalogueYear?.value || ''; 634 const filtered = NEWS_ITEMS.filter(item => { 635 const text = [item.title,item.date,item.type,item.language,item.preview,item.summary,...(item.body||[]),...(item.notes||[])] 636 .filter(Boolean).join(' ').toLowerCase(); 637 return (!query || text.includes(query)) && (!type || item.type === type) && 638 (!year || item.date.match(/\b(?:19|20)\d{2}\b/)?.[0] === year); 639 }); 640 newsCatalogueSummary.textContent = `${filtered.length} ${filtered.length === 1 ? 'item' : 'items'}`; 641 if(!filtered.length){ 642 newsCatalogueList.innerHTML = `<div class="catalogue-empty">No News & Media items match the current filters.</div>`; 643 return; 644 } 645 newsCatalogueList.innerHTML = filtered.map(item => ` 646 <a class="news-catalogue-row" href="${SiteRoutes.pathFor(`#news/${item.slug}`)}" data-news-slug="${newsEscape(item.slug)}"> 647 <time>${newsEscape(item.date)}</time> 648 <span class="news-row-type">${newsEscape(item.type)}</span> 649 <strong>${newsEscape(item.title)}</strong> 650 <span class="news-row-language">${item.language ? `Language: ${newsEscape(item.language)}` : ''}</span> 651 </a>`).join(''); 652 newsCatalogueList.querySelectorAll('[data-news-slug]').forEach(button => { 653 button.addEventListener('click', () => { 654 newsCataloguePage.classList.remove('open'); 655 newsCataloguePage.setAttribute('aria-hidden','true'); 656 openNewsItem(button.dataset.newsSlug); 657 }); 658 }); 659 window.EditorialLayout?.arrangeNewsCatalogue(newsCatalogueList); 660} 661 662function populateYearFilter(select, values){ 663 if(!select || select.querySelectorAll('option').length > 1) return; 664 const years = [...new Set(values.filter(Boolean))]; 665 years.filter(value => /^\d{4}$/.test(value)).sort((a,b) => Number(b) - Number(a)) 666 .concat(years.includes('TBA') ? ['TBA'] : []).forEach(value => { 667 const option = document.createElement('option'); 668 option.value = value; 669 option.setAttribute('value', value); 670 option.textContent = value === 'TBA' ? 'Date TBA' : value; 671 select.append(option); 672 }); 673} 674 675function populateNewsCatalogueYears(select){ 676 populateYearFilter(select, NEWS_ITEMS.map(item => item.date.match(/\b(?:19|20)\d{2}\b/)?.[0])); 677} 678 679function populateUpcomingYears(select){ 680 populateYearFilter(select, allEventRows.map(item => item.date ? item.date.slice(0,4) : 'TBA')); 681} 682 683function populateNewsDetail(item){ 684 window.EditorialLayout?.resetNews(); 685 newsDetailTitle.textContent = item.title; 686 newsDetailArticleSubtitle.textContent = item.articleSubtitle || ''; 687 newsDetailArticleSubtitle.hidden = !item.articleSubtitle; 688 newsDetailMeta.innerHTML = `<span>${newsEscape(item.panelSubtitle || item.type)}</span><span>${newsEscape(item.date)}</span>${item.language ? `<span>Language: ${newsEscape(item.language)}</span>` : ''}`; 689 newsDetailSummary.textContent = item.summary || item.preview || ''; 690 newsDetailBody.innerHTML = (item.body || []).map(p => `<p>${newsEscape(p)}</p>`).join(''); 691 newsDetailNotes.innerHTML = (item.notes || []).map((note,index) => { 692 const logos = (item.noteLogos || [])[index] || []; 693 const logoHTML = logos.length ? `<div class="news-supporter-logos">${logos.map(logo => `<a class="news-supporter-logo ${logo.theme === 'dark' ? 'dark' : ''}" href="${newsEscape(logo.url)}" target="_blank" rel="noreferrer" aria-label="${newsEscape(logo.alt)}"><img src="${newsEscape(logo.src)}" alt="${newsEscape(logo.alt)}" loading="lazy"></a>`).join('')}</div>` : ''; 694 return `<div class="news-detail-note"><p>${newsEscape(note)}</p>${logoHTML}</div>`; 695 }).join(''); 696 const galleryHTML = (item.gallery || []).map(photo => `<figure class="news-gallery-item">${window.PrivacyMedia.galleryItem(photo.src, photo.alt, 'news-gallery-open')}<figcaption>${photo.creditUrl ? `<a href="${newsEscape(photo.creditUrl)}" target="_blank" rel="noreferrer">${newsEscape(photo.credit)}</a>` : newsEscape(photo.credit)}</figcaption></figure>`).join(''); 697 const videoHTML = item.heroVideo ? `<figure class="news-feature-video"><video controls autoplay muted loop playsinline preload="metadata" aria-label="Kinetic Etudes â Chopin Waterfall"><source src="${newsEscape(item.heroVideo)}" type="video/mp4"></video><figcaption>Chopin Waterfall · Kinetic Etudes for Disklavier</figcaption></figure>` : ''; 698 const embedHTML = item.media ? `<div class="news-article-embed">${fieldHTML(item.media, item.mediaKind === 'audio' ? 'audio' : 'video')}</div>` : ''; 699 newsDetailAssets.innerHTML = videoHTML + galleryHTML + embedHTML; 700 newsDetailLinks.innerHTML = (item.links || []).map(link => `<a href="${newsEscape(link.url)}" target="_blank" rel="noreferrer"><span>${newsEscape(link.label)}</span></a>`).join(''); 701 window.EditorialLayout?.arrangeNews(item); 702} 703 704function viennaToday(){ 705 const parts = new Intl.DateTimeFormat('en-US',{timeZone:'Europe/Vienna',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date()); 706 const field = key => parts.find(part => part.type === key)?.value || ''; 707 return `${field('year')}-${field('month')}-${field('day')}`; 708} 709 710// Boundaries classify incomplete historical dates without inventing an exact day. 711// A current month/year remains upcoming until that whole period has elapsed. 712function eventDateBounds(date){ 713 if(!date)return {start:'',end:''}; 714 if(date.length===4)return {start:date+'-01-01',end:date+'-12-31'}; 715 if(date.length===7){ 716 const last=new Date(Date.UTC(Number(date.slice(0,4)),Number(date.slice(5,7)),0)).getUTCDate(); 717 return {start:date+'-01',end:date+'-'+String(last).padStart(2,'0')}; 718 } 719 return {start:date.slice(0,10),end:date.slice(0,10)}; 720} 721 722function renderUpcomingDates(){ 723 const today = viennaToday(); 724 const future = allEventRows.filter(item => !item.end || item.end >= today).sort((a,b) => (a.start || '9999').localeCompare(b.start || '9999')); 725 const past = allEventRows.filter(item => item.end && item.end < today).sort((a,b) => b.end.localeCompare(a.end)); 726 const query = (upcomingSearch?.value || '').trim().toLowerCase(); 727 const period = upcomingPeriodFilter?.value || ''; 728 const year = upcomingYearFilter?.value || ''; 729 const matches = item => (!year || (item.date ? item.date.slice(0,4) : 'TBA') === year) && 730 (!query || item.node.textContent.toLowerCase().includes(query)); 731 const filteredFuture = future.filter(matches), filteredPast = past.filter(matches); 732 const showFuture = period !== 'past', showPast = period !== 'upcoming'; 733 upcomingAllList.hidden = !showFuture; 734 document.getElementById('upcoming-archive').hidden = !showPast; 735 upcomingAllList.replaceChildren(...filteredFuture.map(item => item.node.cloneNode(true))); 736 upcomingPastList.replaceChildren(...filteredPast.map(item => item.node.cloneNode(true))); 737 if(showFuture && !filteredFuture.length) upcomingAllList.innerHTML = `<p class="upcoming-empty">${query || year ? 'No upcoming events match the current filters.' : 'New dates will be announced here.'}</p>`; 738 if(showPast && !filteredPast.length) upcomingPastList.innerHTML = `<p class="upcoming-empty">${query || year ? 'No past events match the current filters.' : 'No past events yet.'}</p>`; 739 const count = (showFuture ? filteredFuture.length : 0) + (showPast ? filteredPast.length : 0); 740 document.getElementById('upcoming-summary').textContent = `${count} ${count === 1 ? 'event' : 'events'}`; 741 upcomingHomeList.replaceChildren(...future.slice(0,4).map(item => item.node.cloneNode(true))); 742 if(!future.length) upcomingHomeList.innerHTML = '<p class="upcoming-empty">New dates will be announced here.</p>'; 743 [upcomingHomeList, upcomingAllList, upcomingPastList].forEach(list => window.EditorialLayout?.arrangeDates(list)); 744}
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.