portfolio overhaul
This commit is contained in:
20 files changed
+1874
-683
No files matched your search
@@ -0,0 +1,194 @@
|
||||
(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 renderLinks = (entry, className) => {
|
||||
const links = [];
|
||||
|
||||
links.push(`<a class="${className} ${className}-primary" href="./entry.html?type=${encodeURIComponent(entry.type)}&slug=${encodeURIComponent(entry.slug)}">Read More</a>`);
|
||||
|
||||
if (entry.github) {
|
||||
links.push(`<a class="${className}" href="${escapeHtml(entry.github)}" target="_blank" rel="noopener noreferrer">Github</a>`);
|
||||
}
|
||||
|
||||
if (entry.demo) {
|
||||
links.push(`<a class="${className}" href="${escapeHtml(entry.demo)}" target="_blank" rel="noopener noreferrer">Live</a>`);
|
||||
}
|
||||
|
||||
return `<div class="${className.replace("card-link", "card-links")}">${links.join("")}</div>`;
|
||||
};
|
||||
|
||||
const createCard = (entry) => {
|
||||
const formattedDate = formatDate(entry.date);
|
||||
const featuredClass = entry.featured ? " featured" : "";
|
||||
|
||||
return `
|
||||
<article class="content-card${featuredClass}">
|
||||
<div class="card-meta">
|
||||
<span class="card-kicker">${escapeHtml(entry.label || entry.type)}</span>
|
||||
${formattedDate ? `<span class="card-date">${escapeHtml(formattedDate)}</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 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 initListPage = async () => {
|
||||
try {
|
||||
const index = await getIndex();
|
||||
renderSection("projects-grid", index.projects || [], "No projects added yet.");
|
||||
renderSection("updates-grid", index.updates || [], "No updates added yet.");
|
||||
} 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.");
|
||||
}
|
||||
|
||||
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>` : ""}
|
||||
${entry.status ? `<span class="entry-pill entry-pill-secondary">${escapeHtml(entry.status)}</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.github ? `<a class="inline-button" href="${escapeHtml(entry.github)}" target="_blank" rel="noopener noreferrer">Github</a>` : ""}
|
||||
${entry.demo ? `<a class="inline-button inline-button-primary" href="${escapeHtml(entry.demo)}" target="_blank" rel="noopener noreferrer">Live</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
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,82 @@
|
||||
(function() {
|
||||
const hero = document.querySelector(".hero-main");
|
||||
const heroShell = document.querySelector(".hero-main .hero-shell");
|
||||
const profile = document.querySelector(".hero-main .profile");
|
||||
const title = document.querySelector(".hero-main .svg-wrapper");
|
||||
const descriptions = document.querySelectorAll(
|
||||
".hero-main .hero-description, .hero-main .hero-badges, .hero-main .hero-actions, .hero-main .social-icons"
|
||||
);
|
||||
|
||||
if (!hero || !heroShell || !profile || !title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
|
||||
if (prefersReducedMotion || coarsePointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const setTransform = (element, x, y) => {
|
||||
element.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
currentX += (targetX - currentX) * 0.065;
|
||||
currentY += (targetY - currentY) * 0.065;
|
||||
|
||||
const bgX = 50 + currentX * 6.8;
|
||||
const bgY = 18 + currentY * 5.6;
|
||||
const bgXAlt = 50 - currentX * 5.2;
|
||||
const bgYAlt = 30 - currentY * 4.6;
|
||||
const glowRotate = currentX * 16 + currentY * 10;
|
||||
const glowShiftX = currentX * 54;
|
||||
const glowShiftY = currentY * 42;
|
||||
|
||||
document.body.style.setProperty("--hero-glow-x", `${bgX}%`);
|
||||
document.body.style.setProperty("--hero-glow-y", `${bgY}%`);
|
||||
document.body.style.setProperty("--hero-glow-alt-x", `${bgXAlt}%`);
|
||||
document.body.style.setProperty("--hero-glow-alt-y", `${bgYAlt}%`);
|
||||
document.body.style.setProperty("--hero-glow-rotate", `${glowRotate}deg`);
|
||||
document.body.style.setProperty("--hero-glow-shift-x", `${glowShiftX}px`);
|
||||
document.body.style.setProperty("--hero-glow-shift-y", `${glowShiftY}px`);
|
||||
|
||||
heroShell.style.setProperty("--hero-shell-spot-x", `${50 + currentX * 14}%`);
|
||||
heroShell.style.setProperty("--hero-shell-spot-y", `${28 + currentY * 10}%`);
|
||||
heroShell.style.setProperty("--hero-shell-tilt-x", `${currentY * -2.2}deg`);
|
||||
heroShell.style.setProperty("--hero-shell-tilt-y", `${currentX * 2.6}deg`);
|
||||
|
||||
setTransform(profile, currentX * -4.5, currentY * -4.5);
|
||||
setTransform(title, currentX * 2.2, currentY * 2.2);
|
||||
|
||||
descriptions.forEach((element, index) => {
|
||||
const strength = 1.3 + index * 0.35;
|
||||
setTransform(element, currentX * strength, currentY * strength);
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", (event) => {
|
||||
const x = clamp((event.clientX / window.innerWidth - 0.5) * 2, -1, 1);
|
||||
const y = clamp((event.clientY / window.innerHeight - 0.5) * 2, -1, 1);
|
||||
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
});
|
||||
|
||||
window.addEventListener("mouseleave", () => {
|
||||
targetX = 0;
|
||||
targetY = 0;
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
})();
|
||||
@@ -1,12 +1,32 @@
|
||||
const doc = document;
|
||||
const body = doc.body;
|
||||
const menuOpen = doc.querySelector(".menu");
|
||||
const menuClose = doc.querySelector(".close");
|
||||
const overlay = doc.querySelector(".overlay");
|
||||
|
||||
menuOpen.addEventListener("click", () => {
|
||||
overlay.classList.add("overlay--active");
|
||||
});
|
||||
if (menuOpen && menuClose && overlay) {
|
||||
const openMenu = () => {
|
||||
overlay.classList.add("overlay--active");
|
||||
body.classList.add("menu-open");
|
||||
};
|
||||
|
||||
menuClose.addEventListener("click", () => {
|
||||
overlay.classList.remove("overlay--active");
|
||||
});
|
||||
const closeMenu = () => {
|
||||
overlay.classList.remove("overlay--active");
|
||||
body.classList.remove("menu-open");
|
||||
};
|
||||
|
||||
menuOpen.addEventListener("click", openMenu);
|
||||
menuClose.addEventListener("click", closeMenu);
|
||||
|
||||
overlay.addEventListener("click", (event) => {
|
||||
if (event.target === overlay) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
(function() {
|
||||
const body = document.body;
|
||||
const themeToggle = document.getElementById("theme-toggle");
|
||||
|
||||
const applyStoredTheme = () => {
|
||||
const storedTheme = localStorage.getItem("pref-theme") || sessionStorage.getItem("theme") || "dark";
|
||||
if (storedTheme === "light") {
|
||||
body.classList.add("light");
|
||||
sessionStorage.setItem("theme", "light");
|
||||
localStorage.setItem("pref-theme", "light");
|
||||
return;
|
||||
}
|
||||
|
||||
body.classList.remove("light");
|
||||
sessionStorage.setItem("theme", "dark");
|
||||
localStorage.setItem("pref-theme", "dark");
|
||||
};
|
||||
|
||||
applyStoredTheme();
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener("click", () => {
|
||||
const isLight = body.classList.toggle("light");
|
||||
const nextTheme = isLight ? "light" : "dark";
|
||||
sessionStorage.setItem("theme", nextTheme);
|
||||
localStorage.setItem("pref-theme", nextTheme);
|
||||
});
|
||||
}
|
||||
|
||||
body.classList.remove("js-stop-transition");
|
||||
})();
|
||||
Reference in new issue
Block a user