Compare commits
No commits in common. "78a2382fd0326987f7e50c1f5c2742732bc75222" and "1b9065bb094519e50cd0c0642c87b4ab3a2e3c95" have entirely different histories.
78a2382fd0
...
1b9065bb09
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
public/videos/** filter=lfs diff=lfs merge=lfs -text
|
||||
source-videos/** filter=lfs diff=lfs merge=lfs -text
|
||||
41
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
49
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side (Firefox)",
|
||||
"type": "firefox",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"reAttach": true,
|
||||
"pathMappings": [
|
||||
{
|
||||
"url": "webpack://_N_E",
|
||||
"path": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"runtimeArgs": [
|
||||
"--inspect"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithEdge",
|
||||
"killOnServerStop": true,
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
57
Dockerfile
|
|
@ -1,3 +1,56 @@
|
|||
FROM nginx:1.27
|
||||
FROM node:23-alpine3.20@sha256:3ac002b133fd3737c44b66f0d023f5d75c7a1ddec71954fa06486aed6aead888 AS base
|
||||
|
||||
COPY index.html ceboola.mp4 /usr/share/nginx/html/
|
||||
# Install dependencies only when needed
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# If using npm comment out above and use below instead
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
# set hostname to localhost
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
|
||||
ARG VERSION="local"
|
||||
ENV VERSION=$VERSION
|
||||
|
||||
CMD echo "Version: $VERSION" && node server.js
|
||||
|
|
|
|||
41
README.md
|
|
@ -1,13 +1,36 @@
|
|||
# Deploying
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
No CI yet, build and push locally:
|
||||
## Getting Started
|
||||
|
||||
docker build -t git.orga.cebula.camp/infra/site:golden .
|
||||
docker push git.orga.cebula.camp/infra/site:golden
|
||||
First, run the development server:
|
||||
|
||||
No autodeploy yet, ping q3k when done.
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
To get access to the container registry, add an [access
|
||||
token](https://git.orga.cebula.camp/user/settings/applications) with read/write
|
||||
permissions to `package`. Then `docker login git.orga.cebula.camp` and use your
|
||||
username and the token as the password.
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
|
|
|||
BIN
ceboola.mp4
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/globals.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/src/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/src/lib",
|
||||
"hooks": "@/src/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
16
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
44
index.html
|
|
@ -1,44 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<meta charset="utf-8">
|
||||
|
||||
<title>cebula.camp 2025</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
body {
|
||||
font-family: arial, sans-serif;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 100px;
|
||||
background-color: #000;
|
||||
color: #eee;
|
||||
}
|
||||
</style>
|
||||
<h1>CEBULA.CAMP 2025 | REAKTYWACJA</h1>
|
||||
<video loop autoplay muted style="height: 50vh;">
|
||||
<source src="ceboola.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<p style="font-size: 14px;">
|
||||
Zjazd hakerów, miłośników open source, wolnych duchów. Organizowany przez hakerów dla hakerów. Będzie mate, będzie utopia, będzie chillera.
|
||||
</p>
|
||||
<p style="font-size: 14px;">
|
||||
Spodziewaj się ciekawych prezentacji, dziwnych instalacji artystycznych i mnóstwa dyskusji. Możesz opowiedzieć o swoim projekcie, zademonstrować skonstruowane zabawki, albo wspólnie coś stworzyć podczas eventu.
|
||||
</p>
|
||||
<p style="margin-top: 5em;">
|
||||
<b>Gdzie</b>: klub Łącznik, Tramwajowa 1-3, Wrocław, obok Hackerspace Wrocław
|
||||
</p>
|
||||
<p>
|
||||
<b>Kiedy</b>: 28-31.08.2025 (chętnych do pomocy w przygotowaniach zapraszamy już na *Day 0* 27 sierpnia)
|
||||
</p>
|
||||
<p>
|
||||
<b>Bilety</b>: soon
|
||||
</p>
|
||||
<p>
|
||||
<b>Nocleg</b>: w duchu prawdziwego campu przygotowujemy ogrodzone miejsce do rozbicia namiotu z toaletami i prysznicem. Ilość miejsc namiotowych ograniczona, obowiązuje kolejność rezerwacji. Jest także możliwość zakwaterowania we własnym zakresie w pobliskim hotelu lub akademikach.
|
||||
</p>
|
||||
<p>
|
||||
<b>Wyżywienie</b>: we własnym zakresie, w okolicy dostępne są knajpy z dowozem, wieczory planujemy umilić wspólnym grillowaniem.
|
||||
</p>
|
||||
<p>
|
||||
<b>Kontakt</b>: orga@cebula.camp
|
||||
</p>
|
||||
7
next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
10538
package-lock.json
generated
Normal file
53
package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "cebula-site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"translations:extract": "lingui extract-template",
|
||||
"translations:compile": "lingui compile",
|
||||
"translations:sync": "lingui extract --overwrite && lingui compile",
|
||||
"translations:sync_and_purge": "lingui extract --overwrite --clean && lingui compile"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.10",
|
||||
"@lingui/core": "^5.1.2",
|
||||
"@lingui/macro": "^5.1.2",
|
||||
"@lingui/react": "^5.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.3.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-defaulticon-compatibility": "^0.1.2",
|
||||
"lucide-react": "^0.474.0",
|
||||
"make-plural": "^7.4.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "15.1.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0-rc.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@lingui/loader": "^5.1.2",
|
||||
"@lingui/swc-plugin": "^5.0.2",
|
||||
"@lingui/vite-plugin": "^5.1.2",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/fonts/jgs7.ttf
Normal file
BIN
public/fonts/jgs7.woff
Normal file
BIN
public/fonts/jgs7.woff2
Normal file
BIN
public/jgs7.ttf
Normal file
21
public/site.webmanifest
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "CebulaCamp",
|
||||
"short_name": "Cebula",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
public/videos/ceboola_gradient-white_full.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_full.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_full.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_hd.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_hd.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_hd.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_mobile.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_mobile.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_mobile.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_tablet.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_tablet.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_tablet.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_twok.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_twok.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_twok.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_uhd.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_uhd.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient-white_uhd.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_full.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_full.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_full.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_hd.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_hd.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_hd.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_mobile.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_mobile.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_mobile.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_tablet.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_tablet.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_tablet.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_twok.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_twok.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_twok.webm
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_uhd.mp4
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_uhd.ogv
(Stored with Git LFS)
Normal file
BIN
public/videos/ceboola_gradient_uhd.webm
(Stored with Git LFS)
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
94
scripts/reencode-videos.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Define directories
|
||||
SOURCE_DIR="./source-videos"
|
||||
DEST_DIR="./public/videos"
|
||||
|
||||
# Define sizes and names
|
||||
SIZES=(480 720 1080 1440 2160)
|
||||
NAMES=("mobile" "tablet" "hd" "twok" "uhd")
|
||||
|
||||
# Create destination directory if it doesn't exist
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
for video in "$SOURCE_DIR"/*.mkv; do
|
||||
if [ -f "$video" ]; then
|
||||
filename=$(basename "$video" .mkv)
|
||||
echo "Processing: $filename"
|
||||
|
||||
# Get video dimensions
|
||||
width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$video")
|
||||
height=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$video")
|
||||
|
||||
# Process each size
|
||||
for i in "${!SIZES[@]}"; do
|
||||
size="${SIZES[$i]}"
|
||||
name="${NAMES[$i]}"
|
||||
|
||||
echo "Debug: Processing $name with height=$size"
|
||||
|
||||
if [ "$size" -le "$height" ]; then
|
||||
echo "Processing size $name (${size}p)"
|
||||
# MP4
|
||||
ffmpeg -n -i "$video" \
|
||||
-c:v libx264 \
|
||||
-vf "scale=-1:${size}" \
|
||||
-preset slow \
|
||||
-crf 23 \
|
||||
-an \
|
||||
"${DEST_DIR}/${filename}_${name}.mp4"
|
||||
|
||||
# WebM
|
||||
ffmpeg -n -i "$video" \
|
||||
-c:v libvpx-vp9 \
|
||||
-deadline good \
|
||||
-cpu-used 2 \
|
||||
-row-mt 1 \
|
||||
-threads 8 \
|
||||
-vf "scale=-1:${size}" \
|
||||
-quality good \
|
||||
-an \
|
||||
-crf 20 \
|
||||
"${DEST_DIR}/${filename}_${name}.webm"
|
||||
|
||||
# Ogg
|
||||
ffmpeg -n -i "$video" \
|
||||
-c:v libtheora \
|
||||
-q:v 5 \
|
||||
-vf "scale=-1:${size}" \
|
||||
-an \
|
||||
"${DEST_DIR}/${filename}_${name}.ogv"
|
||||
fi
|
||||
done
|
||||
|
||||
# Create original resolution version
|
||||
echo "Creating original resolution version"
|
||||
|
||||
ffmpeg -n -i "$video" \
|
||||
-c:v libx264 \
|
||||
-preset slow \
|
||||
-an \
|
||||
"${DEST_DIR}/${filename}_full.mp4"
|
||||
|
||||
ffmpeg -n -i "$video" \
|
||||
-c:v libvpx-vp9 \
|
||||
-deadline good \
|
||||
-cpu-used 2 \
|
||||
-row-mt 1 \
|
||||
-threads 8 \
|
||||
-quality good \
|
||||
-an \
|
||||
"${DEST_DIR}/${filename}_full.webm"
|
||||
|
||||
ffmpeg -n -i "$video" \
|
||||
-c:v libtheora \
|
||||
-q:v 5 \
|
||||
-an \
|
||||
"${DEST_DIR}/${filename}_full.ogv"
|
||||
|
||||
echo "Completed processing: $filename"
|
||||
echo "----------------------------"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All videos have been processed."
|
||||
BIN
source-videos/ceboola_gradient-white.mkv
Executable file
BIN
source-videos/ceboola_gradient.mkv
Executable file
37
src/app/[locale]/error.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold tracking-tight text-black sm:text-8xl">
|
||||
{error.name}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-gray-500">{error.message}</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-base font-medium text-white shadow-xs hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:text-sm"
|
||||
onClick={reset}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/app/[locale]/layout.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import "../../globals.css";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
import { getLocale, Lang } from "@/i18n/locales";
|
||||
import Head from 'next/head';
|
||||
|
||||
import { ThemeProvider } from "@/components/providers";
|
||||
import { translations } from "@/i18n/translations";
|
||||
import { Oxanium } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
const oxanium = Oxanium({ subsets: ["latin-ext"] })
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: Lang }>
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: Props,
|
||||
): Promise<Metadata> {
|
||||
// read route params
|
||||
const locale = (await params).locale
|
||||
|
||||
const currentLang = getLocale(locale);
|
||||
|
||||
const t = translations[currentLang];
|
||||
|
||||
return {
|
||||
title: "CebulaCamp",
|
||||
description: "An amazing gathering of hackers and open source enthusiasts.",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon-96x96.png', type: 'image/png' },
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/favicon.ico', type: 'image/x-icon' }
|
||||
],
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
other: {
|
||||
'apple-mobile-web-app-title': t.siteTitle,
|
||||
},
|
||||
openGraph: {
|
||||
title: t.siteTitle,
|
||||
description: 'An amazing gathering of hackers and open source enthusiasts.',
|
||||
url: 'https://cebula.camp',
|
||||
images: [
|
||||
{
|
||||
url: 'https://cebula.camp/web-app-manifest-512x512.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
alt: t.siteTitle,
|
||||
},
|
||||
],
|
||||
siteName: t.siteTitle,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'CebulaCamp',
|
||||
description: 'An amazing gathering of hackers and open source enthusiasts.',
|
||||
images: ['https://cebula.camp/web-app-manifest-512x512.png'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ locale: Lang }>
|
||||
}) {
|
||||
const [{ locale }, head] = await Promise.all([
|
||||
params,
|
||||
headers(),
|
||||
]);
|
||||
|
||||
const preferedTheme = head.get("Sec-CH-Prefers-Color-Scheme")?.toLowerCase();
|
||||
const supportedThemes = ["dark", "light"];
|
||||
|
||||
const isDarkOrLight =
|
||||
preferedTheme && supportedThemes.includes(preferedTheme);
|
||||
|
||||
const currentLang = getLocale(locale);
|
||||
const defaultTheme = isDarkOrLight ? preferedTheme : "dark";
|
||||
|
||||
const t = translations[currentLang];
|
||||
|
||||
return (
|
||||
<html lang={currentLang} className={`${oxanium.className} ${defaultTheme}`}>
|
||||
<Head>
|
||||
<title>{t.siteTitle}</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="CebulaCamp" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
</Head>
|
||||
<body className="bg-background text:foreground antialiased">
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
4
src/app/[locale]/loading.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
export default function Loading() {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
5
src/app/[locale]/not-found.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
|
||||
export default function NotFound() {
|
||||
return <div>Not Found</div>
|
||||
}
|
||||
17
src/app/[locale]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import LandingPage from "@/components/landing-page";
|
||||
import { getLocale, Lang } from "@/i18n/locales";
|
||||
import { translations } from "@/i18n/translations";
|
||||
|
||||
|
||||
export default async function Home(
|
||||
{ params }
|
||||
:
|
||||
{ params: Promise<{ locale: Lang }> }
|
||||
) {
|
||||
const { locale } = await params
|
||||
const currentLocale = getLocale(locale)
|
||||
const t = translations[currentLocale];
|
||||
|
||||
return <LandingPage t={t} />
|
||||
}
|
||||
|
||||
29
src/components/event-map.lazy.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import "leaflet/dist/leaflet.css"
|
||||
|
||||
import "leaflet-defaulticon-compatibility"
|
||||
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"
|
||||
|
||||
import { Translations } from "@/i18n/translations"
|
||||
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet"
|
||||
|
||||
|
||||
export default function Map({ t }: {
|
||||
t: Translations
|
||||
}) {
|
||||
return (
|
||||
<div className='h-screen max-h-[400px] pt-8 relative mx-auto max-w-3xl'>
|
||||
{/* @ts-expect-error dragging IS a valid prop. */}
|
||||
<MapContainer dragging={false} center={[51.105173, 17.087157]} zoom={40} scrollWheelZoom={false} className="h-full w-full">
|
||||
{/* @ts-expect-error attribution IS a valid prop. */}
|
||||
<TileLayer attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={[51.105173, 17.087157]}>
|
||||
<Popup>
|
||||
{t.where.location}
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
src/components/landing-page.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client"
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
|
||||
|
||||
import { Nav } from "@/components/nav";
|
||||
import { Translations } from "@/i18n/translations";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactElement, useEffect, useRef } from "react";
|
||||
import { useTheme } from "./providers";
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
|
||||
function Section({
|
||||
id,
|
||||
title,
|
||||
paragraphs,
|
||||
after
|
||||
}: {
|
||||
id: string
|
||||
title: string;
|
||||
paragraphs: ReactElement;
|
||||
after?: ReactElement;
|
||||
}) {
|
||||
return (<section id={id} className="bg-background">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold tracking-tighter max-w-3xl mx-auto mb-2">{title}</h2>
|
||||
<div className="text-lg text-muted-foreground max-w-3xl mx-auto whitespace-pre-line">
|
||||
{paragraphs}
|
||||
</div>
|
||||
{after}
|
||||
</div>
|
||||
</section>)
|
||||
}
|
||||
|
||||
function getSource({
|
||||
src,
|
||||
type
|
||||
}: {
|
||||
src: string;
|
||||
type: 'mp4' | 'webm' | 'ogv'
|
||||
}) {
|
||||
const sourceType = `video/${type}`
|
||||
return [
|
||||
<source
|
||||
key={`mobile-${type}`}
|
||||
media="(max-width: 640px)"
|
||||
src={src.replace('.mp4', `_mobile.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
|
||||
<source
|
||||
key={`tablet-${type}`}
|
||||
media="(max-width: 1024px)"
|
||||
src={src.replace('.mp4', `_tablet.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
|
||||
<source
|
||||
key={`hd-${type}`}
|
||||
media="(max-width: 1920px)"
|
||||
src={src.replace('.mp4', `_hd.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
|
||||
<source
|
||||
key={`twok-${type}`}
|
||||
media="(max-width: 2560px)"
|
||||
src={src.replace('.mp4', `_2k.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
|
||||
<source
|
||||
key={`uhd-${type}`}
|
||||
media="(max-width: 3840px)"
|
||||
src={src.replace('.mp4', `_uhd.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
|
||||
<source
|
||||
key={`original-${type}`}
|
||||
media="(min-width: 3841px)"
|
||||
src={src.replace('.mp4', `_original.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
function Video({ sourceBase, hidden }: {
|
||||
sourceBase: string;
|
||||
hidden: boolean;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!videoRef.current || hidden) return;
|
||||
|
||||
videoRef.current.play();
|
||||
const scrolled = window.scrollY;
|
||||
videoRef.current.style.willChange = "transform";
|
||||
videoRef.current.style.transform = `translateY(${scrolled * 0.5}px)`;
|
||||
videoRef.current.style.willChange = "unset";
|
||||
};
|
||||
|
||||
const throttledHandleScroll = () => {
|
||||
requestAnimationFrame(handleScroll);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", throttledHandleScroll);
|
||||
handleScroll();
|
||||
return () => window.removeEventListener("scroll", throttledHandleScroll);
|
||||
}, [hidden]);
|
||||
|
||||
const sources = [...getSource({ src: sourceBase, type: 'mp4' }), ...getSource({ src: sourceBase, type: 'ogv' }), ...getSource({ src: sourceBase, type: 'webm' })]
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x5-playsinline="true"
|
||||
className={cn("w-full h-full object-contain parallax-video", {
|
||||
hidden,
|
||||
})}
|
||||
>
|
||||
{sources.map(x => x)}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const LazyLeafletMap = dynamic(() => import('./event-map.lazy'), {
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="mt-8 h-[368px] w-full" />
|
||||
})
|
||||
|
||||
export default function LandingPage(
|
||||
{ t }: { t: Translations }
|
||||
) {
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav t={t} />
|
||||
<main className="flex flex-col min-h-screen grid-gap-10 gap-10 pb-12">
|
||||
|
||||
<section id="hero" className="h-screen relative overflow-hidden dark:bg-black light:bg-white ">
|
||||
<div className="absolute inset-0 opacity-80">
|
||||
<Video sourceBase="/videos/ceboola_gradient.mp4" hidden={theme === 'light'} />
|
||||
<Video sourceBase="/videos/ceboola_gradient-white.mp4" hidden={theme === "dark"} />
|
||||
</div>
|
||||
<div className="relative z-10 container mx-auto px-4 h-full flex items-center justify-center">
|
||||
<div className="text-center font-[JGS7]">
|
||||
<h1 className="text-5xl sm:text-6xl md:text-8xl font-bold tracking-tighter light:text-background">{t.hero.title}</h1>
|
||||
<p className="mt-2 text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl-text:7xl 2xl:text-8xl text-primary">{t.hero.subtitle}</p>
|
||||
<p className="mt-2 text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl-text:7xl 2xl:text-8xl text-primary ">{t.when.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Section id="about" title={t.about.title} paragraphs={<p>{t.about.description}</p>} />
|
||||
<Section id="where" title={t.where.title} paragraphs={<p>{t.where.location}</p>} after={<LazyLeafletMap t={t} />} />
|
||||
|
||||
<Section id="when" title={t.when.title} paragraphs={<>
|
||||
<p className="text-primary text-3xl font-[JGS7]">{t.when.date}</p>
|
||||
<p className="mt-4">{t.when.extra}</p></>}
|
||||
/>
|
||||
<Section id="tickets" title={t.tickets.title} paragraphs={<p>{t.tickets.status}</p>} />
|
||||
<Section id="accommodation" title={t.accommodation.title} paragraphs={<p>{t.accommodation.description}</p>} />
|
||||
<Section id="food" title={t.food.title} paragraphs={<p>{t.food.description}</p>} />
|
||||
<Section id="contact" title={t.contact.title} paragraphs={<a href={`mailto:${t.contact.email}`}>{t.contact.email}</a>} />
|
||||
<Section id="credits" title={t.credits.title} paragraphs={<>
|
||||
<p>{t.credits.usedFonts}</p>
|
||||
<p><a className="hover:underline" href="https://velvetyne.fr/fonts/jgs-font/">{t.credits.jgs7}</a></p>
|
||||
<p><a className="hover:underline" href="https://fonts.google.com/specimen/Oxanium">{t.credits.oxanium}</a></p>
|
||||
</>}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
45
src/components/mobile-nav.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import type { Sections, translations } from "@/i18n/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Menu } from "lucide-react"
|
||||
import { LanguageSelector } from "./ui/language-selector"
|
||||
|
||||
export function MobileNav({
|
||||
t,
|
||||
linksOrder,
|
||||
activeSection
|
||||
}: {
|
||||
t: typeof translations.pl
|
||||
linksOrder: Array<Sections>
|
||||
activeSection: Sections
|
||||
}) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">{t.mobileNav.toggleMenu}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[80vw] sm:w-[385px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t.mobileNav.menu}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-4 mt-8">
|
||||
{linksOrder.map((value) => (
|
||||
<a key={value} href={`#${value}`} className={cn("text-lg hover:text-primary transition-colors", {
|
||||
'text-primary': activeSection === value
|
||||
})}>
|
||||
{t.nav[value]}
|
||||
</a>
|
||||
))}
|
||||
<LanguageSelector />
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
117
src/components/nav.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sections, type translations } from "@/i18n/translations"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MoonIcon, SunIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { MobileNav } from "./mobile-nav"
|
||||
import { useTheme } from "./providers"
|
||||
import { LanguageSelector } from "./ui/language-selector"
|
||||
|
||||
const linksOrder: Array<keyof (typeof translations.pl)["nav"]> = [
|
||||
"hero",
|
||||
"about",
|
||||
"where",
|
||||
"when",
|
||||
"tickets",
|
||||
"accommodation",
|
||||
"food",
|
||||
"contact",
|
||||
]
|
||||
|
||||
export function Nav({
|
||||
t,
|
||||
}: {
|
||||
t: typeof translations.pl
|
||||
}) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [activeSection, setActiveSection] = useState<Sections>("about")
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: "-10px",
|
||||
threshold: 0.5, // Adjust the visibility threshold as needed
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
const target = entry.target.id as keyof (typeof translations.pl)["nav"]
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(target);
|
||||
if (history.replaceState) {
|
||||
history.replaceState(null, "", `#${target}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, options);
|
||||
|
||||
const sections = linksOrder.map(value => document.getElementById(value));
|
||||
sections.forEach(section => {
|
||||
if (section) {
|
||||
observer.observe(section);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
sections.forEach(section => {
|
||||
if (section) {
|
||||
observer.unobserve(section);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 backdrop-blur-xs bg-background/40 border-b z-[10000]">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex gap-4">
|
||||
<LanguageSelector />
|
||||
<a href="#" className="text-xl font-bold tracking-tighter hover:text-primary transition-colors">
|
||||
<h1>{t.nav.title}</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex md:items-center md:gap-4 lg:gap-8">
|
||||
|
||||
{linksOrder.map((value) => (
|
||||
<a
|
||||
key={value}
|
||||
href={`#${value}`}
|
||||
className={cn("text-sm md:text-md hover:text-primary transition-colors relative group", {
|
||||
'text-primary': activeSection === value
|
||||
})}
|
||||
>
|
||||
{t.nav[value]}
|
||||
<span className={cn("absolute inset-x-0 -bottom-1 h-0.5 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform", {
|
||||
'scale-x-100': activeSection === value
|
||||
})} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="ml-4"
|
||||
>
|
||||
{theme === "dark" ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div className="md:hidden ml-2">
|
||||
<MobileNav t={t} linksOrder={linksOrder} activeSection={activeSection} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/components/providers.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<Theme>("dark")
|
||||
|
||||
const changeTheme = useCallback((theme: Theme) => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(theme)
|
||||
}, [])
|
||||
|
||||
const updateTheme = useCallback((theme: Theme) => {
|
||||
setTheme(theme)
|
||||
changeTheme(theme)
|
||||
}, [changeTheme, setTheme])
|
||||
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme: updateTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
57
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
18
src/components/ui/language-selector.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
'use client'
|
||||
|
||||
import { Lang } from "@/i18n/locales";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const params = useParams<{ locale: Lang }>();
|
||||
|
||||
|
||||
const lang = params?.locale || 'pl';
|
||||
const hash = globalThis?.window?.location?.hash || '';
|
||||
if (lang === 'pl') return (<>
|
||||
<Link suppressHydrationWarning className="pt-1" href={`/en${hash}`}>🇬🇧</Link></>);
|
||||
if (lang === 'en') return (<>
|
||||
<Link suppressHydrationWarning className="pt-1" href={`/pl${hash}`}>🇵🇱</Link></>);
|
||||
};
|
||||
133
src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> { }
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet, SheetClose,
|
||||
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
|
||||
}
|
||||
|
||||
15
src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
BIN
src/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
169
src/globals.css
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@plugin 'tailwindcss-animate';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@font-face {
|
||||
font-family: "JGS7";
|
||||
src: url("/fonts/jgs7.woff2") format("woff2"),
|
||||
url("/fonts/jgs7.woff") format("woff"),
|
||||
url("/fonts/jgs7.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
html {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(0 0% 3.9%);
|
||||
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(0 0% 3.9%);
|
||||
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(0 0% 3.9%);
|
||||
|
||||
--primary: hsl(120 100% 40%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
|
||||
--secondary: hsl(0 0% 96.1%);
|
||||
--secondary-foreground: hsl(0 0% 9%);
|
||||
|
||||
--muted: hsl(0 0% 96.1%);
|
||||
--muted-foreground: hsl(0 0% 45.1%);
|
||||
|
||||
--accent: hsl(0 0% 96.1%);
|
||||
--accent-foreground: hsl(0 0% 9%);
|
||||
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
|
||||
--border: hsl(0 0% 89.8%);
|
||||
--input: hsl(0 0% 89.8%);
|
||||
--ring: hsl(0 0% 3.9%);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0 0% 5%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
|
||||
--card: hsl(0 0% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
|
||||
--popover: hsl(0 0% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
|
||||
--primary: hsl(120 100% 45%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
|
||||
--muted: hsl(0 0% 14.9%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
|
||||
--accent: hsl(0 0% 14.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
|
||||
--border: hsl(0 0% 14.9%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
.parallax-video {
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
.jgs7 {
|
||||
font-family: "JGS7" !important;
|
||||
}
|
||||
|
||||
/* Fix for leaflet's weird z-index */
|
||||
.z-max {
|
||||
z-index: 10000;
|
||||
}
|
||||
10
src/i18n/locales.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// We need this export in here, otherwise lingui.config.ts crashes on macro imports
|
||||
export const locales = ["en", "pl"] as const;
|
||||
|
||||
export type Lang = (typeof locales)[number];
|
||||
|
||||
export function getLocale(lang: Lang): Lang {
|
||||
return locales.includes(lang as Lang) ? lang : "en";
|
||||
}
|
||||
|
||||
export const defaultLocale = locales[0];
|
||||
143
src/i18n/translations.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
const common = {
|
||||
jgs7: "JGS font Jgs font by Adel Faure. Distributed by velvetyne.fr",
|
||||
oxanium: "Oxanium",
|
||||
orgaEmail: "orga@cebula.camp",
|
||||
};
|
||||
|
||||
const pl = {
|
||||
siteTitle: "CebulaCamp",
|
||||
nav: {
|
||||
title: "CEBULACAMP",
|
||||
hero: "Cebula",
|
||||
about: "O nas",
|
||||
when: "Kiedy",
|
||||
where: "Gdzie",
|
||||
food: "Wyżywienie",
|
||||
contact: "Kontakt",
|
||||
tickets: "Bilety",
|
||||
accommodation: "Nocleg",
|
||||
},
|
||||
mobileNav: {
|
||||
toggleMenu: "Aktywuj menu",
|
||||
menu: "Menu",
|
||||
},
|
||||
hero: {
|
||||
title: "CEBULACAMP 2025",
|
||||
subtitle: "REAKTYWACJA",
|
||||
},
|
||||
about: {
|
||||
title: "O nas",
|
||||
description:
|
||||
"Zjazd hakerów, miłośników open source, wolnych duchów. Organizowany przez hakerów dla hakerów. Będzie mate, będzie utopia, będzie chillera.\n\nReaktywacja wydarzenia po długiej przerwie od ostatniej edycji w 2021. Tym razem widzimy się we Wrocławiu, w budynku starej zajezdni tramwajowej który zamienimy na łączone hackcenter i salę prelekcyjną.\n\nSpodziewaj się ciekawych prezentacji, dziwnych instalacji artystycznych i mnóstwa dyskusji. Możesz opowiedzieć o swoim projekcie, zademonstrować skonstruowane zabawki, albo wspólnie coś stworzyć podczas eventu.",
|
||||
},
|
||||
where: {
|
||||
title: "Gdzie",
|
||||
location:
|
||||
"Klub „Łącznik”, Tramwajowa 1-3, Wrocław, obok Hackerspace Wrocław",
|
||||
},
|
||||
when: {
|
||||
title: "Kiedy",
|
||||
date: "28-31.08.2025",
|
||||
extra:
|
||||
"Chętnych do pomocy w przygotowaniach zapraszamy już na Day 0, 27 sierpnia",
|
||||
},
|
||||
tickets: {
|
||||
title: "Bilety",
|
||||
status: "soon",
|
||||
},
|
||||
accommodation: {
|
||||
title: "Nocleg",
|
||||
description:
|
||||
"W duchu prawdziwego campu przygotowujemy ogrodzone miejsce do rozbicia namiotu z toaletami i prysznicem. Ilość miejsc namiotowych ograniczona, obowiązuje kolejność rezerwacji. Jest także możliwość zakwaterowania we własnym zakresie w pobliskim hotelu lub akademikach.",
|
||||
},
|
||||
food: {
|
||||
title: "Wyżywienie",
|
||||
description:
|
||||
"We własnym zakresie, w okolicy dostępne są knajpy z dowozem, wieczory planujemy umilić wspólnym grillowaniem.",
|
||||
},
|
||||
contact: {
|
||||
title: "Kontakt",
|
||||
email: common.orgaEmail,
|
||||
},
|
||||
credits: {
|
||||
title: "Uznania",
|
||||
usedFonts: "Użyte fonty:",
|
||||
oxanium: common.oxanium,
|
||||
jgs7: common.jgs7,
|
||||
},
|
||||
};
|
||||
|
||||
const en = {
|
||||
siteTitle: "CebulaCamp 2025",
|
||||
nav: {
|
||||
title: "CEBULACAMP",
|
||||
hero: "Onion",
|
||||
about: "About us",
|
||||
when: "When",
|
||||
where: "Where",
|
||||
food: "Food",
|
||||
contact: "Contact",
|
||||
tickets: "Tickets",
|
||||
accommodation: "Accomodation",
|
||||
},
|
||||
mobileNav: {
|
||||
toggleMenu: "Toggle menu",
|
||||
menu: "Menu",
|
||||
},
|
||||
hero: {
|
||||
title: "CEBULACAMP 2025",
|
||||
subtitle: "REACTIVATED",
|
||||
},
|
||||
about: {
|
||||
title: "About us",
|
||||
description:
|
||||
"A gathering of hackers, open source enthusiasts, and free spirits. Organized by hackers for hackers. An utopia of mate and chill vibes.\n\nFirst event since a long pause that started on 2021. This year we'll see eachother in Wrocław, in an old tram depot building which we'll turn into a combined hack center and talk stage.\n\nExpect interesting presentations, weird art installations, and lots of discussions. Talk about your project, show off the stuff you've built, or work on something together during the event.",
|
||||
},
|
||||
where: {
|
||||
title: "Where",
|
||||
location:
|
||||
"“Łącznik” Club, Tramwajowa 1-3, Wrocław, next to Hackerspace Wrocław",
|
||||
},
|
||||
when: {
|
||||
title: "When",
|
||||
date: "28-31.08.2025",
|
||||
extra:
|
||||
"Those willing to help with preparations are welcome to join on Day 0, August 27",
|
||||
},
|
||||
tickets: {
|
||||
title: "Tickets",
|
||||
status: "soon",
|
||||
},
|
||||
accommodation: {
|
||||
title: "Accommodation",
|
||||
description:
|
||||
"In the true camp spirit, we're preparing a fenced area for pitching tents with toilets and shower facilities. The number of tent spots is limited, first-come-first-served basis. There's also the possibility of arranging your own accommodation in a nearby hotel or student dormitories.",
|
||||
},
|
||||
food: {
|
||||
title: "Food",
|
||||
description:
|
||||
"Self-catering, there are restaurants with delivery in the area, and we plan to enhance the evenings with communal barbecues.",
|
||||
},
|
||||
contact: {
|
||||
title: "Contact",
|
||||
email: common.orgaEmail,
|
||||
},
|
||||
credits: {
|
||||
title: "Credits",
|
||||
usedFonts: "Used fonts:",
|
||||
oxanium: common.oxanium,
|
||||
jgs7: common.jgs7,
|
||||
},
|
||||
};
|
||||
|
||||
export const translations: {
|
||||
en: typeof pl;
|
||||
pl: typeof pl;
|
||||
} = {
|
||||
pl: pl,
|
||||
en: en,
|
||||
};
|
||||
|
||||
export type Translations = typeof pl;
|
||||
|
||||
export type Sections = keyof (typeof pl)["nav"];
|
||||
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
33
src/middleware.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { match } from "@formatjs/intl-localematcher";
|
||||
import Negotiator from "negotiator";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const locales = ["en", "pl"];
|
||||
const defaultLocale = "pl";
|
||||
|
||||
function getLocale(request: NextRequest) {
|
||||
const headers = {
|
||||
"accept-language": request.headers.get("accept-language") || "",
|
||||
};
|
||||
const languages = new Negotiator({ headers }).languages();
|
||||
return match(languages, locales, defaultLocale);
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const pathnameIsMissingLocale = locales.every(
|
||||
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
|
||||
);
|
||||
|
||||
if (pathnameIsMissingLocale) {
|
||||
const locale = getLocale(request);
|
||||
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/((?!api|static|.*\\..*|_next).*)",
|
||||
|
||||
// matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
27
tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||