Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
363 lines
16 KiB
TypeScript
363 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo } from 'react'
|
|
import { type Milestone, type StatItem, MILESTONES, TODAY_POSITION } from './MilestonesSlide.data'
|
|
import { type Theme } from './MilestonesSlide.themes'
|
|
|
|
const MONO: React.CSSProperties = {
|
|
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
|
|
fontVariantNumeric: 'tabular-nums',
|
|
}
|
|
|
|
// ── Star Field ────────────────────────────────────────────────────────────────
|
|
export function StarField() {
|
|
const stars = useMemo(() => {
|
|
let s = 77
|
|
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
|
|
return Array.from({ length: 95 }, () => ({ x: r() * 100, y: r() * 100, size: r() * 1.4 + 0.3, op: r() * 0.5 + 0.15 }))
|
|
}, [])
|
|
return (
|
|
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
|
{stars.map((st, i) => (
|
|
<div key={i} style={{
|
|
position: 'absolute', left: `${st.x}%`, top: `${st.y}%`,
|
|
width: st.size, height: st.size, borderRadius: '50%',
|
|
background: '#fff', opacity: st.op,
|
|
boxShadow: `0 0 ${st.size * 3}px rgba(180,160,255,.7)`,
|
|
}} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function SoftGrid({ t }: { t: Theme }) {
|
|
return (
|
|
<div style={{
|
|
position: 'absolute', inset: 0, pointerEvents: 'none',
|
|
backgroundImage: `radial-gradient(${t.accent20} 1px, transparent 1px)`,
|
|
backgroundSize: '28px 28px',
|
|
maskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
|
|
WebkitMaskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
|
|
opacity: 0.8,
|
|
}} />
|
|
)
|
|
}
|
|
|
|
// ── Timeline ──────────────────────────────────────────────────────────────────
|
|
interface MilestoneWithPos extends Milestone { x: number; row: 'top' | 'bottom' }
|
|
|
|
export function Timeline({ onSelect, selectedId, t, de }: {
|
|
onSelect: (m: Milestone) => void
|
|
selectedId: string | null
|
|
t: Theme
|
|
de: boolean
|
|
}) {
|
|
const trackW = 1160
|
|
const innerPad = 120
|
|
const usableW = trackW - innerPad * 2
|
|
const positions = MILESTONES.map((_, i) => innerPad + (usableW * i) / (MILESTONES.length - 1))
|
|
const todayX = innerPad + usableW * TODAY_POSITION
|
|
|
|
const layout: MilestoneWithPos[] = MILESTONES.map((m, i) => ({
|
|
...m, x: positions[i],
|
|
row: i % 2 === 0 ? 'top' : 'bottom',
|
|
}))
|
|
|
|
const railColor = t.key === 'dark' ? '#a78bfa' : '#7c3aed'
|
|
|
|
return (
|
|
<div style={{ position: 'relative', width: trackW, height: 360, margin: '0 auto' }}>
|
|
<svg viewBox={`0 0 ${trackW} 360`} preserveAspectRatio="none"
|
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
|
|
<defs>
|
|
<linearGradient id="msTrackBg" x1="0" x2="1">
|
|
<stop offset="0" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
|
|
<stop offset=".5" stopColor={railColor} stopOpacity={t.key === 'dark' ? .28 : .38} />
|
|
<stop offset="1" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
|
|
</linearGradient>
|
|
<filter id="msGlow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="3" result="b"/>
|
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* rail background */}
|
|
<line x1={innerPad} y1={180} x2={trackW - innerPad} y2={180}
|
|
stroke="url(#msTrackBg)" strokeWidth="2.5" />
|
|
{/* past progress */}
|
|
<line x1={innerPad} y1={180} x2={todayX} y2={180}
|
|
stroke={t.done} strokeWidth="3" opacity={t.key === 'dark' ? .85 : .9} />
|
|
{/* future dashed */}
|
|
<line x1={todayX} y1={180} x2={trackW - innerPad} y2={180}
|
|
stroke="#f59e0b" strokeWidth="1.75" strokeDasharray="4 5"
|
|
opacity={t.key === 'dark' ? .6 : .75}
|
|
style={{ animation: 'msFlow 1.8s linear infinite' }} />
|
|
|
|
{/* connector stubs */}
|
|
{layout.map((m) => (
|
|
<line key={m.id}
|
|
x1={m.x} y1={180}
|
|
x2={m.x} y2={m.row === 'top' ? 154 : 200}
|
|
stroke={m.done ? t.done : m.tint}
|
|
strokeOpacity={t.key === 'dark' ? (m.done ? .6 : .55) : (m.done ? .7 : .65)}
|
|
strokeWidth="1"
|
|
strokeDasharray={m.done ? '0' : '3 3'} />
|
|
))}
|
|
|
|
{/* HEUTE marker -- circles only; pill is HTML below */}
|
|
<g transform={`translate(${todayX} 180)`}>
|
|
<circle r="14" fill={t.accent} opacity=".15" />
|
|
<circle r="9" fill={t.accent} opacity=".4">
|
|
<animate attributeName="r" values="9;14;9" dur="2s" repeatCount="indefinite" />
|
|
<animate attributeName="opacity" values=".4;.05;.4" dur="2s" repeatCount="indefinite" />
|
|
</circle>
|
|
<circle r="6" fill={t.heuteCore} stroke={t.accent} strokeWidth="2" filter="url(#msGlow)" />
|
|
</g>
|
|
</svg>
|
|
|
|
{/* HEUTE pill -- HTML so it sits above milestone cards */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: todayX - 30, top: 146,
|
|
width: 60, height: 18,
|
|
borderRadius: 9,
|
|
background: t.heutePillBg,
|
|
border: `1px solid ${t.accent}99`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
zIndex: 10, pointerEvents: 'none',
|
|
...MONO, fontSize: 9.5, letterSpacing: 2.5, fontWeight: 700,
|
|
color: t.heuteText,
|
|
}}>HEUTE</div>
|
|
|
|
{layout.map((m) => (
|
|
<MilestoneNode key={m.id} m={m} t={t} de={de}
|
|
onClick={() => onSelect(m)}
|
|
active={selectedId === m.id} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MilestoneNode({ m, onClick, active, t, de }: {
|
|
m: MilestoneWithPos; onClick: () => void; active: boolean; t: Theme; de: boolean
|
|
}) {
|
|
const [hover, setHover] = useState(false)
|
|
const lit = hover || active
|
|
const isTop = m.row === 'top'
|
|
const cardY = isTop ? 4 : 200
|
|
const nodeColor = m.done ? t.done : m.tint
|
|
|
|
const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop
|
|
const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid
|
|
const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})`
|
|
const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als nächstes' : 'next') : (de ? 'geplant' : 'plan'))
|
|
|
|
return (
|
|
<>
|
|
{/* dot */}
|
|
<div
|
|
onClick={onClick}
|
|
onMouseEnter={() => setHover(true)}
|
|
onMouseLeave={() => setHover(false)}
|
|
style={{
|
|
position: 'absolute', left: m.x - 14, top: 180 - 14,
|
|
width: 28, height: 28, borderRadius: '50%',
|
|
background: m.done
|
|
? `radial-gradient(circle at 35% 30%, ${t.doneBright}, ${t.doneSolid} 60%, ${t.doneDeep})`
|
|
: `radial-gradient(circle at 35% 30%, ${m.tint}dd, ${m.tint}66 60%, ${t.dotTodoDeep})`,
|
|
border: `2px solid ${lit ? '#fff' : nodeColor}`,
|
|
boxShadow: lit
|
|
? `0 0 22px ${nodeColor}, 0 0 44px ${nodeColor}66, inset 0 1px 0 ${t.dotLitHi}`
|
|
: `0 0 10px ${nodeColor}88, inset 0 1px 0 ${t.dotSoftHi}`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
color: '#fff', fontSize: 11, fontWeight: 700,
|
|
cursor: 'pointer', zIndex: 5,
|
|
transition: 'all .25s',
|
|
transform: lit ? 'scale(1.15)' : 'scale(1)',
|
|
}}>
|
|
{m.done ? '✓' : (m.next ? '◉' : '○')}
|
|
</div>
|
|
|
|
{/* card */}
|
|
<div
|
|
onClick={onClick}
|
|
onMouseEnter={() => setHover(true)}
|
|
onMouseLeave={() => setHover(false)}
|
|
style={{
|
|
position: 'absolute', left: m.x - 112, top: cardY,
|
|
width: 224, height: 150, padding: '12px 14px',
|
|
borderRadius: 12,
|
|
background: cardBg,
|
|
border: `1px solid ${lit ? m.tint : m.tint + '55'}`,
|
|
boxShadow: lit ? t.cardShadowLift(m.tint) : t.cardShadowSoft,
|
|
cursor: 'pointer', zIndex: 4,
|
|
transition: 'all .25s',
|
|
transform: lit ? `translateY(${isTop ? -2 : 2}px)` : 'translateY(0)',
|
|
display: 'flex', flexDirection: 'column', gap: 6,
|
|
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span style={{
|
|
...MONO, fontSize: 10, letterSpacing: 1.5, fontWeight: 700,
|
|
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const,
|
|
}}>{m.tick}</span>
|
|
<span style={{ flex: 1, height: 1, background: `${m.tint}44` }} />
|
|
<span style={{
|
|
...MONO, fontSize: 9, letterSpacing: 2, fontWeight: 700,
|
|
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const, opacity: .85,
|
|
}}>{badge}</span>
|
|
</div>
|
|
<div style={{ fontSize: 13, fontWeight: 700, color: t.fg, letterSpacing: -0.2, lineHeight: 1.25 }}>
|
|
{de ? m.title.de : m.title.en}
|
|
</div>
|
|
<div style={{ fontSize: 10.5, lineHeight: 1.45, color: lit ? t.fgSoft : t.fgMuted, transition: 'color .25s' }}>
|
|
{de ? m.short.de : m.short.en}
|
|
</div>
|
|
<div style={{
|
|
marginTop: 'auto',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
paddingTop: 6, borderTop: `1px dashed ${m.tint}44`,
|
|
}}>
|
|
<span style={{ fontSize: 10, color: t.fgFaint }}>{m.when}</span>
|
|
<span style={{
|
|
fontSize: 10, color: m.tint, fontWeight: 700,
|
|
opacity: lit ? 1 : 0.55,
|
|
transform: `translateX(${lit ? 0 : -4}px)`,
|
|
transition: 'all .25s',
|
|
}}>{de ? 'Details →' : 'Details →'}</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── Stat Card ─────────────────────────────────────────────────────────────────
|
|
export function StatCard({ item, t, de }: { item: StatItem; t: Theme; de: boolean }) {
|
|
const [hover, setHover] = useState(false)
|
|
const bgTop = hover ? item.tint + t.statTintTopH : item.tint + t.statTintTop
|
|
const bgMid = item.tint + t.statTintMid
|
|
return (
|
|
<div
|
|
onMouseEnter={() => setHover(true)}
|
|
onMouseLeave={() => setHover(false)}
|
|
style={{
|
|
position: 'relative', padding: '14px 18px', borderRadius: 12,
|
|
background: `linear-gradient(180deg, ${bgTop} 0%, ${bgMid} 60%, ${t.cardBase}${t.cardBaseA})`,
|
|
border: `1px solid ${hover ? item.tint : item.tint + '55'}`,
|
|
boxShadow: hover ? t.statShadowLift(item.tint) : t.statShadowSoft,
|
|
transform: hover ? 'translateY(-3px)' : 'translateY(0)',
|
|
transition: 'all .25s',
|
|
overflow: 'hidden',
|
|
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
|
|
}}>
|
|
<div style={{
|
|
position: 'absolute', right: 10, top: 10, width: 6, height: 6,
|
|
borderRadius: '50%', background: item.tint, opacity: .9,
|
|
boxShadow: `0 0 10px ${item.tint}`,
|
|
}} />
|
|
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2, color: item.tint, textTransform: 'uppercase' as const, fontWeight: 700, marginBottom: 6 }}>
|
|
{de ? item.k.de : item.k.en}
|
|
</div>
|
|
<div style={{ fontSize: 32, fontWeight: 700, color: t.fg, letterSpacing: -0.8, lineHeight: 1 }}>
|
|
{item.v}
|
|
</div>
|
|
<svg viewBox="0 0 100 16" preserveAspectRatio="none"
|
|
style={{ width: '100%', height: 14, marginTop: 8, opacity: hover ? 1 : t.sparkOp, transition: 'opacity .25s' }}>
|
|
<defs>
|
|
<linearGradient id={`spark-${item.tint.replace('#', '')}`} x1="0" x2="1">
|
|
<stop offset="0" stopColor={item.tint} stopOpacity="0" />
|
|
<stop offset=".5" stopColor={item.tint} stopOpacity=".9" />
|
|
<stop offset="1" stopColor={item.tint} stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path d="M 0 10 L 15 8 L 30 11 L 48 6 L 62 9 L 78 4 L 100 2"
|
|
stroke={`url(#spark-${item.tint.replace('#', '')})`} strokeWidth="1.5" fill="none" />
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Detail modal ──────────────────────────────────────────────────────────────
|
|
export function DetailModal({ item, onClose, t, de }: {
|
|
item: Milestone | null; onClose: () => void; t: Theme; de: boolean
|
|
}) {
|
|
useEffect(() => {
|
|
if (!item) return
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [item, onClose])
|
|
|
|
if (!item) return null
|
|
const tint = item.tint
|
|
const badge = item.done
|
|
? (de ? 'ABGESCHLOSSEN' : 'COMPLETED')
|
|
: (item.next ? (de ? 'ALS NÄCHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED'))
|
|
const badgeColor = item.done ? t.done : tint
|
|
|
|
return (
|
|
<div onClick={onClose} style={{
|
|
position: 'absolute', inset: 0, zIndex: 50,
|
|
background: t.modalScrim, backdropFilter: 'blur(8px)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
animation: 'msFadeIn .2s ease-out',
|
|
}}>
|
|
<div onClick={e => e.stopPropagation()} style={{
|
|
width: 580, maxWidth: '88%',
|
|
background: `linear-gradient(180deg, ${tint}22 0%, ${t.modalBgMid} 50%, ${t.modalBgLow} 100%)`,
|
|
border: `1px solid ${tint}77`,
|
|
borderRadius: 16,
|
|
boxShadow: t.modalShadow(tint),
|
|
padding: '24px 28px', color: t.fg,
|
|
animation: 'msScaleIn .22s ease-out',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
|
<div style={{
|
|
width: 42, height: 42, borderRadius: 11,
|
|
background: `linear-gradient(135deg, ${tint}66, ${tint}22)`,
|
|
border: `1px solid ${tint}`,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700,
|
|
boxShadow: `0 0 20px ${tint}66`,
|
|
}}>{item.done ? '✓' : (item.next ? '◉' : '○')}</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
|
<span style={{
|
|
...MONO, fontSize: 9.5, letterSpacing: 2.5, color: badgeColor,
|
|
textTransform: 'uppercase' as const, fontWeight: 700,
|
|
padding: '2px 8px', borderRadius: 4,
|
|
background: `${badgeColor}22`, border: `1px solid ${badgeColor}66`,
|
|
}}>{badge}</span>
|
|
<span style={{ ...MONO, fontSize: 10, color: t.fgFaint }}>{item.when}</span>
|
|
</div>
|
|
<div style={{ fontSize: 20, fontWeight: 700, color: t.fg, letterSpacing: -0.3 }}>
|
|
{de ? item.title.de : item.title.en}
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} style={{
|
|
background: 'transparent', border: `1px solid ${tint}66`, color: t.fg,
|
|
width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14,
|
|
}}>✕</button>
|
|
</div>
|
|
<div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}>
|
|
{de ? item.body.de : item.body.en}
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{(de ? item.bullets.de : item.bullets.en).map((b, i) => (
|
|
<div key={i} style={{
|
|
display: 'flex', alignItems: 'flex-start', gap: 10,
|
|
padding: '9px 13px', borderRadius: 8,
|
|
background: t.bulletBg, border: `1px solid ${tint}44`,
|
|
}}>
|
|
<span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}>
|
|
{item.done ? '✓' : '▸'}
|
|
</span>
|
|
<span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|