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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user