Prod ready, heavily optimized images with automatic scripts
105
README.md
@@ -1,46 +1,83 @@
|
|||||||
# 🎮 TIPILAN
|
# TipiLAN
|
||||||
|
|
||||||
**TIPILAN** is the official web platform for the TipiLAN LAN event — built using [Next.js](https://nextjs.org) and powered by the lightning-fast [Bun](https://bun.sh) runtime.
|
Official website for **TipiLAN** — Estonia's largest student-organised LAN event.
|
||||||
|
|
||||||
> ⚠️ This is a work in progress! Contributions welcome.
|
> Work in progress. Event date: **September 11, 2026**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Features
|
## Tech Stack
|
||||||
|
|
||||||
- Event information & schedule
|
- [Next.js 16](https://nextjs.org/) — App Router, SSG, Turbopack
|
||||||
- Stream
|
- [React 19](https://react.dev/)
|
||||||
- Responsive dark-themed UI
|
- [TypeScript 5](https://www.typescriptlang.org/)
|
||||||
- Built with Next.js, styled with Tailwind (if applicable), powered by Bun
|
- [Tailwind CSS 4](https://tailwindcss.com/)
|
||||||
|
- [next-intl](https://next-intl.dev/) — ET / EN localisation
|
||||||
|
- [react-icons](https://react-icons.github.io/react-icons/)
|
||||||
|
|
||||||
|
Requires **Node.js ≥ 22**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Tech Stack
|
## Quickstart
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/)
|
|
||||||
- [Bun](https://bun.sh/)
|
|
||||||
- [TypeScript](https://www.typescriptlang.org/)
|
|
||||||
- [React](https://reactjs.org/)
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/) *(if used)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Setup Instructions
|
|
||||||
|
|
||||||
📖 See [`docs/SETUP.md`](./docs/SETUP.md) for a full setup guide on:
|
|
||||||
|
|
||||||
- ✅ Windows (native)
|
|
||||||
- 🐧 Windows with WSL
|
|
||||||
- 🐧 Linux (Ubuntu, Arch, Fedora, etc.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Scripts
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun dev # Run development server
|
npm install
|
||||||
bun run build # Build for production
|
npm run dev
|
||||||
bun start # Start production server
|
```
|
||||||
|
|
||||||
bun test # Run tests (if configured)
|
Open [http://localhost:3000](http://localhost:3000) — redirects to `/et` by default.
|
||||||
bun run lint # Run linter (if configured)
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Dev server (Turbopack, localhost:3000)
|
||||||
|
npm run build # Production build (outputs SSG for /et and /en)
|
||||||
|
npm start # Serve production build
|
||||||
|
npm run lint:fix # ESLint with auto-fix
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
app/[locale]/ # Locale-based pages (et, en)
|
||||||
|
components/teaser/ # HeroSection, CarouselSection, EndSection, Footer
|
||||||
|
components/ # LanguageSwitcher, TeaserPage
|
||||||
|
i18n/ # next-intl routing + request config
|
||||||
|
lib/ # blurPlaceholders.ts (auto-generated)
|
||||||
|
proxy.ts # next-intl routing middleware
|
||||||
|
|
||||||
|
translations/ # et.json, en.json
|
||||||
|
public/images/
|
||||||
|
backgrounds/ # Full-viewport background WebPs
|
||||||
|
heros/ # Character/hero image WebPs
|
||||||
|
flags/ # Language switcher SVGs
|
||||||
|
original/ # Original PNG sources (not served)
|
||||||
|
scripts/ # Image processing utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Utilities
|
||||||
|
|
||||||
|
When adding or replacing images, re-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Convert new PNGs → WebP (quality 85)
|
||||||
|
node scripts/convert-images.mjs
|
||||||
|
|
||||||
|
# Regenerate blur placeholders for all images
|
||||||
|
node scripts/gen-blur-placeholders.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Localisation
|
||||||
|
|
||||||
|
Translation files live in `translations/et.json` and `translations/en.json`.
|
||||||
|
Routing is handled by `src/proxy.ts` — `/` redirects to `/et` (default locale).
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import createNextIntlPlugin from "next-intl/plugin";
|
|||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {};
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
qualities: [75, 90],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
BIN
public/images/backgrounds/compete_teaser.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/images/backgrounds/explore_teaser.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/images/backgrounds/hero_teaser.webp
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/images/backgrounds/play_teaser.webp
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
public/images/backgrounds/sponsors_teaser.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/images/backgrounds/tickets_teaser.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 574 B After Width: | Height: | Size: 574 B |
BIN
public/images/heros/compete_hero.webp
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/images/heros/explore_hero.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/images/heros/play_hero.webp
Normal file
|
After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1015 KiB After Width: | Height: | Size: 1015 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
27
scripts/convert-images.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { readdir, unlink } from 'fs/promises';
|
||||||
|
import { join, extname, basename } from 'path';
|
||||||
|
|
||||||
|
const INPUT_DIR = 'public/images';
|
||||||
|
const QUALITY = 85;
|
||||||
|
|
||||||
|
const files = await readdir(INPUT_DIR);
|
||||||
|
const pngs = files.filter(f => extname(f).toLowerCase() === '.png');
|
||||||
|
|
||||||
|
console.log(`Converting ${pngs.length} PNG(s) to WebP at quality ${QUALITY}...\n`);
|
||||||
|
|
||||||
|
for (const file of pngs) {
|
||||||
|
const input = join(INPUT_DIR, file);
|
||||||
|
const output = join(INPUT_DIR, basename(file, '.png') + '.webp');
|
||||||
|
|
||||||
|
const { width, height, size: inSize } = await sharp(input).metadata();
|
||||||
|
await sharp(input).webp({ quality: QUALITY }).toFile(output);
|
||||||
|
|
||||||
|
const { size: outSize } = await sharp(output).metadata();
|
||||||
|
const saved = (((inSize - outSize) / inSize) * 100).toFixed(1);
|
||||||
|
|
||||||
|
console.log(`${file} (${width}x${height})`);
|
||||||
|
console.log(` ${(inSize/1024).toFixed(0)} KB → ${(outSize/1024).toFixed(0)} KB (${saved}% smaller)\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
43
scripts/gen-blur-placeholders.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { readdir, writeFile } from 'fs/promises';
|
||||||
|
import { join, extname, basename } from 'path';
|
||||||
|
|
||||||
|
const INPUT_DIR = 'public/images';
|
||||||
|
const OUTPUT_FILE = 'src/lib/blurPlaceholders.ts';
|
||||||
|
const THUMB_WIDTH = 20;
|
||||||
|
|
||||||
|
async function processDir(dir, prefix = '') {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === 'original') continue;
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const sub = await processDir(fullPath, prefix + entry.name + '/');
|
||||||
|
Object.assign(result, sub);
|
||||||
|
} else if (['.webp', '.png', '.jpg'].includes(extname(entry.name).toLowerCase())) {
|
||||||
|
const buffer = await sharp(fullPath)
|
||||||
|
.resize(THUMB_WIDTH)
|
||||||
|
.webp({ quality: 30 })
|
||||||
|
.toBuffer();
|
||||||
|
const b64 = `data:image/webp;base64,${buffer.toString('base64')}`;
|
||||||
|
const key = prefix + basename(entry.name, extname(entry.name));
|
||||||
|
result[key] = b64;
|
||||||
|
console.log(` ${key} → ${b64.length} chars`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generating blur placeholders...\n');
|
||||||
|
const placeholders = await processDir(INPUT_DIR);
|
||||||
|
|
||||||
|
const lines = Object.entries(placeholders)
|
||||||
|
.map(([k, v]) => ` "${k}": "${v}",`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const output = `// Auto-generated by scripts/gen-blur-placeholders.mjs — do not edit manually\nexport const BLUR_PLACEHOLDERS: Record<string, string> = {\n${lines}\n};\n`;
|
||||||
|
|
||||||
|
await writeFile(OUTPUT_FILE, output, 'utf8');
|
||||||
|
console.log(`\nWrote ${Object.keys(placeholders).length} placeholders to ${OUTPUT_FILE}`);
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { setRequestLocale, getMessages } from "next-intl/server";
|
import { setRequestLocale, getMessages } from "next-intl/server";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function LanguageSwitcher() {
|
|||||||
aria-label="Switch language"
|
aria-label="Switch language"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={`/images/flag-${locale === "et" ? "en" : "et"}.svg`}
|
src={`/images/flags/flag-${locale === "et" ? "en" : "et"}.svg`}
|
||||||
alt={locale === "et" ? "Switch to English" : "Vaheta eesti keelele"}
|
alt={locale === "et" ? "Switch to English" : "Vaheta eesti keelele"}
|
||||||
width={40}
|
width={40}
|
||||||
height={30}
|
height={30}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { useEffect, useState } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { SLIDES, type CarouselSlide } from "./constants";
|
import { SLIDES, type CarouselSlide } from "./constants";
|
||||||
|
import { BLUR_PLACEHOLDERS } from "@/lib/blurPlaceholders";
|
||||||
|
|
||||||
|
function blurKey(src: string) {
|
||||||
|
return src.replace('/images/', '').replace(/\.\w+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function CarouselSlideComponent({
|
function CarouselSlideComponent({
|
||||||
slide,
|
slide,
|
||||||
@@ -23,13 +28,16 @@ function CarouselSlideComponent({
|
|||||||
src={slide.bgImage}
|
src={slide.bgImage}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
unoptimized
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={BLUR_PLACEHOLDERS[blurKey(slide.bgImage)]}
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
className="object-cover pointer-events-none"
|
className="object-cover pointer-events-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content — top padding accounts for the floating heading */}
|
{/* Content — top padding accounts for the floating heading */}
|
||||||
<div
|
<div
|
||||||
className={`relative z-[1] flex h-full gap-8 xl:gap-16 pt-[120px] px-8 xl:px-16 ${isLeft ? "items-end" : "items-end flex-row-reverse"
|
className={`relative z-1 flex h-full gap-8 xl:gap-16 pt-[120px] px-8 xl:px-16 ${isLeft ? "items-end" : "items-end flex-row-reverse"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Text content */}
|
{/* Text content */}
|
||||||
@@ -51,11 +59,15 @@ function CarouselSlideComponent({
|
|||||||
transform: isActive ? "translateY(0)" : "translateY(110%)",
|
transform: isActive ? "translateY(0)" : "translateY(110%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<Image
|
||||||
<img
|
|
||||||
src={slide.heroImage}
|
src={slide.heroImage}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
fill
|
||||||
|
unoptimized
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={BLUR_PLACEHOLDERS[blurKey(slide.heroImage)]}
|
||||||
|
sizes="(min-width: 1280px) 40vw, 35vw"
|
||||||
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,15 +114,18 @@ export default function CarouselSection({ sectionRef }: CarouselSectionProps) {
|
|||||||
{/* Mobile: stacked slides, no hero images, no transitions */}
|
{/* Mobile: stacked slides, no hero images, no transitions */}
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
{SLIDES.map((slide, i) => (
|
{SLIDES.map((slide, i) => (
|
||||||
<div key={i} className="relative w-full overflow-hidden border-t-4 border-primary-50">
|
<div key={i} className="relative w-full h-[60vw] min-h-[320px] overflow-hidden border-t-4 border-primary-50">
|
||||||
<Image
|
<Image
|
||||||
src={slide.bgImage}
|
src={slide.bgImage}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
unoptimized
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={BLUR_PLACEHOLDERS[blurKey(slide.bgImage)]}
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
className="object-cover pointer-events-none"
|
className="object-cover pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<div className="relative z-[1] flex flex-col gap-4 justify-end h-full p-6 pb-12">
|
<div className="relative z-1 flex flex-col gap-4 justify-end h-full p-6 pb-12">
|
||||||
<h3 className="text-h1 text-text-light text-shadow-teaser">{t(slide.titleKey)}</h3>
|
<h3 className="text-h1 text-text-light text-shadow-teaser">{t(slide.titleKey)}</h3>
|
||||||
<p className="text-p-lg text-text-light max-w-[500px]">{t(slide.descKey)}</p>
|
<p className="text-p-lg text-text-light max-w-[500px]">{t(slide.descKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCountUp } from "@/hooks/useCountUp";
|
import { useCountUp } from "@/hooks/useCountUp";
|
||||||
|
import { BLUR_PLACEHOLDERS } from "@/lib/blurPlaceholders";
|
||||||
|
|
||||||
function formatK(n: number): string {
|
function formatK(n: number): string {
|
||||||
if (n < 1000) return String(n);
|
if (n < 1000) return String(n);
|
||||||
@@ -50,13 +51,16 @@ export default function EndSection() {
|
|||||||
{/* Tickets side */}
|
{/* Tickets side */}
|
||||||
<div className="relative w-1/2 overflow-hidden min-h-dvh">
|
<div className="relative w-1/2 overflow-hidden min-h-dvh">
|
||||||
<Image
|
<Image
|
||||||
src="/images/tickets_teaser.png"
|
src="/images/backgrounds/tickets_teaser.webp"
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
unoptimized
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={BLUR_PLACEHOLDERS["backgrounds/tickets_teaser"]}
|
||||||
sizes="(min-width: 1280px) 50vw, 100vw"
|
sizes="(min-width: 1280px) 50vw, 100vw"
|
||||||
className="object-cover pointer-events-none"
|
className="object-cover pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<div className="relative z-[1] flex flex-col items-center justify-center h-full gap-8 xl:gap-16 2xl:gap-32 p-6 lg:p-12 xl:p-16">
|
<div className="relative z-1 flex flex-col items-center justify-center h-full gap-8 xl:gap-16 2xl:gap-32 p-6 lg:p-12 xl:p-16">
|
||||||
{/* Ticket stats */}
|
{/* Ticket stats */}
|
||||||
<div className="flex gap-6 xl:gap-12 text-center text-text-light">
|
<div className="flex gap-6 xl:gap-12 text-center text-text-light">
|
||||||
<CountUpStat end={0} suffix="€" label={t("teaser.tickets.earlyVisitor")} />
|
<CountUpStat end={0} suffix="€" label={t("teaser.tickets.earlyVisitor")} />
|
||||||
@@ -80,13 +84,16 @@ export default function EndSection() {
|
|||||||
{/* Sponsors side */}
|
{/* Sponsors side */}
|
||||||
<div className="relative w-1/2 overflow-hidden min-h-dvh">
|
<div className="relative w-1/2 overflow-hidden min-h-dvh">
|
||||||
<Image
|
<Image
|
||||||
src="/images/sponsors_teaser.png"
|
src="/images/backgrounds/sponsors_teaser.webp"
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
unoptimized
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={BLUR_PLACEHOLDERS["backgrounds/sponsors_teaser"]}
|
||||||
sizes="(min-width: 1280px) 50vw, 100vw"
|
sizes="(min-width: 1280px) 50vw, 100vw"
|
||||||
className="object-cover pointer-events-none"
|
className="object-cover pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<div className="relative z-[1] flex flex-col items-center justify-center h-full gap-8 xl:gap-16 2xl:gap-32 p-6 lg:p-12 xl:p-16">
|
<div className="relative z-1 flex flex-col items-center justify-center h-full gap-8 xl:gap-16 2xl:gap-32 p-6 lg:p-12 xl:p-16">
|
||||||
{/* Sponsor stats */}
|
{/* Sponsor stats */}
|
||||||
<div className="flex gap-6 xl:gap-12 text-center text-text-light">
|
<div className="flex gap-6 xl:gap-12 text-center text-text-light">
|
||||||
<CountUpStat end={900} suffix="+" label={t("teaser.sponsors.visitors")} />
|
<CountUpStat end={900} suffix="+" label={t("teaser.sponsors.visitors")} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function Footer() {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-bg-dark w-full border-t-4 border-primary-50">
|
<footer className="bg-bg-dark w-full border-t-4 border-primary-50 lg:border-t-0">
|
||||||
{/* Mobile: only social icons */}
|
{/* Mobile: only social icons */}
|
||||||
<div className="flex lg:hidden items-center justify-center gap-6 py-8 px-6 flex-wrap">
|
<div className="flex lg:hidden items-center justify-center gap-6 py-8 px-6 flex-wrap">
|
||||||
{SOCIAL_LINKS.map(({ icon: Icon, href, label }) => (
|
{SOCIAL_LINKS.map(({ icon: Icon, href, label }) => (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import { useCountdown } from "@/hooks/useCountdown";
|
import { useCountdown } from "@/hooks/useCountdown";
|
||||||
import { EVENT_DATE } from "./constants";
|
import { EVENT_DATE } from "./constants";
|
||||||
|
import { BLUR_PLACEHOLDERS } from "@/lib/blurPlaceholders";
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
onScrollDown: () => void;
|
onScrollDown: () => void;
|
||||||
@@ -19,9 +20,12 @@ export default function HeroSection({ onScrollDown }: HeroSectionProps) {
|
|||||||
<section className="relative h-dvh w-full overflow-hidden">
|
<section className="relative h-dvh w-full overflow-hidden">
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<Image
|
<Image
|
||||||
src="/images/hero_teaser.png"
|
src="/images/backgrounds/hero_teaser.webp"
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
|
unoptimized
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={BLUR_PLACEHOLDERS["backgrounds/hero_teaser"]}
|
||||||
className="object-cover pointer-events-none"
|
className="object-cover pointer-events-none"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
|
|||||||
@@ -13,22 +13,22 @@ export const SLIDES: CarouselSlide[] = [
|
|||||||
{
|
{
|
||||||
titleKey: "teaser.compete.title",
|
titleKey: "teaser.compete.title",
|
||||||
descKey: "teaser.compete.description",
|
descKey: "teaser.compete.description",
|
||||||
bgImage: "/images/compete_teaser.png",
|
bgImage: "/images/backgrounds/compete_teaser.webp",
|
||||||
heroImage: "/images/compete_hero.png",
|
heroImage: "/images/heros/compete_hero.webp",
|
||||||
layout: "left",
|
layout: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: "teaser.play.title",
|
titleKey: "teaser.play.title",
|
||||||
descKey: "teaser.play.description",
|
descKey: "teaser.play.description",
|
||||||
bgImage: "/images/play_teaser.png",
|
bgImage: "/images/backgrounds/play_teaser.webp",
|
||||||
heroImage: "/images/play_hero.png",
|
heroImage: "/images/heros/play_hero.webp",
|
||||||
layout: "right",
|
layout: "right",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: "teaser.explore.title",
|
titleKey: "teaser.explore.title",
|
||||||
descKey: "teaser.explore.description",
|
descKey: "teaser.explore.description",
|
||||||
bgImage: "/images/explore_teaser.png",
|
bgImage: "/images/backgrounds/explore_teaser.webp",
|
||||||
heroImage: "/images/explore_hero.png",
|
heroImage: "/images/heros/explore_hero.webp",
|
||||||
layout: "left",
|
layout: "left",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
12
src/lib/blurPlaceholders.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Auto-generated by scripts/gen-blur-placeholders.mjs — do not edit manually
|
||||||
|
export const BLUR_PLACEHOLDERS: Record<string, string> = {
|
||||||
|
"backgrounds/compete_teaser": "data:image/webp;base64,UklGRkQAAABXRUJQVlA4IDgAAADwAgCdASoUAAsAPxFysFAsJqSisAgBgCIJZQC7AC0kAAD+7wHPoVxbdBCX00WbjgD9Z4tx1CAAAA==",
|
||||||
|
"backgrounds/explore_teaser": "data:image/webp;base64,UklGRk4AAABXRUJQVlA4IEIAAADQAwCdASoUAAsAPxFysFAsJqSisAgBgCIJZQCdACPtdR6HHnLZHwAA/uk16hD6XlyktfBMGDYcaSECd8XN57v+wAA=",
|
||||||
|
"backgrounds/hero_teaser": "data:image/webp;base64,UklGRjwAAABXRUJQVlA4IDAAAADwAgCdASoUAAsAPxFysFAsJqSisAgBgCIJZQCw7C0kAAD+7wd2LOZnmb7bPjFHAAA=",
|
||||||
|
"backgrounds/play_teaser": "data:image/webp;base64,UklGRkYAAABXRUJQVlA4IDoAAAAQAwCdASoUAAsAPxFysFAsJqSisAgBgCIJZQAAetFV2AAA/u7a/rkwOPegln3XZvJv4IXGbjFCgAAA",
|
||||||
|
"backgrounds/sponsors_teaser": "data:image/webp;base64,UklGRkoAAABXRUJQVlA4ID4AAACwAgCdASoUAAsAPxFysVAsJqSisAgBgCIJZQAAeyAA/u8NKV4mJzYIQ5TdP6QE3PTLq6L/cRRlp5DBY4AAAA==",
|
||||||
|
"backgrounds/tickets_teaser": "data:image/webp;base64,UklGRkAAAABXRUJQVlA4IDQAAADwAgCdASoUAAsAPxFysFAsJqSisAgBgCIJZQCsADDWAAD+7wOPnse+a27m6AB0gO1dloAA",
|
||||||
|
"heros/compete_hero": "data:image/webp;base64,UklGRmABAABXRUJQVlA4WAoAAAAQAAAAEwAAFAAAQUxQSLQAAAABgGrbtrJl/y4Nd/fEILkm90wjkUjQIJEZRLdKdHkAonukubsu5JP/ESJiAmTd5ZSpv2d4uM0huduWZ8tMOoHvJqnsA9YDRs0AfVIt8JBglPYJ1En533ARbeQYhGOfFH4KC24jxcNGnksagAIZZ84BbHfFLMHaaLUkR+8Lxjf8f6yR4r+we5WsROz3K/hub0yumVAo7DUUse+hqCAU1SFpCIHD0W3rsyNnaN/OSbtDheN2syRWUDgghgAAANADAJ0BKhQAFQA/EYC4VawoJSMoCAGAIglmAMiYGHsmEwuFEhEkYAD+66HVfxcO3w6PqL2pnKTu3Ng/jKAoDs8naMiWdzhP7r5h1yoBgzUT75Dx8f0hkv9pe7sM/bowsdteFBkLMihe2/KstbbBJ3JDP0lIY68rF2gLM5uQUOPBuAQ6AAAA",
|
||||||
|
"heros/explore_hero": "data:image/webp;base64,UklGRnoBAABXRUJQVlA4WAoAAAAQAAAAEwAAFAAAQUxQSMcAAAABgGPb2rHn/orVpf6r2OxS22ZpZxC2nbSZwd/GNuZgG++NnvebQkRMADRNoUkWGI794pjJUCu5YayFXDcUsEkeFDtJ/vEREbkf/DtvFiJOKd/aBcvkh8Q1l//QcqDBbtN/UXc615GmfxJudagyAU8bEue02ANMViA9VWmVALWNKAk+F3YOeeUDjDrMEYMUZxfYDSD1wsXpXnKoM18AOWzIe5MeOQwAdqo36lb+MY9R9/HpugwAvJaopLmw9koTADj5+02K5QAAAFZQOCCMAAAA8AQAnQEqFAAVAD8RfLNULCekIygKqYAiCWwAAC6K7ktWusnkjyqNZvLtrf5IXSsgAP7Y10kJQQoMowu9tcKs37WMMYK0+ODOOnN6ITImiWwI2YSiqAiB1HVtiJn751d+7RynAhi3kIDVj/gRpYzPtuFjAKK95QkeE+vkcbEHeYc8lEOzfIUG9z3UwAA=",
|
||||||
|
"heros/play_hero": "data:image/webp;base64,UklGRpgBAABXRUJQVlA4WAoAAAAQAAAAEwAAFAAAQUxQSNoAAAABgGPbtmnplG3brszKbJuhjSZUaER//KxGRbarWmDbtv3eCd5593chIiYAAECxKsVTS1EO2I15xJ+DydZSbzlFMTcUHWq4qFekgsU4RH7LlUgUIz2JQiaOMyVCpQs7UyfU94c3AdB40/dEIWdL6Y0FHXFfv7+CbjmBRl1Xp8oVPj2gMAOEUkR+nkf631Igv4bMuwoCkLIdOhIhf0z47AAAipo9+MOCh/6gvfiBzM3+OgoQiTLmKACAnyxJckz7PyhZxC8HAACrp03isuA0TsnLBki1CAlpryQPNFZQOCCYAAAAkAQAnQEqFAAVAD8RfrZVrCekoygIAYAiCWMAA+YY8jKFKQn7mN5HXGKuMKLAAPnN+XfRnezaXgxFG0vexSu324H8iTVzaqp6+GAnLm3Z0vYGqKNz9UAFLu/aVjnBei3xNpg0DHr4e2EMCNq6VGTYtaJn7sVI0C/bn2ttJ3P6x68okPFLtX69dR+UTOMhnz1zhE3rAwgAAAA=",
|
||||||
|
};
|
||||||
13
src/proxy.ts
@@ -1,17 +1,8 @@
|
|||||||
import createMiddleware from 'next-intl/middleware';
|
import createMiddleware from 'next-intl/middleware';
|
||||||
import { NextRequest } from 'next/server';
|
|
||||||
import { routing } from './i18n/routing';
|
import { routing } from './i18n/routing';
|
||||||
|
|
||||||
const intlMiddleware = createMiddleware(routing);
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
|
||||||
return intlMiddleware(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
|
||||||
'/',
|
|
||||||
'/(et|en)/:path*',
|
|
||||||
'/((?!_next|_vercel|.*\\..*).*)'
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|||||||