commit d4397263eed3ee8b6b25d4093e678c4bd7ee39e5 Author: WinniePatGG Date: Sun May 3 18:35:00 2026 +0000 first commit diff --git a/README b/README new file mode 100644 index 0000000..5e87483 --- /dev/null +++ b/README @@ -0,0 +1 @@ +# My Profile Website Repository \ No newline at end of file diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..6bb87e3 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..5727c2d --- /dev/null +++ b/app/index.html @@ -0,0 +1,382 @@ + + + + + + Winnie | Developer & Gamer + + + + + + + + + + + + + + + + +
+
+
+
+

+ Hey, I'm Winnie +

+

+ < + Developer & Gamer + /> +

+

+ Building cool software with code and creativity. Passionate about + learning new things and open-source projects. +

+ +
+ + + scroll + + +
+
+ +
+
+
+ // about me +

Who I Am

+
+ +
+
+

+ Hi! I'm a developer who loves turning ideas into reality through + code. I specialize in building minecraft plugins and web + applications with a focus on user experiences. +

+

+ When I'm not coding, you'll find me exploring new repositories, + pushing to docker hub, or enjoying a good can of monster while + brainstorming the next big idea. +

+

+ I believe in writing code that not only works but is also clean, + scalable, and elegant. Every project is an opportunity to learn + and grow. +

+ +
+ + + Based in Germany + + + + Monster Energy Enthusiast + + + + Open Source Lover + +
+
+ +
+
+
+
2+
+
Years Coding
+
+
+
...
+
Projects
+
+
+
+ ... +
+
Commits
+
+
+
+
Monster Energy
+
+
+ +
+
+ + + +
+ + const winnie = { +
+   passion: + "coding", +
+   focus: + "web dev", +
+   learning: + true +
+ }; +
+
+
+
+
+
+ +
+
+
+ // my work +

Open Source Projects

+

+ A collection of my public repositories on Gitea +

+
+ +
+
+
+ +
+ + +
+
+ +
+
+
+ // skills & tools +

Tech Stack

+

+ Technologies and tools I work with daily +

+
+ +
+
+
+ + + +

Frontend

+
+
    +
  • HTML
  • +
  • CSS
  • +
  • Next.js
  • +
+
+
+
+ + + +

Backend

+
+
    +
  • Node.js
  • +
  • Python
  • +
  • Express.js
  • +
  • Flask
  • +
+
+
+
+ + + +

DevOps

+
+
    +
  • Docker
  • +
  • Portainer
  • +
  • Nginx
  • +
  • Dash.
  • +
  • Ubuntu
  • +
+
+
+
+ + + +

Tools

+
+
    +
  • Git
  • +
  • VSCode
  • +
  • Webstorm
  • +
  • Intellij
  • +
  • Termius
  • +
+
+
+
+
+ +
+
+
+ // get in touch +

Let's Connect

+

+ Have a project in mind or just want to say hello? I'm always open + to discussing new opportunities and ideas. +

+
+ + + +
+ + +
+
+
+ + + + + + + diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..42d2211 --- /dev/null +++ b/app/main.js @@ -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 + ? ` ${stars}` + : "", + forks !== null + ? ` ${forks}` + : "", + ].filter(Boolean); + const statsMarkup = statsItems.length + ? `
${statsItems.join("")}
` + : ""; + const languageMarkup = repo.language + ? `${repo.language}` + : ""; + const metaMarkup = + statsMarkup || languageMarkup + ? `
${statsMarkup}${languageMarkup}
` + : ""; + + card.innerHTML = ` +
+ + +
+

${repo.name}

+

${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(); +}); diff --git a/app/profile-image.png b/app/profile-image.png new file mode 100644 index 0000000..e4038d4 Binary files /dev/null and b/app/profile-image.png differ diff --git a/app/robots.txt b/app/robots.txt new file mode 100644 index 0000000..a277b05 --- /dev/null +++ b/app/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/app/styles.css b/app/styles.css new file mode 100644 index 0000000..8123b9b --- /dev/null +++ b/app/styles.css @@ -0,0 +1,1037 @@ +:root { + --bg-hsl: 228 14% 3%; + --bg-alt-hsl: 228 14% 7%; + --fg-hsl: 0 0% 96%; + --card-hsl: 228 12% 9%; + --border-hsl: 228 12% 20%; + --muted-hsl: 228 8% 62%; + --primary-hsl: 188 90% 55%; + --accent-hsl: 217 72% 60%; + --github-hsl: 210 16% 94%; + --radius: 16px; + --glow-primary: 0 0 60px hsl(var(--primary-hsl) / 0.3); + --glow-accent: 0 0 60px hsl(var(--accent-hsl) / 0.3); + --glow-github: 0 0 40px hsl(var(--github-hsl) / 0.25); + --gradient-primary: linear-gradient( + 135deg, + hsl(var(--primary-hsl)), + hsl(var(--accent-hsl)) + ); + --gradient-bg: linear-gradient( + 180deg, + hsl(var(--bg-hsl)) 0%, + hsl(var(--bg-alt-hsl)) 100% + ); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Inter", sans-serif; + color: hsl(var(--fg-hsl)); + background: var(--gradient-bg); + position: relative; +} + +body.menu-open { + overflow: hidden; +} + +a { + color: inherit; + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid hsl(var(--primary-hsl) / 0.55); + object-fit: cover; + object-position: center; + background: hsl(var(--card-hsl)); + box-shadow: 0 6px 18px hsl(var(--bg-hsl) / 0.5); + transition: border 0.3s ease, box-shadow 0.3s ease; +} + +.avatar--sm { + width: 32px; + height: 32px; +} + +.logo:hover .avatar { + border-color: hsl(var(--primary-hsl)); + box-shadow: 0 10px 24px hsl(var(--primary-hsl) / 0.2); +} + +.noise-bg::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.03; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); + z-index: 0; +} + +.container { + width: min(1100px, 100%); + margin: 0 auto; + padding: 0 24px; +} + +.text-gradient { + background-image: var(--gradient-primary); + color: transparent; + -webkit-background-clip: text; + background-clip: text; +} + +.text-primary { + color: hsl(var(--primary-hsl)); +} + +.mono { + font-family: "JetBrains Mono", monospace; +} + +.section { + padding: 120px 0; + position: relative; + z-index: 1; + scroll-margin-top: 120px; +} + +.section-header { + text-align: center; + margin-bottom: 64px; +} + +.section-header h2 { + font-size: clamp(2.25rem, 4vw, 3.2rem); + margin: 16px 0; +} + +.section-subtitle { + color: hsl(var(--muted-hsl)); + font-size: 1.1rem; + margin: 0; +} + +.section-footer { + margin-top: 40px; + text-align: center; +} + +.reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.8s ease, transform 0.8s ease; + transition-delay: var(--delay, 0s); +} + +.reveal.is-visible { + opacity: 1; + transform: translateY(0); +} + +.site-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 10; + padding: 20px 0; + transition: background 0.3s ease, padding 0.3s ease, border 0.3s ease; +} + +.site-header.scrolled { + background: hsl(var(--bg-hsl) / 0.8); + backdrop-filter: blur(16px); + border-bottom: 1px solid hsl(var(--border-hsl) / 0.6); + padding: 12px 0; +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: lowercase; +} + +.nav-links { + display: flex; + align-items: center; + gap: 32px; + font-size: 0.95rem; + color: hsl(var(--muted-hsl)); +} + +.nav-links a { + position: relative; + transition: color 0.3s ease; +} + +.nav-links a::after { + content: ""; + position: absolute; + left: 0; + bottom: -6px; + width: 0; + height: 2px; + background: hsl(var(--primary-hsl)); + transition: width 0.3s ease; +} + +.nav-links a:hover { + color: hsl(var(--primary-hsl)); +} + +.nav-links a:hover::after { + width: 100%; +} + +.menu-toggle { + display: none; + background: transparent; + border: none; + color: hsl(var(--fg-hsl)); + padding: 6px; + cursor: pointer; + width: 40px; + height: 40px; + position: relative; + align-items: center; + justify-content: center; +} + +.menu-toggle .icon { + position: absolute; + inset: 0; + margin: auto; +} + +.menu-toggle .icon-close { + display: none; +} + +body.menu-open .menu-toggle .icon-close { + display: inline-flex; +} + +body.menu-open .menu-toggle [data-lucide="menu"] { + display: none; +} + +.mobile-menu { + max-height: 0; + opacity: 0; + overflow: hidden; + background: hsl(var(--bg-hsl) / 0.95); + border-bottom: 1px solid hsl(var(--border-hsl)); + transition: max-height 0.3s ease, opacity 0.3s ease; +} + +.mobile-menu.open { + max-height: 320px; + opacity: 1; +} + +.mobile-nav { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + font-size: 1.1rem; + color: hsl(var(--muted-hsl)); +} + +.mobile-nav .btn { + justify-content: center; + width: 100%; +} + +.mobile-nav a:hover { + color: hsl(var(--primary-hsl)); +} + +.desktop-only { + display: inline-flex; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 22px; + border-radius: 999px; + font-weight: 600; + border: 1px solid transparent; + transition: transform 0.3s ease, background 0.3s ease, border 0.3s ease; +} + +.btn:hover { + transform: translateY(-2px); +} + +.btn-primary { + background: hsl(var(--primary-hsl)); + color: hsl(var(--bg-hsl)); + box-shadow: var(--glow-primary); +} + +.btn-outline { + border-color: hsl(var(--border-hsl)); + background: hsl(var(--card-hsl) / 0.5); +} + +.btn-github { + border-color: hsl(var(--github-hsl) / 0.35); + color: hsl(var(--github-hsl)); +} + +.btn-github:hover { + border-color: transparent; + background: hsl(var(--github-hsl)); + color: hsl(var(--bg-hsl)); + box-shadow: var(--glow-github); +} + +.btn-ghost { + border-color: hsl(var(--border-hsl)); + background: hsl(var(--card-hsl) / 0.6); +} + +.hero { + min-height: 100vh; + display: flex; + align-items: center; + position: relative; + z-index: 1; + padding-top: 120px; +} + +.hero-inner { + display: flex; + align-items: center; + justify-content: center; + position: relative; + text-align: center; + width: 100%; + min-height: 70vh; +} + +.hero-content { + max-width: 720px; +} + +.hero-title { + font-size: clamp(2.8rem, 7vw, 5.5rem); + margin: 0 0 16px; + letter-spacing: -0.02em; +} + +.hero-subtitle { + font-size: clamp(1.2rem, 2.2vw, 1.8rem); + color: hsl(var(--muted-hsl)); + margin: 0 0 16px; +} + +.hero-description { + color: hsl(var(--muted-hsl)); + font-size: 1.1rem; + line-height: 1.7; + margin: 0 auto 40px; + max-width: 640px; +} + +.hero-social { + display: inline-flex; + gap: 16px; +} + +.social-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border-radius: 16px; + border: 1px solid hsl(var(--border-hsl)); + background: hsl(var(--card-hsl)); + color: hsl(var(--muted-hsl)); + transition: border 0.3s ease, background 0.3s ease, color 0.3s ease, + box-shadow 0.3s ease; +} + +.social-link:hover { + border-color: hsl(var(--primary-hsl) / 0.6); + color: hsl(var(--primary-hsl)); + background: hsl(var(--bg-alt-hsl)); + box-shadow: var(--glow-primary); +} + +.social-link--github { + border-color: hsl(var(--github-hsl) / 0.25); + color: hsl(var(--github-hsl)); + background: hsl(var(--card-hsl) / 0.7); +} + +.social-link--github:hover { + border-color: transparent; + background: hsl(var(--github-hsl)); + color: hsl(var(--bg-hsl)); + box-shadow: var(--glow-github); +} + +.scroll-indicator { + position: absolute; + bottom: 48px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: hsl(var(--muted-hsl)); + animation: bounce 2s ease-in-out infinite; +} + +.about-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 48px; + align-items: center; +} + +.about-text p { + color: hsl(var(--muted-hsl)); + line-height: 1.8; + font-size: 1.05rem; + margin-bottom: 20px; +} + +.facts { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 32px; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 999px; + border: 1px solid hsl(var(--border-hsl)); + background: hsl(var(--card-hsl) / 0.5); + font-size: 0.9rem; +} + +.stats-card { + position: relative; + padding: 32px; + border-radius: 24px; + border: 1px solid hsl(var(--border-hsl)); + background: hsl(var(--card-hsl) / 0.5); + backdrop-filter: blur(10px); + box-shadow: 0 20px 40px hsl(var(--bg-hsl) / 0.4); +} + +.stats-card::after { + content: ""; + position: absolute; + top: -24px; + right: -24px; + width: 140px; + height: 140px; + background: hsl(var(--primary-hsl) / 0.2); + filter: blur(50px); + border-radius: 50%; + z-index: -1; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; +} + +.stat { + text-align: center; +} + +.stat-value { + font-size: 2.4rem; + font-weight: 700; + margin-bottom: 8px; +} + +.stat-label { + color: hsl(var(--muted-hsl)); + font-size: 0.9rem; +} + +.terminal { + margin-top: 28px; + padding: 18px; + border-radius: 16px; + background: hsl(var(--bg-hsl) / 0.8); + border: 1px solid hsl(var(--border-hsl) / 0.6); + font-family: "JetBrains Mono", monospace; + font-size: 0.85rem; + color: hsl(var(--muted-hsl)); +} + +.terminal-bar { + display: flex; + gap: 6px; + margin-bottom: 12px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.dot-red { + background: #ef4444; +} + +.dot-yellow { + background: #facc15; +} + +.dot-green { + background: #22c55e; +} + +.terminal-string { + color: #4ade80; +} + +.terminal-boolean { + color: #facc15; +} + +.repos-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + color: hsl(var(--muted-hsl)); +} + +.hidden { + display: none; +} + +.repo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 24px; +} + +.repo-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px; + border-radius: 20px; + border: 1px solid hsl(var(--border-hsl)); + background: hsl(var(--card-hsl) / 0.5); + transition: transform 0.3s ease, border 0.3s ease, background 0.3s ease; +} + +.repo-header { + display: flex; + align-items: center; + justify-content: space-between; + color: hsl(var(--muted-hsl)); +} + +.icon-badge { + width: 36px; + height: 36px; + padding: 8px; + border-radius: 12px; + background: hsl(var(--primary-hsl) / 0.12); + color: hsl(var(--primary-hsl)); +} + +.icon-fade { + opacity: 0; + transition: opacity 0.3s ease; +} + +.repo-card:hover .icon-fade { + opacity: 1; +} + +.repo-card:hover { + transform: translateY(-6px) scale(1.01); + border-color: hsl(var(--primary-hsl) / 0.4); +} + +.repo-card h3 { + margin: 0; + font-size: 1.1rem; +} + +.repo-card p { + margin: 0; + color: hsl(var(--muted-hsl)); + font-size: 0.95rem; + line-height: 1.5; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 46px; +} + +.repo-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: auto; + font-size: 0.9rem; + color: hsl(var(--muted-hsl)); +} + +.repo-stats { + display: flex; + align-items: center; + gap: 14px; +} + +.repo-stats span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.repo-language { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.language-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.card-glow { + position: relative; + overflow: hidden; +} + +.card-glow::before { + content: ""; + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.4s ease; + background: radial-gradient( + 600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), + hsl(var(--primary-hsl) / 0.15), + transparent 40% + ); + pointer-events: none; +} + +.card-glow:hover::before { + opacity: 1; +} + +.skills-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; +} + +.skill-card { + padding: 24px; + border-radius: 20px; + border: 1px solid hsl(var(--border-hsl)); + background: hsl(var(--card-hsl) / 0.4); + transition: transform 0.3s ease, border 0.3s ease; +} + +.skill-card:hover { + transform: translateY(-6px); + border-color: hsl(var(--primary-hsl) / 0.3); +} + +.skill-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.skill-icon { + width: 44px; + height: 44px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + background: hsl(var(--primary-hsl) / 0.15); + color: hsl(var(--primary-hsl)); +} + +.skill-card ul { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; + color: hsl(var(--muted-hsl)); +} + +.skill-card li { + display: flex; + align-items: center; + gap: 10px; +} + +.skill-card li::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: hsl(var(--primary-hsl) / 0.6); +} + +.cta-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; +} + +.divider { + width: 100%; + height: 1px; + margin: 48px 0; + background: linear-gradient( + 90deg, + transparent, + hsl(var(--border-hsl)), + transparent + ); +} + +.email-pill { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 14px 22px; + border-radius: 18px; + border: 1px solid hsl(var(--border-hsl)); + background: hsl(var(--card-hsl) / 0.5); + color: hsl(var(--muted-hsl)); +} + +.site-footer { + border-top: 1px solid hsl(var(--border-hsl)); + padding: 48px 0 24px; + position: relative; + z-index: 1; +} + +.footer-content { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 20px; +} + +.footer-brand { + display: flex; + align-items: center; + gap: 12px; + color: hsl(var(--muted-hsl)); +} + +.footer-links { + display: flex; + gap: 20px; + color: hsl(var(--muted-hsl)); + font-size: 0.9rem; +} + +.footer-links a:hover { + color: hsl(var(--primary-hsl)); +} + +.footer-social { + display: flex; + gap: 12px; +} + +.footer-social-link { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: hsl(var(--card-hsl)); + color: hsl(var(--muted-hsl)); + transition: background 0.3s ease, color 0.3s ease; +} + +.footer-social-link:hover { + background: hsl(var(--bg-alt-hsl)); + color: hsl(var(--primary-hsl)); +} + +.footer-social-link--github { + border: 1px solid hsl(var(--github-hsl) / 0.25); + color: hsl(var(--github-hsl)); +} + +.footer-social-link--github:hover { + border-color: transparent; + background: hsl(var(--github-hsl)); + color: hsl(var(--bg-hsl)); + box-shadow: var(--glow-github); +} + +.footer-bottom { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid hsl(var(--border-hsl) / 0.6); + text-align: center; + color: hsl(var(--muted-hsl)); + font-size: 0.9rem; +} + +.footer-bottom .heart { + color: #ef4444; + margin: 0 4px; +} + +.animated-background { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.bg-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient( + to right, + hsl(var(--border-hsl) / 0.25) 1px, + transparent 1px + ), + linear-gradient( + to bottom, + hsl(var(--border-hsl) / 0.25) 1px, + transparent 1px + ); + background-size: 60px 60px; + opacity: 0.4; +} + +.orb { + position: absolute; + border-radius: 50%; + filter: blur(120px); + opacity: 0.4; + animation: orb-pulse 10s ease-in-out infinite; +} + +.orb-primary { + width: 360px; + height: 360px; + top: -120px; + right: -120px; + background: hsl(var(--primary-hsl) / 0.3); +} + +.orb-accent { + width: 300px; + height: 300px; + top: 30%; + left: -140px; + background: hsl(var(--accent-hsl) / 0.3); + animation-delay: 2s; +} + +.orb-primary-small { + width: 240px; + height: 240px; + bottom: 80px; + right: 25%; + background: hsl(var(--primary-hsl) / 0.2); + animation-delay: 4s; +} + +.particles span { + position: absolute; + width: 4px; + height: 4px; + border-radius: 50%; + background: hsl(var(--primary-hsl) / 0.5); + animation: particle-float 4s ease-in-out infinite; +} + +.spinner { + width: 40px; + height: 40px; + border-radius: 50%; + border: 3px solid hsl(var(--border-hsl)); + border-top-color: hsl(var(--primary-hsl)); + animation: spin 1s linear infinite; +} + +.icon { + width: 20px; + height: 20px; +} + +.icon-sm { + width: 16px; + height: 16px; +} + +svg[data-lucide="github"] { + stroke-width: 2.2; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +@keyframes orb-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.35; + } + 50% { + transform: scale(1.15); + opacity: 0.55; + } +} + +@keyframes particle-float { + 0%, + 100% { + transform: translateY(0); + opacity: 0.3; + } + 50% { + transform: translateY(-24px); + opacity: 0.8; + } +} + +@keyframes bounce { + 0%, + 100% { + transform: translate(-50%, 0); + } + 50% { + transform: translate(-50%, 8px); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 900px) { + .nav-links { + display: none; + } + + .menu-toggle { + display: inline-flex; + } + + .desktop-only { + display: none; + } + + .about-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 700px) { + .section { + padding: 90px 0; + } + + .hero { + padding-top: 110px; + } + + .footer-content { + flex-direction: column; + align-items: flex-start; + } + + .cta-actions { + flex-direction: column; + } +} + +@media (max-width: 600px) { + .logo span { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..79f5394 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: nginx:alpine + ports: + - "4001:80" + volumes: + - ./app:/usr/share/nginx/html:ro + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro \ No newline at end of file diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..8fd7ce3 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,54 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /gitea-feed { + proxy_pass https://git.winniepat.de/winnie.rss; + proxy_set_header Host git.winniepat.de; + proxy_ssl_server_name on; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + + if ($request_method = OPTIONS) { + return 204; + } + } + + location /gitea-repo-feed/ { + rewrite ^/gitea-repo-feed/(.+)$ /$1.rss break; + proxy_pass https://git.winniepat.de; + proxy_set_header Host git.winniepat.de; + proxy_ssl_server_name on; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + + if ($request_method = OPTIONS) { + return 204; + } + } + + location /gitea-api/ { + proxy_pass https://git.winniepat.de/api/v1/; + proxy_set_header Host git.winniepat.de; + proxy_ssl_server_name on; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + + if ($request_method = OPTIONS) { + return 204; + } + } +}