Files
dlStack/assets/js/app.js
Joel Brock 36084013c8 Initial commit: dlstack file-driven link index
A hand-rolled linktree alternative — pure static HTML/CSS/JS, no build
step. Drop on any shared host via SFTP and edit data/links.json to update.

Features
- File-driven content via data/links.json (links, projects, YouTube,
  client tiles, portfolio pieces)
- Two interchangeable templates: editorial (Fraunces + paper + vermilion)
  and swiss (Archivo grotesque, all-caps poster)
- Auto/light/dark theme toggle with no-flash boot script
- Auto-fetched favicons via Google S2 (with image-URL override)
- Lazy YouTube facades (no third-party JS until clicked)
- Adaptive client-logo grid
- Scroll-triggered reveal animations
- ~40 KB total payload, ~12 KB gzipped

The repo ships links.example.json as a demo; data/links.json is
gitignored so personal content stays out of the public repo.
2026-05-15 13:37:09 -07:00

304 lines
14 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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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>` : ""}
${host ? `<span class="card__host">${esc(host)}</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 ? `<div class="tags">${tags}</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)}</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) ? `
<div class="portfolio__caption">
${it.title ? `<span class="card__title">${esc(it.title)}</span>` : ""}
${it.description ? `<span class="card__desc">${esc(it.description)}</span>` : ""}
</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>` : ""}
</${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>'
};
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 esc(name);
const last = parts.pop();
return `${esc(parts.join(" "))} <em>${esc(last)}</em>`;
}
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 iframe = document.createElement("iframe");
iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(fac.dataset.yt)}?autoplay=1&rel=0`;
iframe.allow = "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture";
iframe.allowFullscreen = true;
iframe.title = fac.getAttribute("aria-label") || "YouTube video";
fac.replaceChildren(iframe);
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();
}
})();