- Replace the flat dither backdrop with a lit, rotating torus drawn the way donut.c does it: a z-buffer per cell and a luminance ramp from a dot to a solid block, sized against the shorter viewport axis. The dither field stays underneath as dust. It leads on the landing page and fades back where there is reading to do, and a scrim keeps the projects heading legible over it. - Thicken the glass: one .surface treatment with a heavier blur, more transparency and an SVG film grain, shared by the header, hero, cards, panels and footer. - Give the landing page labelled contact cards for LinkedIn, Gitea and email in place of three bare icons. - Drop the status chips from project cards and entry headers. - Remove the light theme, its toggle and theme.js outright. - Add restrained motion: underlines growing from the left on links and nav items, a lift on cards, buttons and contact cards, and tags brightening with their card. All of it is off under prefers-reduced-motion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
12 KiB
JavaScript
353 lines
12 KiB
JavaScript
(function() {
|
|
const CONTENT_INDEX_PATH = "./public/content/index.json";
|
|
|
|
const escapeHtml = (value) => String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
const formatDate = (value) => {
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
|
|
const date = new Date(`${value}T00:00:00`);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("en-CA", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric"
|
|
}).format(date);
|
|
};
|
|
|
|
const getIndex = async () => {
|
|
if (window.location.protocol === "file:") {
|
|
throw new Error("This content page needs a local web server. Run python3 -m http.server and open http://localhost:8000.");
|
|
}
|
|
|
|
const response = await fetch(CONTENT_INDEX_PATH);
|
|
if (!response.ok) {
|
|
throw new Error("Unable to load content index.");
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
const stripFrontMatter = (markdown) => {
|
|
if (!markdown.startsWith("---")) {
|
|
return markdown;
|
|
}
|
|
|
|
const parts = markdown.split("---");
|
|
if (parts.length < 3) {
|
|
return markdown;
|
|
}
|
|
|
|
return parts.slice(2).join("---").trim();
|
|
};
|
|
|
|
const renderTags = (tags, className) => {
|
|
if (!tags || !tags.length) {
|
|
return "";
|
|
}
|
|
|
|
return `<div class="${className}">${tags.map((tag) => `<span class="${className === "tag-list" ? "tag" : "entry-tag"}">${escapeHtml(tag)}</span>`).join("")}</div>`;
|
|
};
|
|
|
|
const setMetaContent = (selector, content) => {
|
|
const element = document.querySelector(selector);
|
|
if (!element || !content) {
|
|
return;
|
|
}
|
|
|
|
element.setAttribute("content", content);
|
|
};
|
|
|
|
const setCanonicalHref = (href) => {
|
|
const canonical = document.querySelector("link[rel='canonical']");
|
|
if (!canonical || !href) {
|
|
return;
|
|
}
|
|
|
|
canonical.setAttribute("href", href);
|
|
};
|
|
|
|
const setEntryJsonLd = (entry, url, image) => {
|
|
let script = document.getElementById("entry-jsonld");
|
|
|
|
if (!script) {
|
|
script = document.createElement("script");
|
|
script.id = "entry-jsonld";
|
|
script.type = "application/ld+json";
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
script.textContent = JSON.stringify({
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
"@id": `${url}#article`,
|
|
"url": url,
|
|
"headline": entry.title,
|
|
"description": entry.summary || "Project entry or update from Braeden Sowinski.",
|
|
"datePublished": entry.date || undefined,
|
|
"dateModified": entry.date || undefined,
|
|
"image": image,
|
|
"author": {
|
|
"@id": "https://sowinski.dev/#person",
|
|
"name": "Braeden Sowinski",
|
|
"url": "https://sowinski.dev/"
|
|
},
|
|
"publisher": {
|
|
"@id": "https://sowinski.dev/#person"
|
|
},
|
|
"mainEntityOfPage": url,
|
|
"isPartOf": {
|
|
"@id": "https://sowinski.dev/#website"
|
|
}
|
|
});
|
|
};
|
|
|
|
const updateEntryMeta = (entry) => {
|
|
const baseUrl = "https://sowinski.dev";
|
|
const title = `${entry.title} | Braeden Sowinski`;
|
|
const description = entry.summary || "Project entry or update from Braeden Sowinski.";
|
|
const url = `${baseUrl}/entry.html?type=${encodeURIComponent(entry.type)}&slug=${encodeURIComponent(entry.slug)}`;
|
|
const image = "https://avatars.githubusercontent.com/u/48144618?v=4";
|
|
|
|
document.title = title;
|
|
setCanonicalHref(url);
|
|
setMetaContent("meta[name='description']", description);
|
|
setMetaContent("meta[property='og:title']", title);
|
|
setMetaContent("meta[property='og:description']", description);
|
|
setMetaContent("meta[property='og:url']", url);
|
|
setMetaContent("meta[property='og:image']", image);
|
|
setMetaContent("meta[name='twitter:title']", title);
|
|
setMetaContent("meta[name='twitter:description']", description);
|
|
setMetaContent("meta[name='twitter:image']", image);
|
|
setEntryJsonLd(entry, url, image);
|
|
};
|
|
|
|
const renderLinks = (entry, className) => {
|
|
const links = [];
|
|
const sourceLabel = entry.github && entry.github.includes("git.sowinski.dev") ? "Gitea" : "GitHub";
|
|
|
|
links.push(`<a class="${className} ${className}-primary" href="./entry.html?type=${encodeURIComponent(entry.type)}&slug=${encodeURIComponent(entry.slug)}">Details</a>`);
|
|
|
|
if (entry.demo) {
|
|
links.push(`<a class="${className}" href="${escapeHtml(entry.demo)}" target="_blank" rel="noopener noreferrer">Live</a>`);
|
|
} else if (entry.github) {
|
|
links.push(`<a class="${className}" href="${escapeHtml(entry.github)}" target="_blank" rel="noopener noreferrer">${sourceLabel}</a>`);
|
|
}
|
|
|
|
return `<div class="${className.replace("card-link", "card-links")}">${links.join("")}</div>`;
|
|
};
|
|
|
|
const createCard = (entry) => {
|
|
const featuredClass = entry.featured ? " featured" : "";
|
|
const anchorId = entry.slug ? ` id="${escapeHtml(entry.slug)}"` : "";
|
|
|
|
return `
|
|
<article class="content-card surface${featuredClass}"${anchorId}>
|
|
<div class="card-meta">
|
|
<span class="card-kicker">${escapeHtml(entry.label || entry.type)}</span>
|
|
${entry.date ? `<span class="card-date">${escapeHtml(entry.date)}</span>` : ""}
|
|
</div>
|
|
<h3>${escapeHtml(entry.title)}</h3>
|
|
<p class="card-summary">${escapeHtml(entry.summary || "")}</p>
|
|
${renderTags(entry.tags || [], "tag-list")}
|
|
${renderLinks(entry, "card-link")}
|
|
</article>
|
|
`;
|
|
};
|
|
|
|
const setSectionCount = (countId, entries) => {
|
|
const element = document.getElementById(countId);
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
element.textContent = `${entries.length} ${entries.length === 1 ? "entry" : "entries"}`;
|
|
};
|
|
|
|
const renderSection = (containerId, entries, emptyMessage) => {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
if (!entries.length) {
|
|
container.innerHTML = `<p class="loading-state">${escapeHtml(emptyMessage)}</p>`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = entries.map(createCard).join("");
|
|
};
|
|
|
|
const initProjectsToggle = (entries) => {
|
|
const projectsGrid = document.getElementById("projects-grid");
|
|
const toggleShell = document.getElementById("projects-toggle-shell");
|
|
const toggleButton = document.getElementById("projects-toggle");
|
|
const defaultVisibleCount = 6;
|
|
|
|
if (!projectsGrid || !toggleShell || !toggleButton) {
|
|
return;
|
|
}
|
|
|
|
const cards = Array.from(projectsGrid.querySelectorAll(".content-card"));
|
|
if (entries.length <= defaultVisibleCount || cards.length <= defaultVisibleCount) {
|
|
toggleShell.hidden = true;
|
|
return;
|
|
}
|
|
|
|
const setExpandedState = (expanded) => {
|
|
cards.forEach((card, index) => {
|
|
card.classList.toggle("is-hidden", !expanded && index >= defaultVisibleCount);
|
|
});
|
|
|
|
toggleButton.textContent = expanded ? "Show fewer projects" : "Show more projects";
|
|
toggleButton.setAttribute("aria-expanded", String(expanded));
|
|
};
|
|
|
|
toggleShell.hidden = false;
|
|
setExpandedState(false);
|
|
|
|
toggleButton.addEventListener("click", () => {
|
|
const expanded = toggleButton.getAttribute("aria-expanded") === "true";
|
|
setExpandedState(!expanded);
|
|
});
|
|
};
|
|
|
|
const compareByNewestDate = (left, right) => {
|
|
const leftTime = left.date ? Date.parse(`${left.date}T00:00:00`) : Number.NEGATIVE_INFINITY;
|
|
const rightTime = right.date ? Date.parse(`${right.date}T00:00:00`) : Number.NEGATIVE_INFINITY;
|
|
|
|
if (leftTime !== rightTime) {
|
|
return rightTime - leftTime;
|
|
}
|
|
|
|
const leftOrder = Number.isFinite(left.order) ? left.order : Number.MAX_SAFE_INTEGER;
|
|
const rightOrder = Number.isFinite(right.order) ? right.order : Number.MAX_SAFE_INTEGER;
|
|
|
|
if (leftOrder !== rightOrder) {
|
|
return leftOrder - rightOrder;
|
|
}
|
|
|
|
return String(left.title || "").localeCompare(String(right.title || ""));
|
|
};
|
|
|
|
const compareByPriorityOrder = (left, right) => {
|
|
const leftOrder = Number.isFinite(left.order) ? left.order : Number.MAX_SAFE_INTEGER;
|
|
const rightOrder = Number.isFinite(right.order) ? right.order : Number.MAX_SAFE_INTEGER;
|
|
|
|
if (leftOrder !== rightOrder) {
|
|
return leftOrder - rightOrder;
|
|
}
|
|
|
|
const leftTime = left.date ? Date.parse(`${left.date}T00:00:00`) : Number.NEGATIVE_INFINITY;
|
|
const rightTime = right.date ? Date.parse(`${right.date}T00:00:00`) : Number.NEGATIVE_INFINITY;
|
|
|
|
if (leftTime !== rightTime) {
|
|
return rightTime - leftTime;
|
|
}
|
|
|
|
return String(left.title || "").localeCompare(String(right.title || ""));
|
|
};
|
|
|
|
const initListPage = async () => {
|
|
try {
|
|
const index = await getIndex();
|
|
const sortedProjects = [...(index.projects || [])].sort(compareByPriorityOrder);
|
|
renderSection("projects-grid", sortedProjects, "No projects added yet.");
|
|
setSectionCount("projects-count", sortedProjects);
|
|
initProjectsToggle(sortedProjects);
|
|
|
|
const sortedUpdates = [...(index.updates || [])].sort(compareByNewestDate);
|
|
renderSection("updates-grid", sortedUpdates, "No updates added yet.");
|
|
setSectionCount("updates-count", sortedUpdates);
|
|
} catch (error) {
|
|
renderSection("projects-grid", [], error.message);
|
|
renderSection("updates-grid", [], error.message);
|
|
}
|
|
};
|
|
|
|
const initEntryPage = async () => {
|
|
const shell = document.getElementById("entry-shell");
|
|
if (!shell) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const type = params.get("type");
|
|
const slug = params.get("slug");
|
|
|
|
if (!type || !slug) {
|
|
shell.innerHTML = `
|
|
<div class="entry-empty">
|
|
<p class="eyebrow">Not found</p>
|
|
<h1 class="entry-title">This entry could not be loaded.</h1>
|
|
<a class="entry-back" href="./projects.html">← Back to projects</a>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const index = await getIndex();
|
|
const entries = [...(index.projects || []), ...(index.updates || [])];
|
|
const entry = entries.find((item) => item.type === type && item.slug === slug);
|
|
|
|
if (!entry) {
|
|
throw new Error("Entry not found.");
|
|
}
|
|
|
|
updateEntryMeta(entry);
|
|
|
|
const response = await fetch(entry.path);
|
|
if (!response.ok) {
|
|
throw new Error("Unable to load markdown entry.");
|
|
}
|
|
|
|
const rawMarkdown = await response.text();
|
|
const markdown = stripFrontMatter(rawMarkdown);
|
|
|
|
shell.innerHTML = `
|
|
<div class="entry-header">
|
|
<a class="entry-back" href="./projects.html">← Back to projects</a>
|
|
<div class="entry-meta">
|
|
<span class="entry-pill entry-pill-primary">${escapeHtml(entry.label || entry.type)}</span>
|
|
${entry.date ? `<span class="entry-pill entry-pill-secondary">${escapeHtml(formatDate(entry.date))}</span>` : ""}
|
|
</div>
|
|
<h1 class="entry-title">${escapeHtml(entry.title)}</h1>
|
|
<p class="entry-summary">${escapeHtml(entry.summary || "")}</p>
|
|
${renderTags(entry.tags || [], "entry-tag-list")}
|
|
<div class="entry-links">
|
|
${entry.demo ? `<a class="inline-button inline-button-primary" href="${escapeHtml(entry.demo)}" target="_blank" rel="noopener noreferrer">Live</a>` : ""}
|
|
${entry.github ? `<a class="inline-button" href="${escapeHtml(entry.github)}" target="_blank" rel="noopener noreferrer">${entry.github.includes("git.sowinski.dev") ? "Gitea" : "GitHub"}</a>` : ""}
|
|
</div>
|
|
</div>
|
|
<article class="entry-content">
|
|
${window.marked.parse(markdown)}
|
|
</article>
|
|
`;
|
|
} catch (error) {
|
|
shell.innerHTML = `
|
|
<div class="entry-empty">
|
|
<p class="eyebrow">Not found</p>
|
|
<h1 class="entry-title">${escapeHtml(error.message)}</h1>
|
|
<a class="entry-back" href="./projects.html">← Back to projects</a>
|
|
</div>
|
|
`;
|
|
}
|
|
};
|
|
|
|
window.PORTFOLIO_CONTENT = {
|
|
initListPage,
|
|
initEntryPage
|
|
};
|
|
})();
|