Add migration scripts and preload participation awards script

- Created migration index file to manage database migrations.
- Added script to preload participation awards and related blog posts into the CMS.
- Introduced new types and default data for application phases.
- Added award articles data structure for the upcoming awards.
This commit is contained in:
syntaxbullet
2026-06-30 19:10:53 +02:00
parent 9545f2513d
commit 7ff405d1a2
47 changed files with 68728 additions and 986 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router';
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react';
import { Trophy, ChevronRight, ArrowRight } from 'lucide-react';
import { WINNERS, type Winner } from '@/spa/data/winners';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
@@ -47,14 +47,10 @@ const normalizeWinner = (
export default function Preistraeger() {
const isMobile = useIsMobile();
const [filterCategory, setFilterCategory] = useState('');
const [search, setSearch] = useState('');
const [mediaOnly, setMediaOnly] = useState(false);
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('preistraeger');
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) };
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
@@ -69,20 +65,10 @@ export default function Preistraeger() {
return WINNERS;
}, [card, cardFallbackImage, cmsPreistraeger]);
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
const categories = useMemo(() => [...new Set(winners.map((winner) => winner.category))].sort((a, b) => a.localeCompare(b, 'de')), [winners]);
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
const filtered = useMemo(() => winners.filter(w => {
if (w.year !== selectedYear) return false;
if (filterCategory && w.category !== filterCategory) return false;
if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false;
if (mediaOnly && !w.hasMedia) return false;
return true;
}), [selectedYear, filterCategory, search, mediaOnly, winners]);
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
const hasFilter = !!(filterCategory || search || mediaOnly);
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
return (
<div style={{ background: '#fff', minHeight: '100vh' }}>
@@ -102,16 +88,16 @@ export default function Preistraeger() {
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
</div>
{/* Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 24px' : '48px 80px 32px' }}>
{/* Year Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 20px' : '48px 80px 24px' }}>
{/* #93: Headline entfernt Seitenname steht bereits oben im Breadcrumb. */}
{/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, marginBottom: 32, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{years.map(year => (
<button
key={year}
onClick={() => { setActiveYear(year); reset(); }}
onClick={() => setActiveYear(year)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px',
@@ -128,54 +114,6 @@ export default function Preistraeger() {
</button>
))}
</div>
{/* Filter Row */}
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: 'stretch', marginBottom: 20, flexWrap: 'wrap' }}>
<SelectControl
value={filterCategory}
onChange={setFilterCategory}
placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
options={categories.map(c => ({ label: c, value: c }))}
width={isMobile ? undefined : 280}
/>
<SearchControl value={search} onChange={setSearch} placeholder={fallbackText(filters.searchPlaceholder, preistraegerIndexContent.filters.searchPlaceholder)} />
</div>
{/* Toggle row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}>
{fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 15 }}>{fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)}</span>
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>{fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)}</span>
<button
onClick={() => setMediaOnly(v => !v)}
style={{
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
background: mediaOnly ? NAVY : BORDER,
position: 'relative', transition: 'background 0.2s',
}}
>
<span style={{
position: 'absolute', top: 3, left: mediaOnly ? 20 : 3, width: 16, height: 16,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
}} />
</button>
</div>
</div>
{hasFilter && (
<button
onClick={reset}
style={{ fontFamily: FF, fontSize: 16, color: GRAY, background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
>
<X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
</button>
)}
</div>
</div>
{/* Count bar */}
@@ -319,61 +257,3 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string
</Link>
);
}
// ── SelectControl ────────────────────────────────────────────────────────────
function SelectControl({ value, onChange, placeholder, options, width }: {
value: string; onChange: (v: string) => void;
placeholder: string; options: { label: string; value: string }[];
width?: number;
}) {
const active = !!value;
return (
<div style={{ position: 'relative', width: width ?? '100%', flexShrink: width ? 0 : 1 }}>
<select
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: '100%', height: 44, padding: '0 36px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${active ? NAVY : BORDER}`,
color: active ? NAVY : GRAY,
background: '#fff', borderRadius: 0,
appearance: 'none', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = active ? NAVY : BORDER)}
>
<option value="">{placeholder}</option>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown size={16} style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#101828' }} />
</div>
);
}
// ── SearchControl ────────────────────────────────────────────────────────────
function SearchControl({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<div style={{ position: 'relative', flex: 1, minWidth: 200 }}>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{
width: '100%', height: 44, padding: '0 40px 0 14px',
fontFamily: FF, fontSize: 16,
border: `1px solid ${BORDER}`, borderRadius: 0,
background: '#fff', outline: 'none', boxSizing: 'border-box',
transition: 'border-color 0.15s', color: '#101828',
}}
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
onBlur={e => (e.currentTarget.style.borderColor = BORDER)}
/>
<Search size={16} style={{ position: 'absolute', right: 14, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: GRAY }} />
</div>
);
}