The .yt::after gradient overlay was sitting on top of the inserted iframe and stealing every click — clicks on the YouTube player controls bubbled to the .yt facade, which still matched [data-yt] and re-ran open(), replacing the iframe and restarting playback from 0. - Add pointer-events:none on .yt::after so it never blocks player clicks - On play, swap .yt to a .yt--playing state that hides the overlay, play button, and title overlay - Remove the data-yt/role/tabindex attrs after init so any leftover clicks on the facade no longer match the open() delegate - Add controls=1 explicitly and broaden the allow list (clipboard-write, web-share) for the embedded player
329 lines
16 KiB
JavaScript
329 lines
16 KiB
JavaScript
/* dlstack — distilled
|
|
* Loads data/links.json and renders sections, cards, YouTube facades.
|
|
* No search, no filter, no clock, no theme toggle. The page is the index.
|
|
*/
|
|
(() => {
|
|
"use strict";
|
|
|
|
const $ = (s, el = document) => el.querySelector(s);
|
|
const $$ = (s, el = document) => Array.from(el.querySelectorAll(s));
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
}[c]));
|
|
const frag = (html) => document.createRange().createContextualFragment(html);
|
|
|
|
const hostOf = (url) => {
|
|
try { return new URL(url, location.href).hostname.replace(/^www\./, ""); }
|
|
catch { return ""; }
|
|
};
|
|
const faviconFor = (url) => {
|
|
const h = hostOf(url);
|
|
return h ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(h)}&sz=128` : "";
|
|
};
|
|
const ytThumb = (id) => `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
|
|
|
|
function renderLink(it) {
|
|
const host = hostOf(it.url);
|
|
const custom = it.image || it.icon;
|
|
const src = custom || faviconFor(it.url);
|
|
const isFavicon = !custom;
|
|
const initial = (it.title || host || "·").trim().charAt(0).toUpperCase();
|
|
return `
|
|
<a class="card card--link reveal" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">
|
|
<span class="favicon" ${src ? "" : "data-fallback"}>
|
|
${src
|
|
? `<img loading="lazy" alt="" src="${esc(src)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}>`
|
|
: esc(initial)}
|
|
</span>
|
|
<span>
|
|
<span class="card__title">${esc(it.title)}</span>
|
|
${it.description ? `<span class="card__desc">${esc(it.description)}</span>` : ""}
|
|
<span class="card__meta">
|
|
${host ? `<span class="card__host">${esc(host)}</span>` : ""}
|
|
${dateMarkup(it)}
|
|
</span>
|
|
</span>
|
|
</a>`;
|
|
}
|
|
|
|
function renderProject(it) {
|
|
const featured = it.featured ? " card--featured" : "";
|
|
const tag = it.url ? "a" : "div";
|
|
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
|
const tags = (it.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join("");
|
|
return `
|
|
<${tag} class="card card--project${featured} reveal" ${attrs}>
|
|
<span class="card__title">${esc(it.title)}</span>
|
|
${it.description ? `<p class="card__desc">${esc(it.description)}</p>` : ""}
|
|
${(tags || it.date) ? `<div class="tags">${tags}${dateMarkup(it)}</div>` : ""}
|
|
</${tag}>`;
|
|
}
|
|
|
|
function renderYouTube(it) {
|
|
return `
|
|
<div class="card card--youtube reveal">
|
|
<div class="yt" role="button" tabindex="0" aria-label="Play: ${esc(it.title)}" data-yt="${esc(it.id)}" style="background-image:url('${esc(ytThumb(it.id))}')">
|
|
<div class="yt__play" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
|
|
</div>
|
|
<div class="yt__title">${esc(it.title)}${it.date ? ` <span class="yt__date">${esc(fmtDate(it.date))}</span>` : ""}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderPortfolio(it) {
|
|
const src = it.image || it.url;
|
|
const tag = it.url ? "a" : "div";
|
|
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
|
const ratio = it.ratio ? ` style="--portfolio-ratio:${esc(String(it.ratio).replace(":", " / "))}"` : "";
|
|
return `
|
|
<${tag} class="card card--portfolio reveal" ${attrs}>
|
|
<div class="portfolio__media"${ratio}>
|
|
${src ? `<img loading="lazy" alt="${esc(it.title || "")}" src="${esc(src)}">` : ""}
|
|
</div>
|
|
${(it.title || it.description || it.date) ? `
|
|
<div class="portfolio__caption">
|
|
${it.title ? `<span class="card__title">${esc(it.title)}</span>` : ""}
|
|
${it.description ? `<span class="card__desc">${esc(it.description)}</span>` : ""}
|
|
${dateMarkup(it)}
|
|
</div>` : ""}
|
|
</${tag}>`;
|
|
}
|
|
|
|
function renderClient(it) {
|
|
const host = hostOf(it.url);
|
|
const custom = it.image || it.icon;
|
|
const src = custom || faviconFor(it.url);
|
|
const isFavicon = !custom;
|
|
const initial = (it.title || host || "·").trim().charAt(0).toUpperCase();
|
|
const tag = it.url ? "a" : "div";
|
|
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
|
return `
|
|
<${tag} class="card card--client reveal" ${attrs} title="${esc(it.title || host || "")}">
|
|
<span class="client__logo" ${src ? "" : "data-fallback"}>
|
|
${src
|
|
? `<img loading="lazy" alt="${esc(it.title || host || "")}" src="${esc(src)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}>`
|
|
: esc(initial)}
|
|
</span>
|
|
${it.title ? `<span class="client__title">${esc(it.title)}</span>` : ""}
|
|
${dateMarkup(it)}
|
|
</${tag}>`;
|
|
}
|
|
|
|
const renderItem = (it) =>
|
|
it.type === "youtube" ? renderYouTube(it) :
|
|
it.type === "card" ? renderProject(it) :
|
|
it.type === "client" ? renderClient(it) :
|
|
it.type === "portfolio" ? renderPortfolio(it) :
|
|
renderLink(it);
|
|
|
|
function renderSection(sec, n) {
|
|
const items = (sec.items || []).map(renderItem).join("");
|
|
const num = String(n).padStart(2, "0");
|
|
const isClients = sec.layout === "clients" || sec.items?.[0]?.type === "client";
|
|
const gridClass = isClients ? "grid--clients" : "grid";
|
|
return `
|
|
<section class="section">
|
|
<header class="section__head">
|
|
<div class="section__numwrap">
|
|
<small>№</small>
|
|
<span class="section__num">${num}</span>
|
|
</div>
|
|
<div class="section__titlewrap">
|
|
<h2 class="section__title">${esc(sec.label)}</h2>
|
|
${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""}
|
|
</div>
|
|
</header>
|
|
<div class="${gridClass}">${items}</div>
|
|
</section>`;
|
|
}
|
|
|
|
const SOCIAL_ICONS = {
|
|
github: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.87-1.37-3.87-1.37-.52-1.34-1.28-1.7-1.28-1.7-1.05-.71.08-.7.08-.7 1.16.08 1.77 1.2 1.77 1.2 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.3 1.19-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11.05 11.05 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.74.8 1.18 1.84 1.18 3.1 0 4.43-2.69 5.41-5.25 5.69.41.36.78 1.06.78 2.14v3.17c0 .31.2.67.8.56A11.5 11.5 0 0 0 12 .5Z"/></svg>',
|
|
linkedin: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.98 3.5A2.5 2.5 0 1 1 4.97 8.5 2.5 2.5 0 0 1 4.98 3.5ZM3 9.75h4V21H3V9.75ZM9.5 9.75h3.8v1.55h.05c.53-1 1.83-2.05 3.77-2.05 4.03 0 4.78 2.65 4.78 6.1V21H18V16.1c0-1.17-.02-2.68-1.63-2.68-1.63 0-1.88 1.27-1.88 2.6V21H9.5V9.75Z"/></svg>',
|
|
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>',
|
|
rss: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 3a16 16 0 0 1 16 16h-3A13 13 0 0 0 5 6V3Zm0 7a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6v-3Zm1.5 6a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"/></svg>',
|
|
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
|
|
calendar: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days-icon lucide-calendar-days"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>',
|
|
bluesky: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bluesky</title><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>',
|
|
lastfm: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Last.fm</title><path d="M10.584 17.21l-.88-2.392s-1.43 1.594-3.573 1.594c-1.897 0-3.244-1.649-3.244-4.288 0-3.382 1.704-4.591 3.381-4.591 2.42 0 3.189 1.567 3.849 3.574l.88 2.749c.88 2.666 2.529 4.81 7.285 4.81 3.409 0 5.718-1.044 5.718-3.793 0-2.227-1.265-3.381-3.63-3.931l-1.758-.385c-1.21-.275-1.567-.77-1.567-1.595 0-.934.742-1.484 1.952-1.484 1.32 0 2.034.495 2.144 1.677l2.749-.33c-.22-2.474-1.924-3.492-4.729-3.492-2.474 0-4.893.935-4.893 3.932 0 1.87.907 3.051 3.189 3.601l1.87.44c1.402.33 1.869.907 1.869 1.704 0 1.017-.99 1.43-2.86 1.43-2.776 0-3.93-1.457-4.59-3.464l-.907-2.75c-1.155-3.573-2.997-4.893-6.653-4.893C2.144 5.333 0 7.89 0 12.233c0 4.18 2.144 6.434 5.993 6.434 3.106 0 4.591-1.457 4.591-1.457z"/></svg>'
|
|
};
|
|
|
|
function renderSocial(items) {
|
|
return items.map(s =>
|
|
`<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${SOCIAL_ICONS[s.icon] || SOCIAL_ICONS.link}<span>${esc(s.label)}</span></a>`
|
|
).join("");
|
|
}
|
|
|
|
function nameMarkup(name) {
|
|
if (!name) return "";
|
|
const parts = String(name).trim().split(/\s+/);
|
|
if (parts.length < 2) return `<span class="hero__name-first">${esc(name)}</span>`;
|
|
const last = parts.pop();
|
|
return `<span class="hero__name-first">${esc(parts.join(" "))}</span> <em>${esc(last)}</em>`;
|
|
}
|
|
|
|
function fmtDate(d) {
|
|
if (!d) return "";
|
|
const m = String(d).match(/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/);
|
|
if (!m) return String(d);
|
|
const [, y, mo, dd] = m;
|
|
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
if (dd && mo) return `${months[+mo - 1]} ${+dd}, ${y}`;
|
|
if (mo) return `${months[+mo - 1]} ${y}`;
|
|
return y;
|
|
}
|
|
const dateMarkup = (it) =>
|
|
it.date ? `<time class="card__date" datetime="${esc(it.date)}">${esc(fmtDate(it.date))}</time>` : "";
|
|
|
|
function attachFaviconFallback(root) {
|
|
$$("img[data-fallback-initial]", root).forEach(img => {
|
|
img.addEventListener("error", () => {
|
|
const initial = img.dataset.fallbackInitial || "·";
|
|
const parent = img.parentElement;
|
|
if (parent) { parent.setAttribute("data-fallback", ""); parent.textContent = initial; }
|
|
}, { once: true });
|
|
});
|
|
}
|
|
|
|
function attachYouTube(root) {
|
|
const open = (fac) => {
|
|
const id = fac.dataset.yt;
|
|
if (!id) return;
|
|
const iframe = document.createElement("iframe");
|
|
iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&rel=0&controls=1`;
|
|
iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
|
|
iframe.allowFullscreen = true;
|
|
iframe.title = fac.getAttribute("aria-label") || "YouTube video";
|
|
fac.replaceChildren(iframe);
|
|
fac.classList.add("yt--playing");
|
|
fac.removeAttribute("data-yt");
|
|
fac.removeAttribute("role");
|
|
fac.removeAttribute("tabindex");
|
|
fac.style.cursor = "default";
|
|
};
|
|
root.addEventListener("click", (e) => {
|
|
const fac = e.target.closest("[data-yt]");
|
|
if (fac) { e.preventDefault(); open(fac); }
|
|
});
|
|
root.addEventListener("keydown", (e) => {
|
|
if (e.key !== "Enter" && e.key !== " ") return;
|
|
const fac = e.target.closest("[data-yt]");
|
|
if (fac) { e.preventDefault(); open(fac); }
|
|
});
|
|
}
|
|
|
|
function attachTheme() {
|
|
const root = document.documentElement;
|
|
const btn = $("#theme");
|
|
if (!btn) return;
|
|
const order = ["auto", "light", "dark"];
|
|
const label = { auto: "Auto", light: "Light", dark: "Dark" };
|
|
const stored = localStorage.getItem("dlstack-theme");
|
|
if (!order.includes(root.dataset.theme)) root.dataset.theme = "auto";
|
|
if (order.includes(stored)) root.dataset.theme = stored;
|
|
const sync = () => {
|
|
btn.textContent = label[root.dataset.theme];
|
|
btn.setAttribute("aria-label", `Theme: ${label[root.dataset.theme]}. Click to change.`);
|
|
};
|
|
sync();
|
|
btn.addEventListener("click", () => {
|
|
const i = order.indexOf(root.dataset.theme);
|
|
root.dataset.theme = order[(i + 1) % order.length];
|
|
localStorage.setItem("dlstack-theme", root.dataset.theme);
|
|
sync();
|
|
});
|
|
}
|
|
|
|
function attachReveal(root) {
|
|
if (!("IntersectionObserver" in window)) {
|
|
$$(".reveal", root).forEach(el => el.classList.add("in"));
|
|
return;
|
|
}
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach(en => {
|
|
if (en.isIntersecting) { en.target.classList.add("in"); io.unobserve(en.target); }
|
|
});
|
|
}, { threshold: 0.08, rootMargin: "0px 0px -40px 0px" });
|
|
$$(".reveal", root).forEach(el => io.observe(el));
|
|
}
|
|
|
|
async function loadData() {
|
|
const sources = ["data/links.json", "data/links.example.json"];
|
|
let lastErr;
|
|
for (const src of sources) {
|
|
try {
|
|
const res = await fetch(src, { cache: "no-cache" });
|
|
if (!res.ok) { lastErr = new Error(`${src}: HTTP ${res.status}`); continue; }
|
|
return await res.json();
|
|
} catch (err) { lastErr = err; }
|
|
}
|
|
throw lastErr || new Error("No data file found.");
|
|
}
|
|
|
|
async function main() {
|
|
const app = $("#app");
|
|
let data;
|
|
try {
|
|
data = await loadData();
|
|
} catch (err) {
|
|
const p = document.createElement("p");
|
|
p.style.cssText = "color:var(--accent);font-family:var(--mono);padding:2rem;max-width:48ch";
|
|
p.textContent = `Couldn't load data/links.json or data/links.example.json. Check that one exists and is valid JSON. Details: ${err.message}`;
|
|
app.replaceChildren(p);
|
|
return;
|
|
}
|
|
|
|
if (data.theme?.accent) {
|
|
document.documentElement.style.setProperty("--accent", data.theme.accent);
|
|
}
|
|
const tpl = data.theme?.template === "swiss" ? "swiss" : "editorial";
|
|
document.documentElement.dataset.template = tpl;
|
|
try { localStorage.setItem("dlstack-template", tpl); } catch (e) {}
|
|
|
|
const p = data.profile || {};
|
|
const sections = data.sections || [];
|
|
const social = data.social || [];
|
|
document.title = `${p.name || "Links"}`;
|
|
|
|
const taglineMarkup = (t) =>
|
|
esc(t).replace(/\s*[·•|]\s*/g, '<span aria-hidden="true">·</span>');
|
|
|
|
const html = `
|
|
<div class="marker">
|
|
<span class="marker__brand"><span class="star" aria-hidden="true">✱</span> Index №01</span>
|
|
<span class="marker__year">MMXXVI</span>
|
|
<button id="theme" class="theme" type="button">Auto</button>
|
|
</div>
|
|
|
|
<header class="hero">
|
|
<span class="hero__asterism" aria-hidden="true">✱</span>
|
|
<h1 class="hero__name">${nameMarkup(p.name)}</h1>
|
|
${p.tagline ? `<p class="hero__tagline">${taglineMarkup(p.tagline)}</p>` : ""}
|
|
${p.bio ? `<p class="hero__bio">${esc(p.bio)}</p>` : ""}
|
|
${social.length ? `<nav class="social" aria-label="Social">${renderSocial(social)}</nav>` : ""}
|
|
</header>
|
|
|
|
<main>${sections.map((s, i) => renderSection(s, i + 1)).join("")}</main>
|
|
|
|
<footer class="foot">
|
|
<span>${esc(data.footer?.copy || "")}</span>
|
|
<span class="foot__mark" aria-hidden="true">— ✱ —</span>
|
|
<span class="foot__right">© ${new Date().getFullYear()} ${esc(p.name || "")}</span>
|
|
</footer>
|
|
`;
|
|
app.replaceChildren(frag(html));
|
|
|
|
attachFaviconFallback(app);
|
|
attachYouTube(app);
|
|
attachReveal(app);
|
|
attachTheme();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", main);
|
|
} else {
|
|
main();
|
|
}
|
|
})();
|