Compare commits

...

5 Commits

Author SHA1 Message Date
DanielS
ec264e2680 feat(home): integrate motion components in home page
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-08-06 17:21:52 +02:00
DanielS
21e01b1b24 feat(ui): add 3-column workspace central section 2026-08-06 17:21:48 +02:00
DanielS
304d25f202 feat(ui): add animated horizontal process steps 2026-08-06 17:21:42 +02:00
DanielS
a2305319e1 feat(ui): add scroll indicator component 2026-08-06 17:21:36 +02:00
DanielS
70ed890702 feat(ui): add reactive oklch ascii shader background 2026-08-06 17:21:32 +02:00
6 changed files with 757 additions and 116 deletions

249
AsciiShaderBackground.astro Normal file
View File

@@ -0,0 +1,249 @@
---
// AsciiShaderBackground.astro
interface Props {
className?: string;
}
const { className = "" } = Astro.props;
---
<div class={`ascii-shader-container ${className}`}>
<canvas id="ascii-shader-canvas"></canvas>
<div class="grid-overlay"></div>
</div>
<style>
.ascii-shader-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
overflow: hidden;
background-color: oklch(0.145 0.01 148);
pointer-events: none;
}
#ascii-shader-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
will-change: transform;
pointer-events: none;
}
.grid-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 40px 40px;
background-image:
linear-gradient(to right, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px);
pointer-events: none;
}
</style>
<script>
// OKLCH Palette definition
interface OKLCHColor {
l: number;
c: number;
h: number;
}
// Palette defining color stops based on scroll progress
const PALETTE = {
yellow: { l: 0.88, c: 0.18, h: 96 },
green: { l: 0.76, c: 0.15, h: 155 },
blue: { l: 0.68, c: 0.18, h: 255 },
purple: { l: 0.64, c: 0.18, h: 302 },
pink: { l: 0.72, c: 0.20, h: 345 }
};
// Helper for shortest angle interpolation in OKLCH hue space
function interpolateHue(h1: number, h2: number, t: number): number {
let diff = (h2 - h1) % 360;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
return (h1 + diff * t + 360) % 360;
}
// Interpolate two OKLCH colors
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
return {
l: c1.l + (c2.l - c1.l) * t,
c: c1.c + (c2.c - c1.c) * t,
h: interpolateHue(c1.h, c2.h, t)
};
}
// Calculate dynamic colors based on scroll progress (0.0 to 1.0)
function getScrollColors(progress: number): { primary: OKLCHColor; secondary: OKLCHColor } {
if (progress < 0.33) {
// Hero to stage 1: Full spectrum (Pink -> Yellow)
const t = progress / 0.33;
return {
primary: lerpOKLCH(PALETTE.pink, PALETTE.yellow, t),
secondary: lerpOKLCH(PALETTE.purple, PALETTE.green, t)
};
} else if (progress < 0.66) {
// Stage 1 to stage 2: Yellow/Cream to Green
const t = (progress - 0.33) / 0.33;
return {
primary: lerpOKLCH(PALETTE.yellow, PALETTE.green, t),
secondary: lerpOKLCH(PALETTE.green, PALETTE.blue, t)
};
} else {
// Stage 2 to end: Green/Blue focus
const t = (progress - 0.66) / 0.34;
return {
primary: lerpOKLCH(PALETTE.green, PALETTE.blue, t),
secondary: lerpOKLCH(PALETTE.blue, PALETTE.purple, t)
};
}
}
class AsciiShaderEngine {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private mouse = { x: 0, y: 0, targetX: 0, targetY: 0 };
private scrollProgress = 0;
private targetScrollProgress = 0;
private chars = ['/', '\\', '-', '|', '+', '='];
private fontSize = 16;
private cols = 0;
private rows = 0;
private animationFrameId = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const context = canvas.getContext('2d');
if (!context) throw new Error('Could not get 2D context');
this.ctx = context;
this.init();
}
private init(): void {
this.resize();
this.bindEvents();
this.render();
}
private resize = (): void => {
const width = window.innerWidth;
const height = window.innerHeight;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.ctx.scale(dpr, dpr);
this.cols = Math.ceil(width / this.fontSize);
this.rows = Math.ceil(height / this.fontSize);
if (this.mouse.x === 0 && this.mouse.y === 0) {
this.mouse.x = width / 2;
this.mouse.y = height / 2;
this.mouse.targetX = width / 2;
this.mouse.targetY = height / 2;
}
};
private bindEvents(): void {
window.addEventListener('resize', this.resize);
window.addEventListener('pointermove', (e: PointerEvent) => {
this.mouse.targetX = e.clientX;
this.mouse.targetY = e.clientY;
});
window.addEventListener('scroll', () => {
const maxScroll = Math.max(
document.body.scrollHeight - window.innerHeight,
1
);
this.targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1);
}, { passive: true });
}
private render = (time: number = 0): void => {
// Dampened mouse easing (lerp factor 0.05)
this.mouse.x += (this.mouse.targetX - this.mouse.x) * 0.05;
this.mouse.y += (this.mouse.targetY - this.mouse.y) * 0.05;
// Dampened scroll progress easing
this.scrollProgress += (this.targetScrollProgress - this.scrollProgress) * 0.05;
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Get current OKLCH interpolated colors
const { primary, secondary } = getScrollColors(this.scrollProgress);
this.ctx.font = `${this.fontSize}px monospace`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
const timeSec = time * 0.001;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const x = c * this.fontSize + this.fontSize / 2;
const y = r * this.fontSize + this.fontSize / 2;
// Vector field calculations based on distance to cursor & wave function
const dx = x - this.mouse.x;
const dy = y - this.mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Dynamic angle field
const angle = Math.atan2(dy, dx) + Math.sin(dist * 0.01 - timeSec) * 0.5;
const charIndex = Math.floor(Math.abs(Math.sin(angle + dist * 0.005)) * this.chars.length) % this.chars.length;
const char = this.chars[charIndex];
// Color blend based on vector field intensity
const blendFactor = Math.min(Math.max(1 - dist / 400, 0), 1);
const currentColor = lerpOKLCH(secondary, primary, blendFactor);
// Render character with OKLCH styling
this.ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)})`;
this.ctx.fillText(char, x, y);
}
}
this.animationFrameId = requestAnimationFrame(this.render);
};
public destroy(): void {
window.removeEventListener('resize', this.resize);
cancelAnimationFrame(this.animationFrameId);
}
}
// Lifecycle handler for Astro / SPA transitions
function initShader() {
const canvas = document.getElementById('ascii-shader-canvas') as HTMLCanvasElement;
if (canvas) {
new AsciiShaderEngine(canvas);
}
}
// Initial load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initShader);
} else {
initShader();
}
// Astro page swap support
document.addEventListener('astro:page-load', initShader);
</script>

View File

@@ -0,0 +1,191 @@
'use client'
import React, { useEffect, useRef } from 'react'
interface OKLCHColor {
l: number
c: number
h: number
}
// Monochromes Farbkonzept im OKLCH-Raum
const BASE_BG = { l: 0.145, c: 0.01, h: 148 }
const BASE_DIM = { l: 0.50, c: 0.01, h: 148 }
const HIGHLIGHT_BRIGHT = { l: 0.95, c: 0.01, h: 148 }
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
return {
l: lerp(c1.l, c2.l, t),
c: lerp(c1.c, c2.c, t),
h: lerp(c1.h, c2.h, t)
}
}
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let animationFrameId: number
// Minimalistisches, ruhiges ASCII-Array
const chars = ['•', '∘', '+', '▪', '▫', ' ']
const fontSize = 18
let cols = 0
let rows = 0
const mouse = { x: 0, y: 0, targetX: 0, targetY: 0 }
let scrollProgress = 0
let targetScrollProgress = 0
const resize = () => {
const width = window.innerWidth
const height = window.innerHeight
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
cols = Math.ceil(width / fontSize)
rows = Math.ceil(height / fontSize)
if (mouse.x === 0 && mouse.y === 0) {
mouse.x = width / 2
mouse.y = height / 2
mouse.targetX = width / 2
mouse.targetY = height / 2
}
}
const onPointerMove = (e: PointerEvent) => {
mouse.targetX = e.clientX
mouse.targetY = e.clientY
}
const onScroll = () => {
const maxScroll = Math.max(
document.body.scrollHeight - window.innerHeight,
1
)
targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1)
}
resize()
window.addEventListener('resize', resize)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('scroll', onScroll, { passive: true })
const render = (time: number) => {
mouse.x += (mouse.targetX - mouse.x) * 0.05
mouse.y += (mouse.targetY - mouse.y) * 0.05
scrollProgress += (targetScrollProgress - scrollProgress) * 0.05
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Beim Scrollen sofort ausblenden (vollständig weg ab 150px Scroll-Tiefe)
const scrollFade = Math.max(1 - (window.scrollY / 150), 0)
if (scrollFade <= 0) {
animationFrameId = requestAnimationFrame(render)
return
}
// Scroll-gesteuerter Dimmer
const scrollDimFactor = (1 - scrollProgress * 0.4) * scrollFade
const currentDimColor = {
...BASE_DIM,
l: BASE_DIM.l * scrollDimFactor
}
ctx.font = `${fontSize}px monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const timeSec = time * 0.001
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * fontSize + fontSize / 2
const y = r * fontSize + fontSize / 2
// Abstandsvektor zur gedämpften Mausposition
const dx = x - mouse.x
const dy = y - mouse.y
const dist = Math.sqrt(dx * dx + dy * dy)
// Maus-Störung (Vektorfeld-Ablenkung): Phase & Amplitude lokal beeinflussen
const mouseInfluence = Math.exp(-dist / 250)
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
// Diagonale 2D-Strömung / Wellen-Synthese aus Sinus & Kosinus
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
// Zeichenauswahl basierend auf Strömungsfeld
const normalizedFlow = (flowValue + 1) * 0.5 // Range 0..1
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
const char = chars[charIndex]
// Leerzeichen für ruhiges Raster überspringen
if (char === ' ') continue
// Helligkeits-Highlighting durch Maus-Störung und Wellenkamm
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
const currentColor = lerpOKLCH(currentDimColor, HIGHLIGHT_BRIGHT, blendFactor)
// Stärkerer Kontrast: min 0.35, max 0.95 Deckkraft
const alpha = (0.35 + blendFactor * 0.6) * scrollDimFactor
ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)} / ${alpha.toFixed(2)})`
ctx.fillText(char, x, y)
}
}
animationFrameId = requestAnimationFrame(render)
}
animationFrameId = requestAnimationFrame(render)
return () => {
window.removeEventListener('resize', resize)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('scroll', onScroll)
cancelAnimationFrame(animationFrameId)
}
}, [])
return (
<div
className={`absolute top-0 left-0 right-0 h-[500px] z-0 overflow-hidden bg-[oklch(0.145_0.01_148)] pointer-events-none ${className}`}
style={{
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
}}
>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full block will-change-transform pointer-events-none"
/>
<div
className="absolute inset-0 w-full h-full pointer-events-none opacity-60"
style={{
backgroundSize: '40px 40px',
backgroundImage: `
linear-gradient(to right, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px)
`
}}
/>
</div>
)
}

View File

@@ -31,6 +31,11 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
import { ProcessSteps } from '@/components/ProcessSteps'
import { ScrollIndicator } from '@/components/ScrollIndicator'
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
interface HomeClientProps { interface HomeClientProps {
initialUser: User | null initialUser: User | null
} }
@@ -78,7 +83,8 @@ export function HomeClient({ initialUser }: HomeClientProps) {
} }
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200"> <div className="min-h-screen bg-transparent text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
<AsciiShaderBackground />
@@ -249,123 +255,11 @@ export function HomeClient({ initialUser }: HomeClientProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Grid of modules */} {/* Grid of modules / Workspace-Zentrale */}
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50 relative"> <WorkspaceZentrale />
<div className="container max-w-6xl mx-auto px-4 relative">
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
<div className="text-center space-y-3 mb-16">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-white">
Workspace-Zentrale
</h2>
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
Die zentralen Plattformen für unsere tägliche Zusammenarbeit, Dokumentation und den Support.
</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Card 1 */}
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
<MessageSquare className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Zulip</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Treten Sie unserem Partner-Chat bei! tauschen Sie sich mit anderen CASPOS Partnern aus.
</p>
</div>
</motion.div></a>
{/* Card 2 */}
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
<BookOpen className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Wiki</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Ihr Wissens-Portal für die CASPOS Produktfamilie.
</p>
</div>
</motion.div></a>
{/* Card 3 */}
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
<LifeBuoy className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Support</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Sie haben ein technisches Problem in der CASPOS? Erstellen Sie jetzt ein Ticket!
</p>
</div>
</motion.div></a>
</div>
</div>
</section>
{/* Stepper info */} {/* Stepper info */}
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30"> <ProcessSteps />
<div className="container max-w-5xl mx-auto px-4">
<div className="text-center space-y-3 mb-16">
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center text-white">
Wie funktioniert die Bereitstellung?
</h2>
</div>
<div className="relative">
{/* Connection line for Desktop */}
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] border-t border-dashed border-slate-800 z-0" />
<div className="grid gap-8 md:grid-cols-3 relative z-10">
{/* Step 1 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
1
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Kunde wählen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Gewünschten Kunden auswählen, der die Lizenz erhalten soll.
</p>
</div>
{/* Step 2 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
2
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Lizenz wählen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Gewünschte Lizenzen für den Kunden auswählen und den Bestellung abschließen.
</p>
</div>
{/* Step 3 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
3
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Live gehen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Nach dem Erhalt der Lizenz Live gehen.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Footer */} {/* Footer */}

View File

@@ -0,0 +1,155 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
interface Step {
number: string
title: string
description: string
}
const steps: Step[] = [
{
number: '1',
title: 'Kunde wählen',
description: 'Gewünschten Kunden auswählen, der die Lizenz erhalten soll.'
},
{
number: '2',
title: 'Lizenz wählen',
description: 'Gewünschte Lizenzen für den Kunden auswählen und die Bestellung abschließen.'
},
{
number: '3',
title: 'Live gehen',
description: 'Nach dem Erhalt der Lizenz direkt live gehen und durchstarten.'
}
]
const containerVariants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.2,
staggerChildren: 0.35
}
}
}
const stepVariants = {
hidden: {
opacity: 0,
y: 20
},
visible: {
opacity: 1,
y: 0,
transition: {
type: 'spring' as const,
stiffness: 70,
damping: 16
}
}
}
export function ProcessSteps() {
return (
<section className="w-full py-16 px-4 relative z-10">
<div className="container max-w-5xl mx-auto flex flex-col items-center">
{/* Section Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ type: 'spring' as const, stiffness: 60, damping: 15 }}
className="text-center space-y-3 mb-16 max-w-2xl"
>
<h2 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
Wie funktioniert die Bereitstellung?
</h2>
<p className="text-slate-400 text-base sm:text-lg leading-relaxed">
In nur drei einfachen Schritten richten Sie CASPOS Lizenzen für Ihre Kunden ein.
</p>
</motion.div>
{/* Horizontale Timeline */}
<div className="relative w-full">
{/* Basis-Linie exakt zwischen Schritt 1 und 3 */}
<div className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-white/10 z-0 rounded-full" />
{/* Animierte, aufgeladene Linie (sky-500) */}
<motion.div
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true, margin: '-50px' }}
transition={{
duration: 1.5,
ease: 'easeInOut',
delay: 0.2
}}
className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-sky-500 origin-left z-0 rounded-full shadow-[0_0_12px_rgba(14,165,233,0.8)]"
/>
{/* 3 Schritte Nebeneinander */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-50px' }}
className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6 w-full relative z-10"
>
{steps.map((step, index) => {
const isLiveStep = index === 2
return (
<motion.div
key={step.number}
variants={stepVariants}
className="group flex flex-col items-center text-center space-y-4 cursor-pointer relative"
>
{/* Nummer im runden Kreis auf der Linie */}
<div className="relative">
<motion.div
whileHover={{ scale: 1.15 }}
transition={{ type: 'spring' as const, stiffness: 300, damping: 20 }}
className="w-12 h-12 rounded-full bg-slate-950 border-2 border-slate-700 text-slate-300 font-extrabold text-lg flex items-center justify-center group-hover:border-sky-400 group-hover:text-sky-400 group-hover:bg-slate-900 group-hover:shadow-[0_0_20px_rgba(56,189,248,0.4)] transition-all duration-300 z-10 relative"
>
{step.number}
</motion.div>
{/* Pulsierender Ziel-Ring bei Schritt 3 */}
{isLiveStep && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: 1.6 }}
className="absolute inset-0 rounded-full border-2 border-emerald-400/60 pointer-events-none animate-ping"
/>
)}
</div>
{/* Text-Inhalt zentriert ohne Boxen-Hintergrund */}
<div className="space-y-2 max-w-[280px]">
<h3 className="text-xl font-bold text-white group-hover:text-sky-400 transition-colors duration-300 flex items-center justify-center gap-2">
{step.title}
{isLiveStep && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
LIVE
</span>
)}
</h3>
<p className="text-slate-400 text-sm leading-relaxed">
{step.description}
</p>
</div>
</motion.div>
)
})}
</motion.div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,20 @@
'use client'
import React from 'react'
import { motion, useScroll, useSpring } from 'framer-motion'
export function ScrollIndicator() {
const { scrollYProgress } = useScroll()
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
})
return (
<motion.div
className="fixed top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400 origin-left z-50 pointer-events-none shadow-[0_0_12px_rgba(59,130,246,0.8)]"
style={{ scaleX }}
/>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import React, { useState } from 'react'
import { motion } from 'framer-motion'
import { MessageSquare, BookOpen, LifeBuoy, ExternalLink } from 'lucide-react'
interface WorkspaceItem {
id: string
title: string
subtitle: string
hoverText: string
icon: React.ElementType
link: string
}
const workspaceItems: WorkspaceItem[] = [
{
id: 'zulip',
title: 'Zulip Chat',
subtitle: 'Treten Sie unserem Partner-Chat bei!',
hoverText: 'Tauschen Sie sich direkt und unkompliziert mit anderen CASPOS Partnern aus.',
icon: MessageSquare,
link: 'https://caspos.zulipchat.com'
},
{
id: 'wiki',
title: 'CASPOS Wiki',
subtitle: 'Ihr Wissens-Portal...',
hoverText: '...für die gesamte CASPOS Produktfamilie. Hier finden Sie alle technischen Dokumentationen.',
icon: BookOpen,
link: 'https://wiki.caspos.de'
},
{
id: 'support',
title: 'Support Portal',
subtitle: 'Sie haben ein technisches Problem?',
hoverText: 'Erstellen Sie jetzt ein Ticket in der CASPOS, damit sich unsere Experten darum kümmern.',
icon: LifeBuoy,
link: 'https://support.caspos.de'
}
]
export function WorkspaceZentrale() {
const [hoveredId, setHoveredId] = useState<string | null>(null)
return (
<section className="w-full py-12 px-4 relative z-10">
<div className="container max-w-6xl mx-auto space-y-6">
<div className="text-center space-y-2">
<h2 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
Workspace Zentrale
</h2>
</div>
{/* 3-Spalten Grid mit fester Kartenhöhe */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{workspaceItems.map((item) => {
const Icon = item.icon
const isHovered = hoveredId === item.id
return (
<motion.a
key={item.id}
href={item.link}
target="_blank"
rel="noopener noreferrer"
whileHover={{ y: -4 }}
onMouseEnter={() => setHoveredId(item.id)}
onMouseLeave={() => setHoveredId(null)}
className={`group relative p-6 rounded-2xl border transition-all duration-300 backdrop-blur-md flex flex-col justify-between h-[210px] overflow-hidden cursor-pointer ${
isHovered
? 'border-sky-500 bg-slate-900/90 shadow-[0_0_25px_rgba(14,165,233,0.2)]'
: 'border-slate-800 bg-slate-900/60'
}`}
>
<div className="space-y-3">
{/* Header: Icon & Link */}
<div className="flex items-center justify-between">
<div
className={`p-3 rounded-xl border transition-all duration-300 ${
isHovered
? 'bg-sky-500/20 border-sky-500/50 text-sky-300'
: 'bg-slate-800/60 border-slate-700/50 text-slate-400'
}`}
>
<Icon className="w-6 h-6" />
</div>
<ExternalLink
className={`w-4 h-4 transition-all duration-300 ${
isHovered
? 'text-sky-400 translate-x-0.5 -translate-y-0.5'
: 'text-slate-600'
}`}
/>
</div>
{/* Title */}
<h3 className="text-lg font-bold text-white group-hover:text-sky-300 transition-colors">
{item.title}
</h3>
{/* Subtitle / Hover-Text Überblendung ohne Höhenänderung */}
<div className="relative h-[48px] overflow-hidden">
<p
className={`text-sm text-slate-300 font-medium transition-all duration-300 absolute inset-0 ${
isHovered ? 'opacity-0 translate-y-[-8px]' : 'opacity-100 translate-y-0'
}`}
>
{item.subtitle}
</p>
<p
className={`text-xs text-slate-400 leading-relaxed transition-all duration-300 absolute inset-0 ${
isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-[8px]'
}`}
>
{item.hoverText}
</p>
</div>
</div>
{/* Subtile Hover-Linie unten */}
<div className={`w-full h-0.5 rounded-full transition-all duration-300 ${
isHovered ? 'bg-sky-500/80 shadow-[0_0_8px_rgba(14,165,233,0.8)]' : 'bg-transparent'
}`} />
</motion.a>
)
})}
</div>
</div>
</section>
)
}