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 ? ` ${stars}` : "", forks !== null ? ` ${forks}` : "", ].filter(Boolean); const statsMarkup = statsItems.length ? `
${description}
${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(); });