Compare commits
10 Commits
v1.0.0
...
feature/fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 205880b41a | |||
| 84555d11ed | |||
| 1dce82b74e | |||
| 3be4939ff5 | |||
| e054bb3490 | |||
| 75234095b7 | |||
| 4bdd4efdc3 | |||
| 47ca58a85a | |||
| d5d39a218a | |||
| ae7a45a911 |
1
.env
1
.env
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
17
Dockerfile
17
Dockerfile
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
2
next-env.d.ts
vendored
@@ -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
22727
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user