first commit
This commit is contained in:
+358
@@ -0,0 +1,358 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user