PageSourceSearch

https://claudiaschwartz.de/assets/js/main.js

js claudiaschwartz.de collected 2026-10-08 13:34:13 UTC 18,957 bytes, 515 lines download raw bytes

1/* claudiaschwartz.de — menu, waveform players, gallery, lightbox */
2(function () {
3  "use strict";
4
5  var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
6  var $  = function (s, r) { return (r || document).querySelector(s); };
7  var $$ = function (s, r) { return [].slice.call((r || document).querySelectorAll(s)); };
8
9  /* ======================================================== hero entrance */
10  requestAnimationFrame(function () { document.body.classList.add("is-ready"); });
11
12  /* ================================================================= nav */
13  var burger = $(".burger");
14  var menu = $("#menu");
15  var topBar = $(".top");
16  var beforeMenu = null;
17
18  function setMenu(open) {
19    document.body.classList.toggle("menu-open", open);
20    burger.setAttribute("aria-expanded", open ? "true" : "false");
21    burger.setAttribute("aria-label", open ? burger.dataset.close : burger.dataset.open);
22    document.documentElement.style.overflow = open ? "hidden" : "";
23    if (open) {
24      beforeMenu = document.activeElement;
25      menu.removeAttribute("inert");
26      var first = $(".menu__list a", menu);
27      if (first) setTimeout(function () { first.focus(); }, 80);
28    } else {
29      menu.setAttribute("inert", "");
30      if (beforeMenu) beforeMenu.focus();
31    }
32  }
33
34  if (burger && menu) {
35    burger.addEventListener("click", function () {
36      setMenu(!document.body.classList.contains("menu-open"));
37    });
38    menu.addEventListener("click", function (e) {
39      if (e.target.closest("a")) setMenu(false);
40    });
41    addEventListener("keydown", function (e) {
42      if (e.key === "Escape" && document.body.classList.contains("menu-open")) setMenu(false);
43    });
44    /* keep tabbing inside the overlay while it is open */
45    menu.addEventListener("keydown", function (e) {
46      if (e.key !== "Tab") return;
47      var f = $$("a", menu);
48      if (!f.length) return;
49      var first = f[0], last = f[f.length - 1];
50      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
51      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
52    });
53  }
54
55  /* the burger takes the colour of whatever band sits under it */
56  if (topBar) {
57    var bands = $$(".hero, .band, [data-band]");
58    var paint = function () {
59      var y = 34, over = "light";
60      for (var i = 0; i < bands.length; i++) {
61        var r = bands[i].getBoundingClientRect();
62        if (r.top <= y && r.bottom > y) {
63          over = bands[i].dataset.band === "dark" ? "dark" : "light";
64          break;
65        }
66      }
67      topBar.dataset.over = over;
68    };
69    addEventListener("scroll", paint, { passive: true });
70    addEventListener("resize", paint);
71    paint();
72  }
73
74  /* section spy */
75  var sections = $$("main section[id]");
76  var navLinks = $$('.menu__list a[href*="#"]');
77  if (sections.length && navLinks.length && "IntersectionObserver" in window) {
78    var spy = new IntersectionObserver(function (entries) {
79      entries.forEach(function (en) {
80        if (!en.isIntersecting) return;
81        navLinks.forEach(function (a) {
82          var href = a.getAttribute("href");
83          a.setAttribute("aria-current",
84            href.slice(href.indexOf("#")) === "#" + en.target.id ? "true" : "false");
85        });
86      });
87    }, { rootMargin: "-48% 0px -48% 0px" });
88    sections.forEach(function (s) { spy.observe(s); });
89  }
90
91  /* ============================================================== reveal */
92  var revealables = $$(".rise, .fade");
93  if ("IntersectionObserver" in window && !reduce) {
94    var rev = new IntersectionObserver(function (entries, obs) {
95      entries.forEach(function (en) {
96        if (!en.isIntersecting) return;
97        en.target.classList.add("is-in");
98        obs.unobserve(en.target);
99      });
100    }, { rootMargin: "0px 0px -10% 0px", threshold: 0.08 });
101    revealables.forEach(function (el) { rev.observe(el); });
102  } else {
103    revealables.forEach(function (el) { el.classList.add("is-in"); });
104  }
105
106  /* ==================================================== waveform players */
107  /* Peaks are baked at build time from the real recordings, so the shape is
108     on screen before a single byte of audio is fetched.                    */
109  var players = [];
110  var live = null;
111
112  function fmt(t) {
113    if (!isFinite(t)) t = 0;
114    var m = Math.floor(t / 60), s = Math.floor(t % 60);
115    return m + ":" + (s < 10 ? "0" : "") + s;
116  }
117
118  function Player(root) {
119    var self = this;
120    this.root = root;
121    this.canvas = $(".wave__canvas", root);
122    this.ctx = this.canvas.getContext("2d");
123    this.track = $(".wave__track", root);
124    this.btn = $(".wave__play", root);
125    this.time = $(".wave__time", root);
126    this.peaks = (root.dataset.peaks || "").split(",").map(Number);
127    this.duration = parseFloat(root.dataset.duration) || 0;
128    this.src = root.dataset.src;
129    this.audio = null;
130    this.progress = 0;
131    this.raf = 0;
132
133    this.colors();
134    this.resize();
135    addEventListener("resize", function () { self.resize(); });
136
137    this.btn.addEventListener("click", function () { self.toggle(); });
138
139    var seek = function (e) {
140      var r = self.track.getBoundingClientRect();
141      var p = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
142      self.progress = p;
143      if (self.audio && self.audio.duration) self.audio.currentTime = p * self.audio.duration;
144      self.draw();
145      self.label();
146    };
147    this.track.addEventListener("click", seek);
148    this.track.addEventListener("keydown", function (e) {
149      if (e.key === "Enter" || e.key === " ") { e.preventDefault(); self.toggle(); }
150      if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
151        e.preventDefault();
152        var d = (e.key === "ArrowRight" ? 5 : -5);
153        if (self.audio && self.audio.duration) {
154          self.audio.currentTime = Math.min(self.audio.duration, Math.max(0, self.audio.currentTime + d));
155          self.progress = self.audio.currentTime / self.audio.duration;
156          self.draw(); self.label();
157        }
158      }
159    });
160
161    this.label();
162  }
163
164  Player.prototype.colors = function () {
165    var cs = getComputedStyle(this.root);
166    this.cIdle = cs.getPropertyValue("--wave-idle").trim() || "rgba(139,144,156,.55)";
167    this.cLive = cs.getPropertyValue("--wave-live").trim() || "#E3A03C";
168  };
169
170  Player.prototype.resize = function () {
171    var dpr = Math.min(2, devicePixelRatio || 1);
172    var r = this.track.getBoundingClientRect();
173    if (!r.width) return;
174    this.canvas.width = Math.round(r.width * dpr);
175    this.canvas.height = Math.round(r.height * dpr);
176    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
177    this.w = r.width; this.h = r.height;
178    this.draw();
179  };
180
181  Player.prototype.draw = function () {
182    if (!this.w) return;
183    var ctx = this.ctx, n = this.peaks.length;
184    var gap = 2, bar = Math.max(1.5, (this.w - gap * (n - 1)) / n);
185    var mid = this.h / 2, cut = this.progress * this.w;
186
187    ctx.clearRect(0, 0, this.w, this.h);
188    for (var i = 0; i < n; i++) {
189      var x = i * (bar + gap);
190      var hh = Math.max(1.5, this.peaks[i] * (this.h - 6));
191      ctx.fillStyle = (x + bar / 2) <= cut ? this.cLive : this.cIdle;
192      ctx.fillRect(x, mid - hh / 2, bar, hh);
193    }
194  };
195
196  Player.prototype.label = function () {
197    var d = (this.audio && this.audio.duration) || this.duration;
198    var cur = this.progress * d;
199    this.time.textContent = (this.progress > 0 ? fmt(cur) + " / " : "") + fmt(d);
200  };
201
202  Player.prototype.tick = function () {
203    var self = this;
204    if (!this.audio) return;
205    this.progress = this.audio.duration ? this.audio.currentTime / this.audio.duration : 0;
206    this.draw();
207    this.label();
208    this.raf = requestAnimationFrame(function () { self.tick(); });
209  };
210
211  Player.prototype.toggle = function () {
212    var self = this;
213    if (live && live !== this) live.pause();
214
215    if (!this.audio) {
216      this.audio = new Audio();
217      this.audio.preload = "none";
218      this.audio.src = this.src;
219      this.audio.addEventListener("ended", function () { self.pause(true); });
220      /* rAF is frozen in background tabs; timeupdate keeps the readout honest */
221      this.audio.addEventListener("timeupdate", function () {
222        if (!self.audio.duration) return;
223        self.progress = self.audio.currentTime / self.audio.duration;
224        self.label();
225      });
226      this.audio.addEventListener("loadedmetadata", function () { self.label(); });
227      this.audio.addEventListener("error", function () {
228        self.pause();
229        self.time.textContent = "—";
230      });
231    }
232    if (this.audio.paused) {
233      if (this.progress >= 0.999) this.progress = 0;
234      if (this.audio.duration) this.audio.currentTime = this.progress * this.audio.duration;
235      this.audio.play().then(function () {
236        live = self;
237        self.root.classList.add("is-playing");
238        self.btn.setAttribute("aria-label", self.btn.dataset.pause || "Pause");
239        var li = self.root.closest("li");
240        if (li) li.classList.add("is-live");
241        self.tick();
242      }).catch(function () { self.pause(); });
243    } else {
244      this.pause();
245    }
246  };
247
248  Player.prototype.pause = function (reset) {
249    if (this.audio) this.audio.pause();
250    cancelAnimationFrame(this.raf);
251    this.root.classList.remove("is-playing");
252    this.btn.setAttribute("aria-label", this.btn.dataset.play || "Play");
253    var li = this.root.closest("li");
254    if (li) li.classList.remove("is-live");
255    if (reset) { this.progress = 0; this.draw(); this.label(); }
256    if (live === this) live = null;
257  };
258
259  $$(".wave").forEach(function (el) { players.push(new Player(el)); });
260
261  addEventListener("load", function () {
262    players.forEach(function (p) { p.resize(); });
263  });
264  document.addEventListener("visibilitychange", function () {
265    if (document.hidden) return;
266    players.forEach(function (p) { p.draw(); p.label(); });
267    if (live) live.tick();
268  });
269
270  /* =================================================== justified gallery */
271  function layout(grid) {
272    var items = [].slice.call(grid.children);
273    var width = grid.clientWidth;
274    if (!items.length || !width) return;
275
276    var gap = parseFloat(getComputedStyle(grid).gap) || 6;
277    var ideal = width < 700 ? 180 : width < 1000 ? 230 : 300;
278    var row = [], sum = 0;
279
280    function flush(last) {
281      if (!row.length) return;
282      var avail = width - gap * (row.length - 1);
283      var h = avail / sum;
284      if (last && h > ideal * 1.35) h = ideal;
285      var used = 0;
286      row.forEach(function (it, i) {
287        var w = (i === row.length - 1 && !last) ? avail - used : Math.floor(it.r * h);
288        used += w;
289        it.el.style.width = w + "px";
290        it.el.style.height = Math.round(h) + "px";
291      });
292      row = []; sum = 0;
293    }
294
295    items.forEach(function (el) {
296      var r = parseFloat(el.dataset.ratio) || 1;
297      row.push({ el: el, r: r });
298      sum += r;
299      if (sum * ideal >= width - gap * (row.length - 1)) flush(false);
300    });
301    flush(true);
302  }
303
304  var grids = $$(".gal__grid");
305  function layoutAll() { grids.forEach(function (g) { if (!g.hidden) layout(g); }); }
306  layoutAll();
307
308  var rafL;
309  addEventListener("resize", function () {
310    cancelAnimationFrame(rafL);
311    rafL = requestAnimationFrame(layoutAll);
312  });
313
314  $$(".gal__bar").forEach(function (bar) {
315    bar.addEventListener("click", function (e) {
316      var btn = e.target.closest(".gal__tab");
317      if (!btn) return;
318      $$(".gal__tab", bar).forEach(function (b) {
319        b.setAttribute("aria-selected", b === btn ? "true" : "false");
320      });
321      grids.forEach(function (g) {
322        g.hidden = g.id !== btn.dataset.target;
323        if (!g.hidden) layout(g);
324      });
325    });
326  });
327
328  /* ============================================================ lightbox */
329  var lb = $(".lb");
330  if (lb) {
331    var stage = $(".lb__stage", lb);
332    var count = $(".lb__count", lb);
333    var prevBtn = $(".lb__prev", lb);
334    var nextBtn = $(".lb__next", lb);
335    var set = [], idx = 0, restore = null;
336
337    /* Stop a self-hosted clip at once — the stage is only emptied after the
338       fade-out, and a still-playing video would keep sounding through it. */
339    function stopMedia() {
340      var vd = $("video", stage);
341      if (vd) { vd.pause(); vd.removeAttribute("src"); vd.load(); }
342    }
343
344    function render() {
345      var it = set[idx];
346      stopMedia();
347      stage.innerHTML = "";
348      if (it.file) {
349        var vd = document.createElement("video");
350        vd.src = it.file;
351        if (it.poster) vd.poster = it.poster;
352        vd.controls = true;
353        vd.autoplay = true;
354        vd.playsInline = true;
355        vd.preload = "metadata";
356        vd.setAttribute("controlslist", "nodownload");
357        vd.title = it.alt || "Video";
358        stage.appendChild(vd);
359      } else if (it.video) {
360        var f = document.createElement("iframe");
361        f.src = "https://www.youtube-nocookie.com/embed/" + it.video + "?autoplay=1&rel=0";
362        f.allow = "accelerometer; autoplay; encrypted-media; picture-in-picture; fullscreen";
363        f.allowFullscreen = true;
364        f.title = it.alt || "Video";
365        stage.appendChild(f);
366      } else {
367        var img = new Image();
368        img.src = it.src;
369        img.alt = it.alt || "";
370        stage.appendChild(img);
371      }
372      count.textContent = set.length > 1 ? (idx + 1) + " / " + set.length : "";
373      prevBtn.hidden = nextBtn.hidden = set.length < 2;
374    }
375
376    function open(items, start) {
377      if (live) live.pause();
378      if (document.body.classList.contains("menu-open")) setMenu(false);
379      restore = document.activeElement;
380      set = items; idx = start;
381      lb.classList.add("is-open");
382      requestAnimationFrame(function () { lb.classList.add("is-vis"); });
383      document.documentElement.style.overflow = "hidden";
384      render();
385      $(".lb__close", lb).focus();
386    }
387
388    function close() {
389      stopMedia();
390      lb.classList.remove("is-vis");
391      setTimeout(function () { lb.classList.remove("is-open"); stage.innerHTML = ""; }, 350);
392      document.documentElement.style.overflow = "";
393      if (restore) restore.focus();
394    }
395
396    function step(d) { idx = (idx + d + set.length) % set.length; render(); }
397
398    lb.addEventListener("click", function (e) {
399      if (e.target === lb || e.target.closest(".lb__close")) return close();
400      if (e.target.closest(".lb__prev")) return step(-1);
401      if (e.target.closest(".lb__next")) return step(1);
402    });
403    addEventListener("keydown", function (e) {
404      if (!lb.classList.contains("is-open")) return;
405      if (e.key === "Escape") close();
406      else if (e.key === "ArrowLeft") step(-1);
407      else if (e.key === "ArrowRight") step(1);
408      else if (e.key === "Tab") {
409        var f = $$("button:not([hidden])", lb);
410        var first = f[0], last = f[f.length - 1];
411        if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
412        else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
413      }
414    });
415
416    grids.forEach(function (g) {
417      g.addEventListener("click", function (e) {
418        var a = e.target.closest(".gal__item");
419        if (!a) return;
420        e.preventDefault();
421        var all = $$(".gal__item", g);
422        open(all.map(function (l) {
423          var i = $("img", l);
424          return { src: l.href, alt: i ? i.alt : "" };
425        }), all.indexOf(a));
426      });
427    });
428
429    $$(".film__row").forEach(function (row) {
430      row.addEventListener("click", function (e) {
431        var b = e.target.closest(".film__item");
432        if (!b) return;
433        var all = $$(".film__item", row);
434        open(all.map(function (x) {
435          return {
436            video: x.dataset.video,
437            file: x.dataset.file,
438            poster: x.dataset.poster,
439            alt: x.getAttribute("aria-label")
440          };
441        }), all.indexOf(b));
442      });
443    });
444  }
445
446  /* ================================================================ form */
447  var form = $(".form");
448  if (form) {
449    var status = $(".form__status", form);
450    var send = $(".send", form);
451
452    /* contact.php only accepts a message carrying a token it issued, which
453       costs a round trip and means running this script -- the form-spam bots
454       hitting the endpoint just POST the field names blind and never get one.
455       The token is requested as soon as someone starts filling the form in,
456       and must be a few seconds old when it comes back: a person spends that
457       long typing, a script does not. If no token can be had we say so rather
458       than posting into a void, so a real enquiry is never lost silently. */
459    var tokenReq = null;
460    var getToken = function () {
461      if (!tokenReq) {
462        tokenReq = fetch(form.action + "?token=1", { headers: { Accept: "application/json" } })
463          .then(function (r) { return r.ok ? r.json() : null; })
464          .then(function (d) { return d && d.token ? { v: d.token, at: Date.now() } : null; })
465          .catch(function () { tokenReq = null; return null; });
466      }
467      return tokenReq;
468    };
469    form.addEventListener("focusin", getToken);
470    form.addEventListener("input", getToken);
471
472    var valid = function () {
473      var ok = true;
474      $$("[data-required]", form).forEach(function (f) {
475        var err = $('[data-err="' + f.name + '"]', form);
476        var msg = "";
477        if (!f.value.trim()) msg = form.dataset.msgRequired;
478        else if (f.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.value)) msg = form.dataset.msgEmail;
479        if (err) err.textContent = msg;
480        if (msg) ok = false;
481      });
482      return ok;
483    };
484
485    form.addEventListener("submit", function (e) {
486      e.preventDefault();
487      status.textContent = "";
488      if (!valid()) return;
489
490      var data = new FormData(form);
491      data.append("lang", form.dataset.lang || "de");
492      send.disabled = true;
493      status.textContent = form.dataset.msgSending;
494
495      getToken()
496        .then(function (tok) {
497          if (!tok) throw new Error("no token");
498          data.append("t", tok.v);
499          // hold back until the token is comfortably past the server's floor
500          var wait = 4000 - (Date.now() - tok.at);
501          return wait > 0 ? new Promise(function (r) { setTimeout(r, wait); }) : null;
502        })
503        .then(function () { return fetch(form.action, { method: "POST", body: data }); })
504        .then(function (r) { return r.ok ? r.json() : Promise.reject(r); })
505        .then(function (res) {
506          if (!res || !res.ok) throw new Error("rejected");
507          status.textContent = form.dataset.msgOk;
508          form.reset();
509          tokenReq = null;               // one token per message
510        })
511        .catch(function () { status.textContent = form.dataset.msgErr; })
512        .then(function () { send.disabled = false; });
513    });
514  }
515})();

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.