Compare commits

...

10 Commits

Author SHA1 Message Date
205880b41a ci: branch deploys 2026-02-10 13:50:57 +01:00
84555d11ed fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Successful in 2m24s
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m23s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 1m42s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-10 13:45:13 +01:00
1dce82b74e fix: synchronize next.js version via pnpm overrides
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Has been cancelled
2026-02-10 13:44:11 +01:00
3be4939ff5 fix: optimize ci pipeline and dynamic registry auth
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 2m30s
2026-02-10 13:39:41 +01:00
e054bb3490 fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 7m28s
2026-02-10 13:31:55 +01:00
75234095b7 fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 1m21s
2026-02-10 13:23:51 +01:00
4bdd4efdc3 fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 47s
2026-02-10 13:09:55 +01:00
47ca58a85a fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 43s
2026-02-10 12:21:48 +01:00
d5d39a218a fix: deploy issues
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 17s
2026-02-10 11:59:43 +01:00
ae7a45a911 fix: deploy issues
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 34s
2026-02-10 11:52:10 +01:00
14 changed files with 22942 additions and 128 deletions

1
.env
View File

@@ -23,6 +23,7 @@ DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_DB_NAME=directus DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=directus
# Local Development # Local Development
PROJECT_NAME=klz-cables PROJECT_NAME=klz-cables
GATEKEEPER_BYPASS_ENABLED=true GATEKEEPER_BYPASS_ENABLED=true

View File

@@ -1,9 +1,6 @@
name: CI - Lint, Typecheck & Test name: CI - Lint, Typecheck & Test
on: on:
push:
branches-ignore:
- main
pull_request: pull_request:
jobs: jobs:
@@ -17,10 +14,23 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'npm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: 🔐 Configure Private Registry
run: |
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
echo "@mintel:registry=https://$REGISTRY" > .npmrc
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install
env:
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
- name: 🔍 Lint - name: 🔍 Lint
run: npm run lint run: npm run lint

View File

@@ -3,18 +3,18 @@ name: Build & Deploy KLZ Cables
on: on:
push: push:
branches: branches:
- main - '**'
tags: tags:
- 'v*' - 'v*'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
skip_long_checks: skip_long_checks:
description: 'Skip tests? (true/false)' description: 'Skip tests? (true/false)'
required: false required: false
default: 'false' default: 'false'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }} group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || (github.ref_name == 'main' && 'testing' || github.ref_name)) }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
@@ -113,6 +113,20 @@ jobs:
GOTIFY_TITLE="❓ Unbekannter Tag" GOTIFY_TITLE="❓ Unbekannter Tag"
GOTIFY_PRIORITY=3 GOTIFY_PRIORITY=3
fi fi
elif [[ "${{ github.ref_type }}" == "branch" ]]; then
TARGET="branch"
# Slugify branch name: lowercase, replace non-alphanumeric with -, remove leading/trailing -
SLUG=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}"
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
NEXT_PUBLIC_BASE_URL="https://${SLUG}.branch.mintel.me"
DIRECTUS_URL="https://cms.${SLUG}.branch.mintel.me"
DIRECTUS_HOST="cms.${SLUG}.branch.mintel.me"
PROJECT_NAME="klz-cables-br-${SLUG}"
IS_PROD="false"
GOTIFY_TITLE="🌿 Branch-Deploy ($TAG)"
GOTIFY_PRIORITY=4
else else
TARGET="skip" TARGET="skip"
fi fi
@@ -166,17 +180,31 @@ jobs:
with: with:
node-version: 20 node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: 🔐 Configure Private Registry
run: |
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
echo "@mintel:registry=https://$REGISTRY" > .npmrc
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: pnpm install
env:
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
- name: 🧪 Run Checks in Parallel - name: 🧪 Run Checks in Parallel
if: github.event.inputs.skip_long_checks != 'true' if: github.event.inputs.skip_long_checks != 'true'
run: | run: |
npm run lint & pnpm lint &
LINT_PID=$! LINT_PID=$!
npm run typecheck & pnpm typecheck &
TYPE_PID=$! TYPE_PID=$!
npm run test & pnpm test &
TEST_PID=$! TEST_PID=$!
# Wait for all and fail if any fail # Wait for all and fail if any fail
@@ -220,6 +248,8 @@ jobs:
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \ --build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \ --build-arg NEXT_PUBLIC_TARGET="$TARGET" \
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \ --build-arg DIRECTUS_URL="$DIRECTUS_URL" \
--build-arg REGISTRY_HOST="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" \
--build-arg NPM_TOKEN="${{ secrets.REGISTRY_PASS }}" \
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \ -t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \ --cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \ --cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
@@ -409,8 +439,20 @@ jobs:
with: with:
node-version: 20 node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: 🔐 Configure Private Registry
run: |
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
echo "@mintel:registry=https://$REGISTRY" > .npmrc
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: pnpm install
- name: 🔍 Install Chromium (Native & ARM64) - name: 🔍 Install Chromium (Native & ARM64)
run: | run: |
@@ -477,7 +519,7 @@ jobs:
PAGESPEED_LIMIT: 8 PAGESPEED_LIMIT: 8
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
CHROME_PATH: /usr/bin/chromium CHROME_PATH: /usr/bin/chromium
run: npm run pagespeed:test run: pnpm pagespeed:test

View File

@@ -2,13 +2,22 @@ FROM node:20-alpine AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat curl RUN apk add --no-cache libc6-compat curl
WORKDIR /app WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10
# Install dependencies based on the preferred package manager # Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./ COPY package.json pnpm-lock.yaml* ./
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps ARG REGISTRY_HOST
ARG NPM_TOKEN
RUN if [ -n "$NPM_TOKEN" ]; then \
REGISTRY="${REGISTRY_HOST:-npm.infra.mintel.me}" && \
echo "@mintel:registry=https://$REGISTRY" > .npmrc && \
echo "//$REGISTRY/:_authToken=$NPM_TOKEN" >> .npmrc; \
fi
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
# Rebuild the source code only when needed # Rebuild the source code only when needed
@@ -35,7 +44,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
# Validate environment variables during build # Validate environment variables during build
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
RUN --mount=type=cache,target=/app/.next/cache npm run build RUN --mount=type=cache,target=/app/.next/cache pnpm run build
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM base AS runner FROM base AS runner

View File

@@ -14,10 +14,10 @@ export default function Header() {
const pathname = usePathname(); const pathname = usePathname();
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Extract locale from pathname // Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en'; const currentLocale = pathname.split('/')[1] || 'en';
// Check if homepage // Check if homepage
const isHomePage = pathname === `/${currentLocale}` || pathname === '/'; const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
@@ -32,8 +32,10 @@ export default function Header() {
// Close mobile menu on route change // Close mobile menu on route change
useEffect(() => { useEffect(() => {
setIsMobileMenuOpen(false); if (isMobileMenuOpen) {
}, [pathname]); setIsMobileMenuOpen(false);
}
}, [pathname, isMobileMenuOpen]);
// Prevent scroll when mobile menu is open // Prevent scroll when mobile menu is open
useEffect(() => { useEffect(() => {
@@ -43,7 +45,7 @@ export default function Header() {
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
} }
}, [isMobileMenuOpen]); }, [isMobileMenuOpen]);
// Function to get path for a different locale // Function to get path for a different locale
const getPathForLocale = (newLocale: string) => { const getPathForLocale = (newLocale: string) => {
const segments = pathname.split('/'); const segments = pathname.split('/');
@@ -59,15 +61,15 @@ export default function Header() {
]; ];
const headerClass = cn( const headerClass = cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu", 'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
{ {
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen, 'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
"bg-primary py-3 md:py-4 shadow-2xl": !isHomePage || isScrolled || isMobileMenuOpen, 'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
} },
); );
const textColorClass = "text-white"; const textColorClass = 'text-white';
const logoSrc = "/logo-white.svg"; const logoSrc = '/logo-white.svg';
return ( return (
<> <>
@@ -75,14 +77,14 @@ export default function Header() {
className={headerClass} className={headerClass}
initial={{ y: -100, opacity: 0 }} initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }} transition={{ duration: 0.8, ease: 'easeOut' }}
> >
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between"> <div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
<motion.div <motion.div
className="flex-shrink-0 group touch-target" className="flex-shrink-0 group touch-target"
initial={{ scale: 0.8, opacity: 0 }} initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }} transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
> >
<Link href={`/${currentLocale}`}> <Link href={`/${currentLocale}`}>
<Image <Image
@@ -105,25 +107,19 @@ export default function Header() {
visible: { visible: {
transition: { transition: {
staggerChildren: 0.08, staggerChildren: 0.08,
delayChildren: 0.3 delayChildren: 0.3,
} },
} },
}} }}
> >
<motion.nav <motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
className="hidden lg:flex items-center space-x-10"
variants={navVariants}
>
{menuItems.map((item, idx) => ( {menuItems.map((item, idx) => (
<motion.div <motion.div key={item.href} variants={navLinkVariants}>
key={item.href}
variants={navLinkVariants}
>
<Link <Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
className={cn( className={cn(
textColorClass, textColorClass,
"hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5" 'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)} )}
> >
{item.label} {item.label}
@@ -134,7 +130,7 @@ export default function Header() {
</motion.nav> </motion.nav>
<motion.div <motion.div
className={cn("hidden lg:flex items-center space-x-8", textColorClass)} className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
variants={headerRightVariants} variants={headerRightVariants}
> >
<motion.div <motion.div
@@ -174,11 +170,11 @@ export default function Header() {
</Link> </Link>
</motion.div> </motion.div>
</motion.div> </motion.div>
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: "spring", stiffness: 400, delay: 0.7 }} transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
> >
<Button <Button
href={`/${currentLocale}/contact`} href={`/${currentLocale}/contact`}
@@ -193,11 +189,20 @@ export default function Header() {
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<motion.button <motion.button
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50", textColorClass)} className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
)}
aria-label={t('toggleMenu')} aria-label={t('toggleMenu')}
initial={{ scale: 0.8, opacity: 0, rotate: -180 }} initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }} animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ duration: 0.6, type: "spring", stiffness: 300, damping: 20, delay: 0.5 }} transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
> >
<motion.svg <motion.svg
@@ -236,21 +241,25 @@ export default function Header() {
</div> </div>
{/* Mobile Menu Overlay */} {/* Mobile Menu Overlay */}
<div className={cn( <div
"fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col", className={cn(
isMobileMenuOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full pointer-events-none" 'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
)}> isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
>
<motion.div <motion.div
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8" className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed" initial="closed"
animate={isMobileMenuOpen ? "open" : "closed"} animate={isMobileMenuOpen ? 'open' : 'closed'}
variants={{ variants={{
open: { open: {
transition: { transition: {
staggerChildren: 0.1, staggerChildren: 0.1,
delayChildren: 0.2 delayChildren: 0.2,
} },
} },
}} }}
> >
{menuItems.map((item, idx) => ( {menuItems.map((item, idx) => (
@@ -264,10 +273,10 @@ export default function Header() {
scale: 1, scale: 1,
transition: { transition: {
duration: 0.6, duration: 0.6,
ease: "easeOut", ease: 'easeOut',
delay: idx * 0.08 delay: idx * 0.08,
} },
} },
}} }}
> >
<Link <Link
@@ -278,7 +287,7 @@ export default function Header() {
</Link> </Link>
</motion.div> </motion.div>
))} ))}
<motion.div <motion.div
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8" className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@@ -322,11 +331,11 @@ export default function Header() {
</Link> </Link>
</motion.div> </motion.div>
</motion.div> </motion.div>
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }} initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }} animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 20, delay: 1.2 }} transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
> >
<Button <Button
href={`/${currentLocale}/contact`} href={`/${currentLocale}/contact`}
@@ -338,23 +347,23 @@ export default function Header() {
</Button> </Button>
</motion.div> </motion.div>
</motion.div> </motion.div>
{/* Bottom Branding */} {/* Bottom Branding */}
<motion.div
className="p-12 flex justify-center opacity-20"
initial={{ opacity: 0, scale: 0.8 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<motion.div <motion.div
initial={{ scale: 0.5 }} className="p-12 flex justify-center opacity-20"
animate={{ scale: 1 }} initial={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 300, delay: 1.5 }} animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
> >
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized /> <motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div>
</motion.div> </motion.div>
</motion.div> </motion.div>
</motion.div>
</div> </div>
</motion.header> </motion.header>
</> </>
@@ -367,9 +376,9 @@ const navVariants = {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.06, staggerChildren: 0.06,
delayChildren: 0.1 delayChildren: 0.1,
} },
} },
} as const; } as const;
const navLinkVariants = { const navLinkVariants = {
@@ -380,9 +389,9 @@ const navLinkVariants = {
scale: 1, scale: 1,
transition: { transition: {
duration: 0.5, duration: 0.5,
ease: "easeOut" ease: 'easeOut',
} },
} },
} as const; } as const;
const headerRightVariants = { const headerRightVariants = {
@@ -390,6 +399,6 @@ const headerRightVariants = {
visible: { visible: {
opacity: 1, opacity: 1,
x: 0, x: 0,
transition: { duration: 0.6, ease: "easeOut" } transition: { duration: 0.6, ease: 'easeOut' },
} },
} as const; } as const;

View File

@@ -25,15 +25,18 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
return () => setMounted(false); return () => setMounted(false);
}, []); }, []);
const updateUrl = useCallback((index: number | null) => { const updateUrl = useCallback(
const params = new URLSearchParams(searchParams.toString()); (index: number | null) => {
if (index !== null) { const params = new URLSearchParams(searchParams.toString());
params.set('photo', index.toString()); if (index !== null) {
} else { params.set('photo', index.toString());
params.delete('photo'); } else {
} params.delete('photo');
router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }
}, [pathname, router, searchParams]); router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
const prevImage = useCallback(() => { const prevImage = useCallback(() => {
setCurrentIndex((prev) => { setCurrentIndex((prev) => {
@@ -61,6 +64,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
} }
}, [searchParams, images.length]); }, [searchParams, images.length]);
const handleClose = useCallback(() => {
updateUrl(null);
onClose();
}, [updateUrl, onClose]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
updateUrl(currentIndex); updateUrl(currentIndex);
@@ -79,22 +87,17 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
// Lock scroll // Lock scroll
const originalStyle = window.getComputedStyle(document.body).overflow; const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => { return () => {
document.body.style.overflow = originalStyle; document.body.style.overflow = originalStyle;
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
}; };
}, [isOpen, prevImage, nextImage]); }, [isOpen, prevImage, nextImage, handleClose]);
if (!mounted) return null; if (!mounted) return null;
const handleClose = () => {
updateUrl(null);
onClose();
};
return createPortal( return createPortal(
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
@@ -121,7 +124,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<span className="text-3xl font-extralight leading-none mb-1">×</span> <span className="text-3xl font-extralight leading-none mb-1">×</span>
</div> </div>
</motion.button> </motion.button>
<motion.button <motion.button
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
@@ -131,9 +134,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10" className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Previous image" aria-label="Previous image"
> >
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500"></span> <span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
</span>
</motion.button> </motion.button>
<motion.button <motion.button
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
@@ -143,10 +148,12 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10" className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image" aria-label="Next image"
> >
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500"></span> <span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</motion.button> </motion.button>
<motion.div <motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }} initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }} exit={{ opacity: 0, y: 20, scale: 0.98 }}
@@ -173,15 +180,15 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
/> />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */} {/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" /> <div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */} {/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" /> <div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div> </div>
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
@@ -199,6 +206,6 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
</div> </div>
)} )}
</AnimatePresence>, </AnimatePresence>,
document.body document.body,
); );
} }

View File

@@ -27,11 +27,11 @@ export default function GallerySection() {
if (photoParam !== null) { if (photoParam !== null) {
const index = parseInt(photoParam, 10); const index = parseInt(photoParam, 10);
if (!isNaN(index) && index >= 0 && index < images.length) { if (!isNaN(index) && index >= 0 && index < images.length) {
setLightboxIndex(index); if (lightboxIndex !== index) setLightboxIndex(index);
setLightboxOpen(true); if (!lightboxOpen) setLightboxOpen(true);
} }
} }
}, [searchParams, images.length]); }, [searchParams, images.length, lightboxIndex, lightboxOpen]);
return ( return (
<Section className="bg-white text-white py-32"> <Section className="bg-white text-white py-32">
@@ -39,7 +39,7 @@ export default function GallerySection() {
<Heading level={2} subtitle={t('subtitle')} align="center"> <Heading level={2} subtitle={t('subtitle')} align="center">
{t('title')} {t('title')}
</Heading> </Heading>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{images.map((src, idx) => ( {images.map((src, idx) => (
<button <button

View File

@@ -20,8 +20,11 @@ export default [
"*.mjs", "*.mjs",
"scripts/**", "scripts/**",
"tests/**", "tests/**",
"next-env.d.ts" "next-env.d.ts",
"reference/**",
"data/**"
], ],
}, },
...baseConfig, ...baseConfig,
...nextConfig.map((config) => ({ ...nextConfig.map((config) => ({
@@ -39,7 +42,9 @@ export default [
"@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-require-imports": "off",
"prefer-const": "warn", "prefer-const": "warn",
"react/no-unescaped-entities": "off", "react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn" "@next/next/no-img-element": "warn",
"react-hooks/set-state-in-effect": "warn"
} }
})), })),
]; ];

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import './.next/types/routes.d.ts'; import './.next/dev/types/routes.d.ts';
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

22727
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -98,5 +98,10 @@
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"", "pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"prepare": "husky" "prepare": "husky"
}, },
"version": "1.0.0" "version": "1.0.0",
"pnpm": {
"overrides": {
"next": "16.1.6"
}
}
} }

3
pnpm-lock.yaml generated
View File

@@ -6,7 +6,6 @@ settings:
overrides: overrides:
next: 16.1.6 next: 16.1.6
'@sentry/nextjs': 10.38.0
importers: importers:
@@ -28,7 +27,7 @@ importers:
specifier: ^4.3.2 specifier: ^4.3.2
version: 4.3.2(react@19.2.4) version: 4.3.2(react@19.2.4)
'@sentry/nextjs': '@sentry/nextjs':
specifier: 10.38.0 specifier: ^10.38.0
version: 10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.0) version: 10.38.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.0)
'@swc/helpers': '@swc/helpers':
specifier: ^0.5.18 specifier: ^0.5.18

View File

@@ -34,5 +34,5 @@
"tests/**/*.test.ts", "tests/**/*.test.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules", "scripts"] "exclude": ["node_modules", "scripts", "reference", "data"]
} }

File diff suppressed because one or more lines are too long