359 lines
11 KiB
JavaScript
359 lines
11 KiB
JavaScript
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();
|
|
});
|