Files
Website/app/main.js
T

359 lines
11 KiB
JavaScript
Raw Normal View History

2026-05-03 18:35:00 +00:00
const LANGUAGE_COLORS = {
TypeScript: "#3b82f6",
JavaScript: "#facc15",
Python: "#22c55e",
Rust: "#f97316",
Go: "#22d3ee",
Java: "#ef4444",
HTML: "#f97316",
CSS: "#a855f7",
Vue: "#10b981",
React: "#22d3ee",
};
const GITEA_BASE_URL = "https://git.winniepat.de";
const GITEA_USER = "winnie";
const GITEA_FEED_URL = "/gitea-feed";
const GITEA_REPO_FEED_BASE = "/gitea-repo-feed";
const FALLBACK_STATS = { totalRepos: 10, totalCommits: 500 };
const initNavbar = () => {
const header = document.getElementById("site-header");
if (!header) return;
const updateHeader = () => {
header.classList.toggle("scrolled", window.scrollY > 50);
};
updateHeader();
window.addEventListener("scroll", updateHeader);
};
const initMobileMenu = () => {
const toggle = document.getElementById("menu-toggle");
const menu = document.getElementById("mobile-menu");
if (!toggle || !menu) return;
const closeMenu = () => {
document.body.classList.remove("menu-open");
menu.classList.remove("open");
toggle.setAttribute("aria-expanded", "false");
};
toggle.addEventListener("click", () => {
const isOpen = document.body.classList.toggle("menu-open");
menu.classList.toggle("open", isOpen);
toggle.setAttribute("aria-expanded", String(isOpen));
});
menu.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", closeMenu);
});
};
const initParticles = () => {
const particleWrap = document.getElementById("particles");
if (!particleWrap) return;
for (let i = 0; i < 20; i += 1) {
const dot = document.createElement("span");
dot.style.left = `${Math.random() * 100}%`;
dot.style.top = `${Math.random() * 100}%`;
dot.style.animationDuration = `${3 + Math.random() * 4}s`;
dot.style.animationDelay = `${Math.random() * 2}s`;
particleWrap.appendChild(dot);
}
};
const initReveal = () => {
const elements = document.querySelectorAll("[data-reveal]");
if (!elements.length) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
elements.forEach((el) => observer.observe(el));
};
const initCardGlow = () => {
document.querySelectorAll(".card-glow").forEach((card) => {
card.addEventListener("mousemove", (event) => {
const rect = card.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
card.style.setProperty("--mouse-x", `${x}px`);
card.style.setProperty("--mouse-y", `${y}px`);
});
});
};
const updateYear = () => {
const year = document.getElementById("current-year");
if (year) {
year.textContent = new Date().getFullYear();
}
};
const toCount = (value) => {
if (value === null || value === undefined) return null;
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
};
const htmlToText = (value) => {
if (!value) return "";
const textarea = document.createElement("textarea");
textarea.innerHTML = value;
const decoded = textarea.value;
const doc = new DOMParser().parseFromString(decoded, "text/html");
return doc.body.textContent || "";
};
const stripSha = (value) =>
value.replace(/\b[0-9a-f]{7,40}\b/gi, "").replace(/\s+/g, " ").trim();
const isActivityText = (value) =>
/created branch|created repository|pushed to|opened pull request|merged pull request|opened issue|closed issue/i.test(
value
);
const getItemText = (item, tagName) => {
const node = item.getElementsByTagName(tagName)[0];
return node ? node.textContent : "";
};
const parseItemDate = (value) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
const getRepoInfoFromLink = (link) => {
if (!link) return null;
try {
const url = new URL(link);
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length < 2) return null;
const [owner, repo] = segments;
return {
owner,
repo,
full_name: `${owner}/${repo}`,
html_url: `${GITEA_BASE_URL}/${owner}/${repo}`,
};
} catch (error) {
return null;
}
};
const buildRepoDescription = (item) => {
const rawDescription =
getItemText(item, "content:encoded") || getItemText(item, "description");
const descriptionText = stripSha(htmlToText(rawDescription));
if (descriptionText && !isActivityText(descriptionText)) return descriptionText;
const titleText = stripSha(htmlToText(getItemText(item, "title")));
if (titleText && !isActivityText(titleText)) return titleText;
return "";
};
const getChannelText = (xml, tagName) => {
const channel = xml.getElementsByTagName("channel")[0];
if (!channel) return "";
const node = channel.getElementsByTagName(tagName)[0];
return node ? node.textContent : "";
};
const fetchRepoFeedDescription = async (repoInfo) => {
try {
const repoPath = `${encodeURIComponent(
repoInfo.owner
)}/${encodeURIComponent(repoInfo.repo)}`;
const response = await fetch(`${GITEA_REPO_FEED_BASE}/${repoPath}`);
if (!response.ok) return "";
const xmlText = await response.text();
const xml = new DOMParser().parseFromString(xmlText, "text/xml");
const description = stripSha(htmlToText(getChannelText(xml, "description")));
return description.trim();
} catch (error) {
return "";
}
};
const updateStats = (stats) => {
const reposEl = document.getElementById("stat-repos");
const commitsEl = document.getElementById("stat-commits");
if (reposEl) reposEl.textContent = stats.totalRepos.toString();
if (commitsEl)
commitsEl.textContent = stats.totalCommits.toLocaleString();
};
const createRepoCard = (repo) => {
const card = document.createElement("a");
card.href = repo.html_url;
card.target = "_blank";
card.rel = "noopener noreferrer";
card.className = "repo-card card-glow";
const languageColor = LANGUAGE_COLORS[repo.language] || "#6b7280";
const description = repo.description || "No description available";
const stars = toCount(repo.stars_count ?? repo.stargazers_count);
const forks = toCount(repo.forks_count);
const statsItems = [
stars !== null
? `<span><i class="icon icon-sm" data-lucide="star"></i> ${stars}</span>`
: "",
forks !== null
? `<span><i class="icon icon-sm" data-lucide="git-fork"></i> ${forks}</span>`
: "",
].filter(Boolean);
const statsMarkup = statsItems.length
? `<div class="repo-stats">${statsItems.join("")}</div>`
: "";
const languageMarkup = repo.language
? `<span class="repo-language"><span class="language-dot" style="background:${languageColor}"></span>${repo.language}</span>`
: "";
const metaMarkup =
statsMarkup || languageMarkup
? `<div class="repo-meta">${statsMarkup}${languageMarkup}</div>`
: "";
card.innerHTML = `
<div class="repo-header">
<i class="icon icon-badge" data-lucide="code-2"></i>
<i class="icon icon-fade" data-lucide="external-link"></i>
</div>
<h3>${repo.name}</h3>
<p>${description}</p>
${metaMarkup}
`;
return card;
};
const renderRepos = (repos) => {
const grid = document.getElementById("repo-grid");
const loading = document.getElementById("repos-loading");
const error = document.getElementById("repos-error");
if (!grid || !loading || !error) return;
grid.innerHTML = "";
loading.classList.add("hidden");
error.classList.add("hidden");
repos.forEach((repo) => {
grid.appendChild(createRepoCard(repo));
});
initCardGlow();
if (window.lucide) {
window.lucide.createIcons();
}
};
const renderRepoError = () => {
const loading = document.getElementById("repos-loading");
const error = document.getElementById("repos-error");
if (!loading || !error) return;
loading.classList.add("hidden");
error.classList.remove("hidden");
};
const sortRepos = (repos) =>
repos
.slice()
.sort(
(a, b) => new Date(b.updated_at || 0) - new Date(a.updated_at || 0)
)
.slice(0, 12);
const fetchGiteaFeed = async () => {
const response = await fetch(GITEA_FEED_URL);
if (!response.ok) throw new Error("Failed to fetch feed");
const xmlText = await response.text();
const xml = new DOMParser().parseFromString(xmlText, "text/xml");
const items = Array.from(xml.getElementsByTagName("item"));
if (!items.length) throw new Error("No feed items");
const repoMap = new Map();
let commitCount = 0;
items.forEach((item) => {
const link = getItemText(item, "link");
const titleText = htmlToText(getItemText(item, "title"));
const repoInfo = getRepoInfoFromLink(link);
if (!repoInfo) return;
const pubDate = parseItemDate(getItemText(item, "pubDate")) || new Date(0);
const description = buildRepoDescription(item);
const existing = repoMap.get(repoInfo.full_name);
if (!existing || pubDate > new Date(existing.updated_at || 0)) {
repoMap.set(repoInfo.full_name, {
...repoInfo,
name: repoInfo.repo,
description,
updated_at: pubDate.toISOString(),
});
}
if (/pushed to/i.test(titleText) || /\/commit\//i.test(link)) {
commitCount += 1;
}
});
const repos = Array.from(repoMap.values());
const enrichedRepos = await Promise.all(
repos.map(async (repo) => {
const description = await fetchRepoFeedDescription(repo);
return description ? { ...repo, description } : repo;
})
);
const hasItems = items.length > 0;
const totalRepos =
enrichedRepos.length || (hasItems ? 0 : FALLBACK_STATS.totalRepos);
const totalCommits = hasItems ? commitCount : FALLBACK_STATS.totalCommits;
return {
repos: enrichedRepos,
stats: { totalRepos, totalCommits },
};
};
const loadGiteaFeed = async () => {
try {
const { repos, stats } = await fetchGiteaFeed();
renderRepos(sortRepos(repos));
updateStats(stats);
} catch (error) {
renderRepoError();
updateStats(FALLBACK_STATS);
}
};
const initIcons = () => {
if (window.lucide) {
window.lucide.createIcons();
}
};
document.addEventListener("DOMContentLoaded", () => {
initIcons();
initNavbar();
initMobileMenu();
initParticles();
initReveal();
initCardGlow();
updateYear();
loadGiteaFeed();
});