170 lines
7.2 KiB
JavaScript
170 lines
7.2 KiB
JavaScript
|
|
/* raayoni — grid, filtering, sorting, light/dark.
|
|||
|
|
*
|
|||
|
|
* The grid is Muuri (muuri.dev), as on dominofactory.net. Filtering is by
|
|||
|
|
* discipline (Category) and by engagement (Project) — two independent axes, so
|
|||
|
|
* they compose. Sorting is by year, which spans 2000–2022.
|
|||
|
|
*/
|
|||
|
|
(function () {
|
|||
|
|
"use strict";
|
|||
|
|
|
|||
|
|
/* ── light / dark ──────────────────────────────────────────────────────
|
|||
|
|
* The initial value is applied by an inline script in <head> so there is no
|
|||
|
|
* flash of the wrong ground colour before this file loads. Here we only
|
|||
|
|
* handle the toggle and keep the button label honest.
|
|||
|
|
*/
|
|||
|
|
var root = document.documentElement;
|
|||
|
|
|
|||
|
|
function currentTheme() {
|
|||
|
|
return root.getAttribute("data-theme") ||
|
|||
|
|
(window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function paintToggle(btn) {
|
|||
|
|
if (!btn) return;
|
|||
|
|
var dark = currentTheme() === "dark";
|
|||
|
|
btn.textContent = dark ? "light" : "dark";
|
|||
|
|
btn.setAttribute("aria-label", "Switch to " + (dark ? "light" : "dark") + " mode");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var toggle = document.querySelector("[data-theme-toggle]");
|
|||
|
|
paintToggle(toggle);
|
|||
|
|
if (toggle) {
|
|||
|
|
toggle.addEventListener("click", function () {
|
|||
|
|
var next = currentTheme() === "dark" ? "light" : "dark";
|
|||
|
|
root.setAttribute("data-theme", next);
|
|||
|
|
try { localStorage.setItem("theme", next); } catch (e) { /* private mode */ }
|
|||
|
|
paintToggle(toggle);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
/* ── contact address ──────────────────────────────────────────────────────
|
|||
|
|
* The address is stored rot13'd, so the page source contains no string an
|
|||
|
|
* email regex will match, and is written out as a real mailto only once the
|
|||
|
|
* document is live. Without JS the visible fallback stays human-readable
|
|||
|
|
* ("tobias at raayoni-last dot com") rather than silently breaking.
|
|||
|
|
*
|
|||
|
|
* This stops regex harvesters, not a scraper that executes JS. The previous
|
|||
|
|
* version used document.write inside Markdown, which rendered its own source
|
|||
|
|
* as visible text and was neither clickable nor hidden.
|
|||
|
|
*/
|
|||
|
|
Array.prototype.forEach.call(document.querySelectorAll(".mail[data-r]"), function (el) {
|
|||
|
|
var addr = el.getAttribute("data-r").replace(/[a-zA-Z]/g, function (c) {
|
|||
|
|
return String.fromCharCode((c <= "Z" ? 90 : 122) >= (c = c.charCodeAt(0) + 13) ? c : c - 26);
|
|||
|
|
});
|
|||
|
|
var a = document.createElement("a");
|
|||
|
|
a.href = "mailto:" + addr;
|
|||
|
|
a.textContent = addr;
|
|||
|
|
el.replaceWith(a);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ── the grid ─────────────────────────────────────────────────────────── */
|
|||
|
|
var el = document.querySelector(".grid");
|
|||
|
|
if (!el || typeof Muuri === "undefined") return;
|
|||
|
|
|
|||
|
|
var grid = new Muuri(el, {
|
|||
|
|
items: ".item",
|
|||
|
|
layout: { fillGaps: false, rounding: true },
|
|||
|
|
layoutDuration: 280,
|
|||
|
|
layoutEasing: "cubic-bezier(.2,.7,.3,1)",
|
|||
|
|
sortData: {
|
|||
|
|
year: function (item, element) {
|
|||
|
|
return parseInt(element.getAttribute("data-year"), 10) || 0;
|
|||
|
|
},
|
|||
|
|
title: function (item, element) {
|
|||
|
|
return (element.getAttribute("data-title") || "").toLowerCase();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* Tiles are images of wildly varying size fetched from an archive, so the
|
|||
|
|
first layout is usually wrong until they have decoded. Relayout as they
|
|||
|
|
arrive rather than guessing dimensions up front. */
|
|||
|
|
Array.prototype.forEach.call(el.querySelectorAll("img"), function (img) {
|
|||
|
|
if (img.complete) return;
|
|||
|
|
img.addEventListener("load", relayout, { once: true });
|
|||
|
|
img.addEventListener("error", function () {
|
|||
|
|
img.closest(".hoverthumb").classList.add("empty");
|
|||
|
|
relayout();
|
|||
|
|
}, { once: true });
|
|||
|
|
});
|
|||
|
|
var pending = null;
|
|||
|
|
function relayout() {
|
|||
|
|
clearTimeout(pending);
|
|||
|
|
pending = setTimeout(function () { grid.refreshItems().layout(); }, 60);
|
|||
|
|
}
|
|||
|
|
window.addEventListener("load", relayout);
|
|||
|
|
|
|||
|
|
/* ── grouping, filtering, sorting ─────────────────────────────────────── */
|
|||
|
|
/* Headers are grid items too, so they must be hidden whenever the grid is
|
|||
|
|
not in its grouped arrangement — a filtered or re-sorted grid would
|
|||
|
|
otherwise strand them above the wrong tiles. */
|
|||
|
|
function grouped(on) {
|
|||
|
|
if (on) { el.setAttribute("data-grouped", "true"); }
|
|||
|
|
else { el.removeAttribute("data-grouped"); }
|
|||
|
|
}
|
|||
|
|
function isGrouped() { return el.hasAttribute("data-grouped"); }
|
|||
|
|
|
|||
|
|
var active = { category: "", project: "" };
|
|||
|
|
var countEl = document.querySelector("[data-count]");
|
|||
|
|
|
|||
|
|
function apply() {
|
|||
|
|
var filtering = !!(active.category || active.project);
|
|||
|
|
if (filtering) grouped(false);
|
|||
|
|
grid.filter(function (item) {
|
|||
|
|
var e = item.getElement();
|
|||
|
|
if (e.classList.contains("head")) return isGrouped() && !filtering;
|
|||
|
|
var okCat = !active.category || e.getAttribute("data-category") === active.category;
|
|||
|
|
var okPrj = !active.project || e.getAttribute("data-project") === active.project;
|
|||
|
|
return okCat && okPrj;
|
|||
|
|
});
|
|||
|
|
if (countEl) {
|
|||
|
|
var n = grid.getItems().filter(function (i) {
|
|||
|
|
return i.isVisible() && !i.getElement().classList.contains("head");
|
|||
|
|
}).length;
|
|||
|
|
countEl.textContent = n + (n === 1 ? " entry" : " entries");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Array.prototype.forEach.call(document.querySelectorAll("[data-filter]"), function (btn) {
|
|||
|
|
btn.addEventListener("click", function () {
|
|||
|
|
var axis = btn.getAttribute("data-axis") || "category";
|
|||
|
|
var val = btn.getAttribute("data-filter");
|
|||
|
|
active[axis] = active[axis] === val ? "" : val; /* click again to clear */
|
|||
|
|
document.querySelectorAll('[data-axis="' + axis + '"]').forEach(function (b) {
|
|||
|
|
b.setAttribute("aria-pressed", String(b.getAttribute("data-filter") === active[axis]));
|
|||
|
|
});
|
|||
|
|
apply();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ── sorting ──────────────────────────────────────────────────────────── */
|
|||
|
|
Array.prototype.forEach.call(document.querySelectorAll("[data-sort]"), function (btn) {
|
|||
|
|
btn.addEventListener("click", function () {
|
|||
|
|
var how = btn.getAttribute("data-sort");
|
|||
|
|
if (how === "group") {
|
|||
|
|
active.category = active.project = "";
|
|||
|
|
document.querySelectorAll("[data-filter]").forEach(function (b) {
|
|||
|
|
b.setAttribute("aria-pressed", "false");
|
|||
|
|
});
|
|||
|
|
grouped(true);
|
|||
|
|
grid.sort(function (a, b) { return a._dom - b._dom; }); /* DOM order */
|
|||
|
|
} else {
|
|||
|
|
grouped(false);
|
|||
|
|
grid.sort(how === "year-asc" ? "year:asc" : how === "title" ? "title:asc" : "year:desc");
|
|||
|
|
}
|
|||
|
|
document.querySelectorAll("[data-sort]").forEach(function (b) {
|
|||
|
|
b.setAttribute("aria-pressed", String(b === btn));
|
|||
|
|
});
|
|||
|
|
apply();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* The grid is rendered grouped by engagement so that arrangement can be
|
|||
|
|
restored from DOM order; newest is the default view, so sort on init. */
|
|||
|
|
grid.getItems().forEach(function (item, i) { item._dom = i; });
|
|||
|
|
grid.sort("year:desc");
|
|||
|
|
|
|||
|
|
apply();
|
|||
|
|
})();
|