ci: migrate GitHub actions to Gitea actions
Deploy CV Site (andreknie.de) / deploy (push) Canceled after 0s

This commit is contained in:
2026-07-22 15:42:06 +02:00
parent e1cf49b0a2
commit b46de6dc7d
649 changed files with 127224 additions and 2786 deletions
@@ -0,0 +1,53 @@
import React from 'react'
import { useParams, Link } from 'react-router-dom'
import { useContent } from '../hooks/useContent'
import SEOHead from '../components/SEOHead'
import { decodeHtml } from '../utils/decodeHtml'
export default function ArticleSingle() {
const { slug } = useParams()
const { items: posts } = useContent('posts', { pageSize: 1000 })
const post = posts.find((p) => p.slug === slug)
if (!post) {
return (
<div className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
<h2>Artikel nicht gefunden</h2>
<Link to="/artikel" className="primary-button" style={{ marginTop: '20px' }}>Zurück zur Übersicht</Link>
</div>
)
}
const formattedDate = post.date
? new Date(post.date).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' })
: ''
return (
<div className="page-container article-single-page">
<SEOHead
title={`${post.title} | Dr. André Knie`}
description={post.excerpt || post.title}
/>
<div className="app-container" style={{ maxWidth: '800px', margin: '0 auto', paddingTop: '120px' }}>
<Link to="/artikel" style={{ color: 'var(--primary-color)', textDecoration: 'none', marginBottom: '20px', display: 'inline-block' }}>
Zurück zur Übersicht
</Link>
<article className="glass-panel" style={{ padding: '40px' }}>
<header style={{ marginBottom: '30px' }}>
{formattedDate && <p style={{ color: 'var(--text-secondary)', marginBottom: '10px' }}>{formattedDate}</p>}
<h1 style={{ fontSize: '2.5rem', marginBottom: '20px' }}>{decodeHtml(post.title)}</h1>
{post.url && (
<a href={post.url} target={post.url.startsWith('/') ? "_self" : "_blank"} rel="noopener noreferrer" className="secondary-button" style={{ display: 'inline-block', marginBottom: '20px' }}>
{post.url.includes('/podcast') ? 'Zum Podcast' : (post.linkText || 'Auf LinkedIn ansehen')}
</a>
)}
</header>
<div
className="article-body content-body"
dangerouslySetInnerHTML={{ __html: post.body }}
/>
</article>
</div>
</div>
)
}
@@ -0,0 +1,133 @@
.articles-page {
padding-top: 120px;
min-height: 100vh;
}
.articles-page .page-header {
text-align: center;
margin-bottom: 4rem;
}
.articles-page .hero-title {
font-size: clamp(2.5rem, 5vw, 4rem);
margin-bottom: 1rem;
}
.articles-page .hero-title span {
color: var(--color-primary, #646cff);
background: linear-gradient(135deg, var(--color-primary, #646cff), var(--color-accent, #9089fc));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.articles-page .hero-subtitle {
font-size: 1.2rem;
color: var(--color-text-muted, #aaa);
max-width: 600px;
margin: 0 auto;
}
.articles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2.5rem;
padding-bottom: 6rem;
}
.article-card {
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 20px;
overflow: hidden;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease, border-color 0.3s ease;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.article-card:hover {
transform: translateY(-8px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.15);
}
.article-image {
width: 100%;
height: 220px;
overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.article-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.article-card:hover .article-image img {
transform: scale(1.08);
}
.article-content {
padding: 1.8rem;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.article-date {
font-size: 0.85rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
}
.article-title {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text, #fff);
margin: 0 0 1rem;
line-height: 1.4;
transition: color 0.3s ease;
}
.article-card:hover .article-title {
color: var(--color-primary, #646cff);
}
.article-excerpt {
font-size: 1rem;
color: var(--color-text-muted, #aaa);
line-height: 1.6;
margin: 0 0 1.5rem;
flex-grow: 1;
}
.article-link {
display: inline-flex;
align-items: center;
font-weight: 600;
color: var(--color-primary, #646cff);
text-decoration: none;
font-size: 0.95rem;
transition: color 0.2s ease, transform 0.2s ease;
margin-top: auto;
}
.article-link:hover {
color: var(--color-primary-light, #9089fc);
transform: translateX(4px);
}
.no-articles {
text-align: center;
grid-column: 1 / -1;
font-size: 1.2rem;
color: var(--color-text-muted, #aaa);
padding: 3rem 0;
}
@@ -0,0 +1,107 @@
import React, { useState } from 'react'
import { Search } from 'lucide-react'
import { useContent } from '../hooks/useContent'
import SEOHead from '../components/SEOHead'
import { decodeHtml } from '../utils/decodeHtml'
import './Articles.css'
export default function Articles() {
const [searchQuery, setSearchQuery] = useState('')
const { items: posts } = useContent('posts', { pageSize: 100 })
const filteredPosts = posts?.filter(post => {
if (!searchQuery) return true
const query = searchQuery.toLowerCase()
return (
(post.title && post.title.toLowerCase().includes(query)) ||
(post.excerpt && post.excerpt.toLowerCase().includes(query)) ||
(post.body && post.body.toLowerCase().includes(query))
)
})
return (
<div className="page-container articles-page">
<SEOHead
title="Artikel & Beiträge | Dr. André Knie"
description="Aktuelle Artikel und LinkedIn-Beiträge zu den Themen Softwareentwicklung, Leadership und Technologie."
/>
<header className="page-header">
<div className="app-container">
<h1 className="hero-title">
Meine <span>Artikel</span>
</h1>
<p className="hero-subtitle">
Gedanken, Analysen und Erfahrungen rund um Software Engineering, Führung und Technologie.
</p>
</div>
</header>
<div className="app-container" style={{ marginBottom: '40px' }}>
<div style={{ maxWidth: '800px', margin: '0 auto', position: 'relative' }}>
<div style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)' }}>
<Search size={20} />
</div>
<input
type="text"
placeholder="Artikel durchsuchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '16px 80px 16px 48px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'var(--glass-bg)',
color: 'var(--text-primary)',
fontSize: '1rem',
outline: 'none'
}}
/>
<div style={{ position: 'absolute', right: '16px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{filteredPosts?.length || 0} / {posts?.length || 0}
</div>
</div>
</div>
<section className="articles-grid app-container">
{filteredPosts?.map((post) => (
<article key={post.id || post.slug} className="article-card glass-card">
{post.image && (
<div className="article-image">
<img src={post.image} alt={post.title} />
</div>
)}
<div className="article-content">
{post.date && (
<span className="article-date">
{new Date(post.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
)}
<h2 className="article-title">{decodeHtml(post.title)}</h2>
<p className="article-excerpt">
{decodeHtml(post.excerpt || (post.body ? post.body.replace(/<[^>]+>/g, '').substring(0, 150) + '...' : ''))}
</p>
<div style={{ display: 'flex', gap: '16px' }}>
<a href={`/artikel/${post.slug}`} className="article-link">
Weiterlesen
</a>
{post.url && post.url.includes('/podcast') && (
<a href={post.url} className="article-link" style={{ color: 'var(--accent-color)' }}>
Zum Podcast
</a>
)}
</div>
</div>
</article>
))}
{(!filteredPosts || filteredPosts.length === 0) && (
<p className="no-articles">Keine Artikel für diese Suche gefunden.</p>
)}
</section>
</div>
)
}
@@ -0,0 +1,49 @@
import React from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import SEOHead from '../components/SEOHead'
export default function Confirmation() {
const [searchParams] = useSearchParams()
const status = searchParams.get('status')
const isSuccess = status === 'success'
return (
<>
<SEOHead
title={isSuccess ? "Vielen Dank" : "Ein Fehler ist aufgetreten"}
description={isSuccess ? "Ihre Nachricht wurde erfolgreich gesendet." : "Es gab ein Problem beim Senden Ihrer Nachricht."}
/>
<main style={{ paddingTop: '120px', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="app-container" style={{ textAlign: 'center', maxWidth: '600px' }}>
{isSuccess ? (
<>
<div style={{ width: '80px', height: '80px', borderRadius: '50%', background: 'rgba(34, 197, 94, 0.1)', color: '#22c55e', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
<svg width="40" height="40" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 style={{ fontSize: '3rem', marginBottom: '16px' }}><span className="text-gradient">Vielen Dank!</span></h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '1.2rem', marginBottom: '32px' }}>
Ihre Nachricht wurde erfolgreich gesendet. Ich werde mich so schnell wie möglich bei Ihnen melden.
</p>
</>
) : (
<>
<div style={{ width: '80px', height: '80px', borderRadius: '50%', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
<svg width="40" height="40" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 style={{ fontSize: '3rem', marginBottom: '16px' }}><span className="text-gradient">Ein Fehler ist aufgetreten</span></h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '1.2rem', marginBottom: '32px' }}>
Leider gab es ein Problem beim Senden Ihrer Nachricht. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie mich direkt per E-Mail.
</p>
</>
)}
<Link to="/" className="primary-button">Zurück zur Startseite</Link>
</div>
</main>
</>
)
}
@@ -38,8 +38,8 @@ export default function Datenschutz() {
<p><strong style={{ color: 'var(--text-primary)' }}>9. Ihre Rechte</strong></p>
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a></p>
<p><strong style={{ color: 'var(--text-primary)' }}>10. Cookies</strong></p>
<p>Diese Webseite verwendet keine Tracking-Cookies und keine Analyse-Tools von Drittanbietern. Es werden ausschließlich technisch notwendige Funktionen genutzt.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>10. Cookies und Webanalyse</strong></p>
<p>Diese Webseite verwendet <strong>keine</strong> Cookies zur Analyse Ihres Nutzerverhaltens. Um die Nutzung unserer Website statistisch auszuwerten und das Angebot zu verbessern, setzen wir die Open-Source-Software <strong>Umami</strong> ein. Umami ist selbstgehostet (die Daten verlassen unsere Server nicht) und arbeitet zu 100% ohne Cookies und ohne die Speicherung von personenbezogenen Daten. Ihre IP-Adresse wird sofort anonymisiert. Ein Rückschluss auf Ihre Person ist nicht möglich.</p>
</div>
</div>
</main>
+54 -29
View File
@@ -1,13 +1,23 @@
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import { useContent, useContentTags } from '../hooks/useContent'
import { Search } from 'lucide-react'
import { useContent } from '../hooks/useContent'
import { formatDate } from '../utils/formatDate'
import { decodeHtml } from '../utils/decodeHtml'
export default function Kniepunkt() {
const [tag, setTag] = useState(null)
const [page, setPage] = useState(1)
const { items, totalPages, hasNext, hasPrev } = useContent('kniepunkt', { tag, page })
const tags = useContentTags('kniepunkt')
const [searchQuery, setSearchQuery] = useState('')
const { items } = useContent('kniepunkt', { pageSize: 1000 })
const filteredItems = items.filter(item => {
if (!searchQuery) return true
const query = searchQuery.toLowerCase()
return (
(item.title && item.title.toLowerCase().includes(query)) ||
(item.summary && item.summary.toLowerCase().includes(query)) ||
(item.body && item.body.toLowerCase().includes(query))
)
})
return (
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
@@ -17,33 +27,48 @@ export default function Kniepunkt() {
<p className="section-subtitle">Wöchentliche KI-Kolumne. Nachrichten und Absurditäten rund um KI.</p>
</div>
{tags.length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', justifyContent: 'center', marginBottom: '40px' }}>
<button onClick={() => setTag(null)} className={`secondary-button ${!tag ? 'active' : ''}`} style={{ padding: '8px 16px', fontSize: '0.85rem' }}>Alle</button>
{tags.map(t => (
<button key={t} onClick={() => { setTag(t); setPage(1) }} className={`secondary-button ${tag === t ? 'active' : ''}`} style={{ padding: '8px 16px', fontSize: '0.85rem' }}>{t}</button>
))}
<div style={{ maxWidth: '700px', margin: '0 auto 40px auto', position: 'relative' }}>
<div style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)' }}>
<Search size={20} />
</div>
<input
type="text"
placeholder="Kniepunkte durchsuchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '16px 80px 16px 48px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'var(--glass-bg)',
color: 'var(--text-primary)',
fontSize: '1rem',
outline: 'none'
}}
/>
<div style={{ position: 'absolute', right: '16px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{filteredItems?.length || 0} / {items?.length || 0}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '700px', margin: '0 auto' }}>
{items.map(issue => (
<Link to={`/kniepunkt/${issue.slug}`} key={issue.slug} className="glass-panel" style={{ padding: '24px', display: 'block', transition: 'transform 0.3s ease' }}>
<h3 style={{ marginBottom: '8px' }}>{issue.title}</h3>
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{formatDate(issue.date)}</p>
{issue.summary && <p style={{ color: 'var(--text-secondary)', margin: '8px 0 0', fontSize: '0.95rem' }}>{issue.summary}</p>}
</Link>
))}
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Noch keine Ausgaben vorhanden.</p>}
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '16px', marginTop: '40px' }}>
{hasPrev && <button onClick={() => setPage(p => p - 1)} className="secondary-button">Zurück</button>}
<span style={{ color: 'var(--text-secondary)', alignSelf: 'center' }}>Seite {page} / {totalPages}</span>
{hasNext && <button onClick={() => setPage(p => p + 1)} className="secondary-button">Weiter</button>}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '700px', margin: '0 auto' }}>
{filteredItems.map(issue => (
<Link to={`/kniepunkt/${issue.slug}`} key={issue.slug} className="glass-panel" style={{ padding: '0', display: 'flex', flexDirection: 'column', transition: 'transform 0.3s ease', overflow: 'hidden' }}>
{issue.image && (
<div style={{ width: '100%', height: '240px', overflow: 'hidden' }}>
<img src={issue.image} alt={decodeHtml(issue.title)} style={{ width: '100%', height: '100%', objectFit: 'cover', borderBottom: '1px solid rgba(255,255,255,0.1)' }} />
</div>
)}
<div style={{ padding: '24px' }}>
<h3 style={{ marginBottom: '8px' }}>{decodeHtml(issue.title)}</h3>
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{formatDate(issue.date)}</p>
{issue.summary && <p style={{ color: 'var(--text-secondary)', margin: '8px 0 0', fontSize: '0.95rem' }}>{decodeHtml(issue.summary)}</p>}
</div>
</Link>
))}
{filteredItems.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Keine Kniepunkte für diese Suche gefunden.</p>}
</div>
</div>
</main>
)
@@ -25,6 +25,11 @@ export default function KniepunktIssue() {
<Link to="/kniepunkt" style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '24px', display: 'inline-block' }}>&larr; Alle Ausgaben</Link>
<h1 style={{ marginBottom: '12px' }}>{issue.title}</h1>
<p style={{ color: 'var(--text-secondary)', marginBottom: '40px' }}>{formatDate(issue.date)}</p>
{issue.image && (
<div style={{ marginBottom: '40px', borderRadius: '12px', overflow: 'hidden', boxShadow: '0 8px 24px rgba(0,0,0,0.1)' }}>
<img src={issue.image} alt={issue.title} style={{ width: '100%', height: 'auto', display: 'block' }} />
</div>
)}
<div className="content-body" dangerouslySetInnerHTML={{ __html: issue.body }} />
</div>
</main>