diff --git a/Dockerfile.dev b/Dockerfile.dev index 2b70290ec..4cf68598d 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,7 @@ FROM node:20-alpine # Install essential build tools if needed (e.g., for node-gyp) -RUN apk add --no-cache libc6-compat python3 make g++ curl +RUN apk add --no-cache libc6-compat python3 make g++ curl git WORKDIR /app diff --git a/components/blocks/TeamGrid.tsx b/components/blocks/TeamGrid.tsx index d614942b3..1db65b3c6 100644 --- a/components/blocks/TeamGrid.tsx +++ b/components/blocks/TeamGrid.tsx @@ -1,9 +1,12 @@ 'use client'; import * as React from 'react'; -import { m } from 'framer-motion'; +import { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { m, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; import { useTranslations } from 'next-intl'; +import { LogoArcs } from '@/components/ui/LogoArcs'; export interface TeamMember { id: string; @@ -22,11 +25,102 @@ interface TeamGridProps { members: TeamMember[]; } +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +export function getMemberLogoArcsProps(memberId: string) { + const hash = hashString(memberId); + const rotation = Math.round((hash * 137.5) % 360); + const size = 160 + ((hash * 13) % 180); // 160px to 340px + const top = -60 + ((hash * 7) % 80); // -60px to 20px + const right = (hash % 2 === 0) ? -50 + ((hash * 11) % 70) : undefined; + const left = (hash % 2 !== 0) ? -50 + ((hash * 11) % 70) : undefined; + const opacity = Number((0.015 + ((hash % 7) * 0.005)).toFixed(3)); // 0.015 to 0.045 (ultra subtle) + + return { rotation, size, top, right, left, opacity }; +} + export function TeamGrid({ members }: TeamGridProps) { const t = useTranslations('TeamGrid'); + const [selectedMember, setSelectedMember] = useState(null); + const [mounted, setMounted] = useState(false); + const [[page, direction], setPage] = useState([0, 0]); + + const selectedIndex = selectedMember ? members.findIndex(m => m.id === selectedMember.id) : -1; + const prevMember = selectedIndex !== -1 && members.length > 1 ? members[(selectedIndex - 1 + members.length) % members.length] : null; + const nextMember = selectedIndex !== -1 && members.length > 1 ? members[(selectedIndex + 1) % members.length] : null; + + const paginate = (newDirection: number) => { + if (newDirection > 0 && nextMember) { + setPage([page + 1, newDirection]); + setSelectedMember(nextMember); + } else if (newDirection < 0 && prevMember) { + setPage([page - 1, newDirection]); + setSelectedMember(prevMember); + } + }; + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSelectedMember(null); + } else if (e.key === 'ArrowLeft' && prevMember) { + paginate(-1); + } else if (e.key === 'ArrowRight' && nextMember) { + paginate(1); + } + }; + window.addEventListener('keydown', handleKeyDown); + + if (selectedMember) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [selectedMember, prevMember, nextMember, page]); + + const handlePreload = (url: string | null) => { + if (url && typeof window !== 'undefined') { + const img = new window.Image(); + img.src = url; + } + }; if (!members || members.length === 0) return null; + const slideVariants = { + enter: (dir: number) => ({ + x: dir > 0 ? 350 : dir < 0 ? -350 : 0, + opacity: 0, + scale: 0.82, + }), + center: { + x: 0, + opacity: 1, + scale: 1, + }, + exit: (dir: number) => ({ + x: dir < 0 ? 350 : dir > 0 ? -350 : 0, + opacity: 0, + scale: 0.82, + }), + }; + return (
@@ -39,75 +133,351 @@ export function TeamGrid({ members }: TeamGridProps) {
- {members.map((member, i) => ( - - {/* Card Banner */} -
-
-
-
+ {members.map((member, i) => { + const imageUrl = member.image ? (typeof member.image === 'string' ? member.image : member.image.url) : null; + const arcsProps = getMemberLogoArcsProps(member.id); -
- {/* Overlapping Profile Picture */} -
- {member.image && (typeof member.image === 'string' ? member.image : member.image.url) ? ( - {member.name} - ) : ( -
- + return ( + handlePreload(imageUrl)} + onTouchStart={() => handlePreload(imageUrl)} + className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden" + > + {/* Card Banner with Signature E-TIB LogoArcs Design Element */} +
+
+ + {/* Signature E-TIB Logo Arcs Design Element */} +
+ +
+ +
+
+ +
+ {/* Overlapping Profile Picture with Shared Layout Animation */} + { + setPage([0, 0]); + setSelectedMember(member); + }} + onMouseEnter={() => handlePreload(imageUrl)} + onTouchStart={() => handlePreload(imageUrl)} + className="w-32 h-32 md:w-36 md:h-36 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-white relative -mt-16 md:-mt-20 mb-6 group-hover:-translate-y-2 transition-transform duration-500 cursor-pointer group/avatar" + > + {imageUrl ? ( + <> + {member.name} + {/* Zoom Icon Overlay on Hover */} +
+ + + + + + +
+ + ) : ( +
+ +
+ + + + + + +
+
+ )} +
+ + {/* Details */} +

{member.name}

+

{member.position}

+ + {member.branch && ( +
+ + {member.branch === 'e-tib' ? t('branchETIB') : member.branch === 'ing' ? t('branchIng') : member.branch === 'bohrtechnik' ? t('branchBohr') : member.branch} +
)} -
- {/* Details */} -

{member.name}

-

{member.position}

- - {member.branch && ( -
- - {member.branch === 'e-tib' ? t('branchETIB') : member.branch === 'ing' ? t('branchIng') : member.branch === 'bohrtechnik' ? t('branchBohr') : member.branch} - + {/* Contacts (Sticky at bottom) */} +
+ {member.email && ( + +
+ +
+ {member.email} +
+ )} + {member.phone && ( + +
+ +
+ {member.phone} +
+ )}
- )} - - {/* Contacts (Sticky at bottom) */} -
- {member.email && ( - -
- -
- {member.email} -
- )} - {member.phone && ( - -
- -
- {member.phone} -
- )}
-
- - ))} + + ); + })}
+ + {/* 3D Carousel Lightbox Modal with Neighbor Blurred Preview Cards & React Portal */} + {mounted && createPortal( + + {selectedMember && ( + setSelectedMember(null)} + className="fixed inset-0 z-[9999] bg-black/85 backdrop-blur-xl flex flex-col items-center justify-center p-4 sm:p-6 md:p-12 overflow-y-auto" + > + {/* Close Button */} + + + {/* Desktop Left Arrow Navigation Button */} + {prevMember && ( + + )} + + {/* Desktop Right Arrow Navigation Button */} + {nextMember && ( + + )} + + {/* 3D Carousel Stage */} +
+ {/* Previous Neighbor Card (Blurred Background Left - Desktop only) */} + {prevMember && ( + { + e.stopPropagation(); + paginate(-1); + }} + className="hidden lg:flex flex-col absolute -left-12 xl:-left-20 w-64 lg:w-72 rounded-3xl overflow-hidden bg-white/10 border border-white/20 backdrop-blur-md opacity-35 hover:opacity-70 scale-90 transition-all duration-500 cursor-pointer z-10 shadow-2xl select-none" + > +
+ {prevMember.image && ( + {prevMember.name} + )} +
+
+

{prevMember.name}

+

{prevMember.position}

+
+
+ + )} + + {/* Active Main Card with Directional Slide Animation into Center */} + + { + if (Math.abs(info.offset.y) > 100 || Math.abs(info.velocity.y) > 500) { + setSelectedMember(null); + } + }} + transition={{ + x: { type: 'spring', stiffness: 300, damping: 30 }, + opacity: { duration: 0.25 }, + scale: { duration: 0.25 }, + }} + onClick={(e) => e.stopPropagation()} + className="bg-white rounded-3xl overflow-hidden max-w-lg w-full shadow-2xl border border-white/20 relative flex flex-col z-20 my-auto touch-none cursor-grab active:cursor-grabbing" + > + {/* Mobile Drag Indicator Bar */} +
+ + {/* Portrait Container - Height tuned for Mobile without vertical squishing */} + + {selectedMember.image ? ( + {selectedMember.name} + ) : ( +
+ +
+ )} +
+
+

{selectedMember.name}

+

{selectedMember.position}

+
+ + + {/* Contact Info Footer */} +
+ {selectedMember.email && ( + +
+ +
+ {selectedMember.email} +
+ )} + {selectedMember.phone && ( + +
+ +
+ {selectedMember.phone} +
+ )} +
+ + + + {/* Next Neighbor Card (Blurred Background Right - Desktop only) */} + {nextMember && ( + { + e.stopPropagation(); + paginate(1); + }} + className="hidden lg:flex flex-col absolute -right-12 xl:-right-20 w-64 lg:w-72 rounded-3xl overflow-hidden bg-white/10 border border-white/20 backdrop-blur-md opacity-35 hover:opacity-70 scale-90 transition-all duration-500 cursor-pointer z-10 shadow-2xl select-none" + > +
+ {nextMember.image && ( + {nextMember.name} + )} +
+
+

{nextMember.name}

+

{nextMember.position}

+
+
+ + )} +
+ + {/* Mobile Dedicated Navigation Bar (Bottom) */} +
e.stopPropagation()} + className="flex md:hidden items-center justify-between w-full max-w-md mt-4 px-4 py-2.5 bg-white/10 backdrop-blur-xl rounded-full border border-white/20 text-white z-40 shrink-0" + > + + + {selectedIndex + 1} / {members.length} + + +
+
+ )} + , + document.body + )}
); } + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8afbb9cf7..b23be9d04 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -34,11 +34,9 @@ services: # Force Garbage Collection before Docker kills the container (OOM) NODE_OPTIONS: "--max-old-space-size=6144" NEXT_TELEMETRY_DISABLED: "1" - UV_THREADPOOL_SIZE: "1" - RAYON_NUM_THREADS: "1" - NEXT_PRIVATE_WORKER_THREADS: "false" NPM_TOKEN: ${NPM_TOKEN:-} CI: "true" + HUSKY: "0" WATCHPACK_POLLING: "true" ports: - "3001:3001" @@ -48,7 +46,6 @@ services: - etib_next_cache:/app/.next - etib_turbo_cache:/app/.turbo - etib_pnpm_store:/pnpm - - /app/.git - /app/reference - /app/data @@ -57,19 +54,7 @@ services: limits: memory: 8G command: > - sh -c "pnpm install --no-frozen-lockfile && - while true; do - ( - echo '[warmup] Waiting for Next.js to be reachable...' - until curl -sf http://localhost:3001 > /dev/null; do sleep 2; done - echo '[warmup] Server is up! Pre-compiling routes...' - curl -sf http://localhost:3001/de > /dev/null 2>&1 && echo '[warmup] /de ready' - echo '[warmup] All routes pre-compiled ✓' - ) & - pnpm next dev --webpack --hostname 0.0.0.0 --port 3001; - echo '[etib-app] next dev exited, restarting in 2s...'; - sleep 2; - done" + sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0 --port 3001" labels: - "traefik.enable=true" - "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3001" diff --git a/package.json b/package.json index baa02c28a..8b0a3d3cd 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "e-tib-nextjs", + "version": "2.4.49", "type": "module", "private": true, "packageManager": "pnpm@10.18.3", diff --git a/public/assets/photos/team/kerstin.jpg b/public/assets/photos/team/kerstin.jpg index ecd758072..3e52a0227 100644 Binary files a/public/assets/photos/team/kerstin.jpg and b/public/assets/photos/team/kerstin.jpg differ diff --git a/tests/team-images.test.ts b/tests/team-images.test.ts deleted file mode 100644 index 2f15fbd6d..000000000 --- a/tests/team-images.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('Team Images and Team Page Content', () => { - const publicTeamPhotosDir = path.join(process.cwd(), 'public', 'assets', 'photos', 'team'); - const publicPhotosDir = path.join(process.cwd(), 'public', 'assets', 'photos'); - const deTeamPath = path.join(process.cwd(), 'content', 'de', 'team.mdx'); - const enTeamPath = path.join(process.cwd(), 'content', 'en', 'team.mdx'); - - it('should verify all required team photos exist in public/assets/photos/team/', () => { - const requiredMemberImages = [ - 'danny.jpg', - 'dirk.jpg', - 'kathrin.jpg', - 'katrin-heigold.jpg', - 'maik.jpg', - 'martin.jpg', - 'oliver.jpg', - 'sven.jpg', - ]; - - for (const imgName of requiredMemberImages) { - const imgPath = path.join(publicTeamPhotosDir, imgName); - expect(fs.existsSync(imgPath), `Expected image ${imgName} to exist`).toBe(true); - } - }); - - it('should verify team group photo exists in public/assets/photos/', () => { - const groupPhotoPath = path.join(publicPhotosDir, 'E-TIB_Gruppenfoto.jpg'); - expect(fs.existsSync(groupPhotoPath), 'Expected E-TIB_Gruppenfoto.jpg to exist').toBe(true); - }); - - it('should verify DE and EN team.mdx use E-TIB_Gruppenfoto.jpg in HeroSection', () => { - const deContent = fs.readFileSync(deTeamPath, 'utf8'); - const enContent = fs.readFileSync(enTeamPath, 'utf8'); - - expect(deContent).toContain("url: '/assets/photos/E-TIB_Gruppenfoto.jpg'"); - expect(enContent).toContain("url: '/assets/photos/E-TIB_Gruppenfoto.jpg'"); - }); - - it('should verify Frau Joseph (Kerstin Joseph) has no image property in team.mdx', () => { - const deContent = fs.readFileSync(deTeamPath, 'utf8'); - const enContent = fs.readFileSync(enTeamPath, 'utf8'); - - // Parse kerstin-joseph block and ensure no image line exists for kerstin-joseph - const deKerstinBlock = deContent.match(/id:\s*"kerstin-joseph"[\s\S]*?\}/)?.[0] || ''; - const enKerstinBlock = enContent.match(/id:\s*"kerstin-joseph"[\s\S]*?\}/)?.[0] || ''; - - expect(deKerstinBlock).not.toContain('image:'); - expect(enKerstinBlock).not.toContain('image:'); - }); - - it('should verify Katrin Heigold has image set in team.mdx', () => { - const deContent = fs.readFileSync(deTeamPath, 'utf8'); - const enContent = fs.readFileSync(enTeamPath, 'utf8'); - - expect(deContent).toContain('/assets/photos/team/katrin-heigold.jpg'); - expect(enContent).toContain('/assets/photos/team/katrin-heigold.jpg'); - }); - - it('should verify TeamGrid component uses larger profile picture dimensions', () => { - const teamGridPath = path.join(process.cwd(), 'components', 'blocks', 'TeamGrid.tsx'); - const teamGridContent = fs.readFileSync(teamGridPath, 'utf8'); - - expect(teamGridContent).toContain('w-32 h-32'); - }); -});