feat(privat/CV): sync to latest upstream (cv-upstream/main, 30f9608b)

This commit is contained in:
2026-07-07 15:20:55 +02:00
parent 8ac664d33d
commit a4efabbc60
417 changed files with 48564 additions and 712 deletions
@@ -0,0 +1,93 @@
import content from 'virtual:content'
/**
* Get all content of a given type, sorted by date descending.
* @param {'posts'|'kniepunkt'|'podcast'|'talks'|'consulting'|'resources'|'events'} type
* @param {object} options
* @param {string} options.tag - Filter by tag
* @param {'primary'|'secondary'|'all'} options.visibility - Filter by visibility (default: 'all')
* @param {number} options.page - Page number (1-based)
* @param {number} options.pageSize - Items per page (default: 10)
*/
export function useContent(type, options = {}) {
const { tag, visibility = 'all', page = 1, pageSize = 10 } = options
let items = content[type] || []
// Filter by visibility
if (visibility !== 'all') {
items = items.filter(item => (item.visibility || 'primary') === visibility)
}
// Filter by tag
if (tag) {
items = items.filter(item => item.tags && item.tags.includes(tag))
}
// Sort by date descending
items = [...items].sort((a, b) => {
const dateA = new Date(a.date || a.issue || 0)
const dateB = new Date(b.date || b.issue || 0)
return dateB - dateA
})
// Pagination
const total = items.length
const totalPages = Math.ceil(total / pageSize)
const paginated = items.slice((page - 1) * pageSize, page * pageSize)
return {
items: paginated,
total,
page,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
}
}
/**
* Get a single content item by slug.
*/
export function useContentItem(type, slug) {
const items = content[type] || []
return items.find(item => item.slug === slug) || null
}
/**
* Get all unique tags across a content type.
*/
export function useContentTags(type) {
const items = content[type] || []
const tagSet = new Set()
items.forEach(item => {
if (item.tags) item.tags.forEach(t => tagSet.add(t))
})
return [...tagSet].slice(0, 20).sort()
}
/**
* Get featured/primary content for homepage highlights.
* Returns the latest N items with visibility=primary from each type.
*/
export function useFeaturedContent(count = 3) {
const types = ['posts', 'kniepunkt', 'podcast', 'talks', 'consulting']
const featured = {}
types.forEach(type => {
const items = (content[type] || [])
.filter(item => (item.visibility || 'primary') === 'primary')
.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
.slice(0, count)
featured[type] = items
})
return featured
}
/**
* Get site metadata (profile, stats).
*/
export function useMeta() {
return content.meta || {}
}
@@ -0,0 +1,71 @@
import { useState, useRef, useEffect } from 'react'
/**
* Hook for form submission with validation and loading state.
* Automatically includes anti-spam fields (honeypot + form-render timestamp).
* @param {string} endpoint - API endpoint (e.g., '/api/contact')
* @param {function} validate - Validation function, returns { valid, errors }
*/
export function useFormSubmit(endpoint, validate) {
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [errors, setErrors] = useState({})
const [serverError, setServerError] = useState(null)
// Timestamp when the form mounted — used as a server-side time trap.
const formStartedAt = useRef(0)
useEffect(() => {
formStartedAt.current = Date.now()
}, [])
async function submit(data) {
setServerError(null)
// Client-side validation
if (validate) {
const result = validate(data)
if (!result.valid) {
setErrors(result.errors)
return false
}
}
setErrors({})
setLoading(true)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, form_started_at: formStartedAt.current }),
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
if (body.errors) {
setErrors(body.errors)
} else {
setServerError(body.error || 'Ein Fehler ist aufgetreten.')
}
setLoading(false)
return false
}
setSuccess(true)
setLoading(false)
return true
} catch {
setServerError('Verbindungsfehler. Bitte versuche es erneut.')
setLoading(false)
return false
}
}
function reset() {
setLoading(false)
setSuccess(false)
setErrors({})
setServerError(null)
}
return { submit, loading, success, errors, serverError, reset }
}