Compare commits
88 commits
277300a68b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3e323464 | ||
|
|
7bceaab551 | ||
|
|
8a629c3de8 | ||
|
|
10bfa5e561 | ||
|
|
5127ee7967 | ||
|
|
e69926a162 | ||
|
|
f1ed363830 | ||
|
|
78db02a03d | ||
|
|
956cd060f0 | ||
|
|
38d94270a3 | ||
|
|
e9c5416f89 | ||
|
|
25a98cbb85 | ||
|
|
c1ead36042 | ||
|
|
65a8ea8ab8 | ||
|
|
29c1252633 | ||
|
|
6da5da5bee | ||
|
|
103e343f22 | ||
|
|
2c0d70975a | ||
|
|
af1a4e6c74 | ||
|
|
96861a4490 | ||
|
|
0882f73e53 | ||
|
|
c30dea28bf | ||
|
|
c69447a9c1 | ||
|
|
a40e78cf5d | ||
|
|
d2eff84d30 | ||
|
|
dc2b12403d | ||
|
|
b858551e6d | ||
|
|
eb55bfe905 | ||
|
|
53883f4148 | ||
|
|
9fc91d9480 | ||
|
|
4bc8882620 | ||
|
|
281f3a9a89 | ||
|
|
0072eac5d2 | ||
|
|
615e4466de | ||
|
|
e11dcdf071 | ||
|
|
bb8c5ec6d0 | ||
|
|
07714ef782 | ||
|
|
41bfff9955 | ||
|
|
63fca6b5ad | ||
|
|
f1caef1058 | ||
|
|
c6db42dff0 | ||
|
|
fc46f7a4cd | ||
|
|
19d53a0993 | ||
|
|
c3837d90be | ||
|
|
dfdad2c16a | ||
|
|
137704a2f9 | ||
|
|
d9b728aacf | ||
|
|
f044b17877 | ||
|
|
02779b9d4a | ||
|
|
477fc884be | ||
|
|
39f994fc0b | ||
|
|
8158b6817e | ||
|
|
772a6fb04d | ||
|
|
a88da1fa37 | ||
|
|
1eddc9adf5 | ||
|
|
6789cdd962 | ||
|
|
8e09ea3a93 | ||
|
|
bfe470cb5a | ||
|
|
037b89a971 | ||
|
|
0a213c7de4 | ||
|
|
2c5ab83631 | ||
|
|
0746fb985b | ||
|
|
c738b7a904 | ||
|
|
19842f909e | ||
|
|
1b9065bb09 | ||
|
|
1278f07adf | ||
|
|
321a8a45cc | ||
|
|
6d5804df3a | ||
|
|
a9c98ee778 | ||
|
|
ff033eb1a1 | ||
|
|
fc83d7e67d | ||
|
|
6f826c47df | ||
|
|
2de5b79047 | ||
|
|
d22d51b8d6 | ||
|
|
baba60aa3f | ||
|
|
b3e7f118f2 | ||
|
|
62964757f7 | ||
|
|
b9efdc7d5a | ||
|
|
9a6d46a826 | ||
|
|
e65adb54a2 | ||
|
|
892ca8a79a | ||
|
|
2a4c2ca392 | ||
|
|
34330d9d1f | ||
|
|
3fb06c523d | ||
|
|
3e11459bbb | ||
|
|
401b8046ec | ||
|
|
09038467f2 | ||
|
|
d874678248 |
6
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
on: [push]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted-nixos-x86_64
|
||||
steps:
|
||||
- run: curl https://cebula.camp/.not-well-known/update
|
||||
0
.gitattributes
vendored
Normal file
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}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
56
Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
FROM node:23-alpine3.20@sha256:3ac002b133fd3737c44b66f0d023f5d75c7a1ddec71954fa06486aed6aead888 AS base
|
||||
|
||||
# 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
|
||||
|
|
@ -34,3 +34,9 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
|||
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.
|
||||
|
||||
## Deploying on prod
|
||||
|
||||
Once merged to main (go through a PR!) changes will autodeploy within 5 minutes.
|
||||
|
||||
See [infra/nixos/autodeploy](https://git.orga.cebula.camp/infra/nixos/src/branch/master/autodeploy) to discover how the sausage is made.
|
||||
|
|
|
|||
57
cebula.html
|
|
@ -1,57 +0,0 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
26
default.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{ pkgs ? import <nixpkgs> {}, ... }: let
|
||||
package-json = (builtins.fromJSON (builtins.readFile ./package.json));
|
||||
in pkgs.buildNpmPackage {
|
||||
pname = package-json.name;
|
||||
version = package-json.version;
|
||||
src = ./.;
|
||||
|
||||
npmDeps = pkgs.importNpmLock {
|
||||
npmRoot = ./.;
|
||||
};
|
||||
|
||||
npmConfigHook = pkgs.importNpmLock.npmConfigHook;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/.next
|
||||
cp -r public $out/
|
||||
cp -r .next/standalone/{.*,*} $out/
|
||||
cp -r .next/static $out/.next
|
||||
mkdir $out/bin
|
||||
echo "#! /usr/bin/env bash" > $out/bin/cebula-site
|
||||
echo 'SOURCE=''${BASH_SOURCE[0]}' >> $out/bin/cebula-site
|
||||
echo 'cd $(dirname $SOURCE)/..' >> $out/bin/cebula-site
|
||||
echo 'exec ${pkgs.nodejs}/bin/node server.js' >> $out/bin/cebula-site
|
||||
chmod +x $out/bin/cebula-site
|
||||
'';
|
||||
}
|
||||
61
flake.lock
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1739055578,
|
||||
"narHash": "sha256-2MhC2Bgd06uI1A0vkdNUyDYsMD0SLNGKtD8600mZ69A=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a45fa362d887f4d4a7157d95c28ca9ce2899b70e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
14
flake.nix
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }: {
|
||||
# nixosModules.default = import ./module.nix self;
|
||||
} // (flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
package-json = (builtins.fromJSON (builtins.readFile ./package.json));
|
||||
in {
|
||||
packages.default = import ./default.nix { inherit pkgs; };
|
||||
}
|
||||
));
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import createMDX from "@next/mdx";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
const withMDX = createMDX({
|
||||
// Add markdown plugins here, as desired
|
||||
});
|
||||
|
||||
// Merge MDX config with Next.js config
|
||||
export default withMDX(nextConfig);
|
||||
|
|
|
|||
3401
package-lock.json
generated
28
package.json
|
|
@ -6,36 +6,48 @@
|
|||
"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"
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.10",
|
||||
"@hookform/resolvers": "^4.0.0",
|
||||
"@lingui/core": "^5.1.2",
|
||||
"@lingui/macro": "^5.1.2",
|
||||
"@lingui/react": "^5.1.2",
|
||||
"@mdx-js/loader": "^3.1.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@next/mdx": "^15.1.7",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"form-data": "^4.0.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-hook-form": "^7.54.2",
|
||||
"react-leaflet": "^5.0.0-rc.2",
|
||||
"react-scrollspy-navigation": "^2.0.6",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"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",
|
||||
|
|
@ -43,7 +55,7 @@
|
|||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
70
public/favicon.svg
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
width="455.08301mm"
|
||||
height="455.08334mm"
|
||||
viewBox="0 0 1719.9988 1720"
|
||||
id="svg1"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
inkscape:export-filename="favicon-96x96.png"
|
||||
inkscape:export-xdpi="5.3581433"
|
||||
inkscape:export-ydpi="5.3581433"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1">
|
||||
<linearGradient
|
||||
id="linearGradient1"
|
||||
inkscape:collect="always">
|
||||
<stop
|
||||
style="stop-color:#1ee6e3;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1" />
|
||||
<stop
|
||||
style="stop-color:#7b60fa;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop2" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient1"
|
||||
id="linearGradient2"
|
||||
x1="405.06433"
|
||||
y1="983.10406"
|
||||
x2="1313.6776"
|
||||
y2="722.56342"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.1650752,-0.43613075,0.43613075,1.1650752,-525.28288,209.08369)" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.49127477"
|
||||
inkscape:cx="702.25467"
|
||||
inkscape:cy="961.78357"
|
||||
inkscape:window-width="2558"
|
||||
inkscape:window-height="1418"
|
||||
inkscape:window-x="2560"
|
||||
inkscape:window-y="20"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1"
|
||||
inkscape:document-units="mm" />
|
||||
<style
|
||||
id="style1">@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style>
|
||||
<path
|
||||
style="opacity:1;fill:url(#linearGradient2);fill-opacity:1;stroke:none;stroke-width:1.24403px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 165.35902,66.81658 27.83692,-24.953608 103.39847,64.040578 84.52455,93.07457 -24.69864,-84.03733 19.3099,-14.32601 -6.24758,-23.009933 18.12727,-19.291047 24.9772,-12.729698 20.3611,25.500281 32.1506,10.947612 48.46774,46.411245 73.92088,107.18338 8.06741,15.23107 17.85315,6.16021 47.67496,8.17809 8.14508,9.11835 38.68548,113.27567 47.149,84.42086 109.68532,19.43943 161.71177,-1.38785 132.6495,28.4182 116.9316,45.11732 83.5383,68.77108 66.1942,79.31945 41.1045,94.45711 27.0972,100.37646 -1.507,107.02823 -16.5114,107.5755 -41.1115,108.6724 -55.2614,105.1819 -72.07,86.4632 -3.1973,19.448 57.4117,19.7425 35.0132,-6.6854 48.1503,-44.7247 8.1833,7.414 -29.3349,52.553 -89.7853,34.6239 -46.1805,-30.3686 20.3567,33.6138 47.3342,20.8109 21.0318,82.3682 -13.2701,12.403 -34.4859,-72.2621 -50.1147,-17.4043 -29.0262,-32.3961 14.6131,35.4257 46.9903,38.8528 2.976,106.3639 -4.8192,5.1838 -11.6722,4.0315 -27.3983,-92.1521 -20.9588,-17.1651 3.2561,6.8926 11.4985,103.85 -7.1543,7.0718 -11.3013,3.2165 -38.4698,-101.865 -3.5177,-3.077 -6.5646,12.2588 11.679,48.3533 v 5.4088 l 5.9621,4.19 -11.452,7.3285 -58.407,-27.8191 -11.1638,-42.4627 4.7367,-54.1602 -8.9677,4.033 -24.5953,-7.0163 -109.23025,26.0178 -120.62672,1.8934 -111.87615,-10.8459 -106.82404,-34.3678 -94.40681,-45.4376 -79.0553,-69.4352 -65.75036,-78.1337 -41.21544,-94.7536 -24.20418,-106.1913 12.29782,-124.2491 34.53093,-132.57159 70.50823,-140.29369 32.59228,-111.56712 -29.49548,-53.51325 6.39472,-38.89581 9.33243,5.97002 2.41271,-8.00078 15.89689,-6.28878 -217.5502,-238.96957 33.21564,-36.76851 8.96776,-4.03294 z"
|
||||
id="path1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
21
public/site.webmanifest
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "Cebula Camp 2025",
|
||||
"short_name": "CebulaCamp",
|
||||
"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
Normal file
BIN
public/videos/ceboola_gradient-white_full.ogv
Normal file
BIN
public/videos/ceboola_gradient-white_full.webm
Normal file
BIN
public/videos/ceboola_gradient-white_hd.mp4
Normal file
BIN
public/videos/ceboola_gradient-white_hd.ogv
Normal file
BIN
public/videos/ceboola_gradient-white_hd.webm
Normal file
BIN
public/videos/ceboola_gradient-white_mobile.mp4
Normal file
BIN
public/videos/ceboola_gradient-white_mobile.ogv
Normal file
BIN
public/videos/ceboola_gradient-white_mobile.webm
Normal file
BIN
public/videos/ceboola_gradient-white_tablet.mp4
Normal file
BIN
public/videos/ceboola_gradient-white_tablet.ogv
Normal file
BIN
public/videos/ceboola_gradient-white_tablet.webm
Normal file
BIN
public/videos/ceboola_gradient-white_twok.mp4
Normal file
BIN
public/videos/ceboola_gradient-white_twok.ogv
Normal file
BIN
public/videos/ceboola_gradient-white_twok.webm
Normal file
BIN
public/videos/ceboola_gradient-white_uhd.mp4
Normal file
BIN
public/videos/ceboola_gradient-white_uhd.ogv
Normal file
BIN
public/videos/ceboola_gradient-white_uhd.webm
Normal file
BIN
public/videos/ceboola_gradient_full.mp4
Normal file
BIN
public/videos/ceboola_gradient_full.ogv
Normal file
BIN
public/videos/ceboola_gradient_full.webm
Normal file
BIN
public/videos/ceboola_gradient_hd.mp4
Normal file
BIN
public/videos/ceboola_gradient_hd.ogv
Normal file
BIN
public/videos/ceboola_gradient_hd.webm
Normal file
BIN
public/videos/ceboola_gradient_mobile.mp4
Normal file
BIN
public/videos/ceboola_gradient_mobile.ogv
Normal file
BIN
public/videos/ceboola_gradient_mobile.webm
Normal file
BIN
public/videos/ceboola_gradient_tablet.mp4
Normal file
BIN
public/videos/ceboola_gradient_tablet.ogv
Normal file
BIN
public/videos/ceboola_gradient_tablet.webm
Normal file
BIN
public/videos/ceboola_gradient_twok.mp4
Normal file
BIN
public/videos/ceboola_gradient_twok.ogv
Normal file
BIN
public/videos/ceboola_gradient_twok.webm
Normal file
BIN
public/videos/ceboola_gradient_uhd.mp4
Normal file
BIN
public/videos/ceboola_gradient_uhd.ogv
Normal file
BIN
public/videos/ceboola_gradient_uhd.webm
Normal file
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
32
scripts/create-better-thumbnail.sh
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Ensure a video file is passed as an argument
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Usage: $0 path/to/video.mp4"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VIDEO_FILE=$1
|
||||
FRAME_FILE="frame.png"
|
||||
OUTPUT_FILE="frame_transparent.png"
|
||||
|
||||
# Extract the first frame as a PNG
|
||||
ffmpeg -i "$VIDEO_FILE" -vf "select=eq(n\,0)" -q:v 3 "$FRAME_FILE"
|
||||
|
||||
# Ensure ffmpeg succeeded
|
||||
if [ ! -f "$FRAME_FILE" ]; then
|
||||
echo "Failed to extract the first frame."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use ImageMagick to convert white to transparent
|
||||
# Replace 'convert' with 'magick' for ImageMagick v7
|
||||
magick "$FRAME_FILE" -fuzz 15% -transparent white "$OUTPUT_FILE"
|
||||
|
||||
# Notify the user of success
|
||||
if [ -f "$OUTPUT_FILE" ]; then
|
||||
echo "The transparent PNG has been saved as $OUTPUT_FILE"
|
||||
else
|
||||
echo "Failed to create a transparent PNG."
|
||||
exit 1
|
||||
fi
|
||||
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
30
src/actions/newsletter.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use server";
|
||||
|
||||
import {
|
||||
NewsletterFormSchema,
|
||||
newsletterFormSchema,
|
||||
} from "@/schemas/newsletter";
|
||||
|
||||
export async function subscribe(data: NewsletterFormSchema) {
|
||||
const result = newsletterFormSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new Error("Invalid data");
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("email", result.data.email);
|
||||
if (result.data.name) {
|
||||
formData.append("name", result.data.name);
|
||||
}
|
||||
// Subscribe to CebulaCamp 2025 newsletter, magic value from newsletter system
|
||||
formData.append("l", "1402b3e7-1e3f-4ab6-b878-4a8478fcef52");
|
||||
|
||||
const res = await fetch("https://news.cebula.camp/subscription/form", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error("Invalid data");
|
||||
}
|
||||
6
src/app/[locale]/[...not-found]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { notFound } from "next/navigation";
|
||||
|
||||
export default function NotFoundDummy() {
|
||||
console.log("Global catch-all 404 page");
|
||||
notFound()
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,10 +2,69 @@ import "../../globals.css";
|
|||
|
||||
import type React from "react";
|
||||
|
||||
import { Lang, locales } from "@/i18n/locales";
|
||||
import { getLocale, Lang } from "@/i18n/locales";
|
||||
import Head from 'next/head';
|
||||
|
||||
import { Oxanium } from "next/font/google";
|
||||
const oxanium = Oxanium({ subsets: ["latin-ext"] })
|
||||
import { ThemeProvider } from "@/components/providers";
|
||||
import { translations } from "@/i18n/translations";
|
||||
|
||||
import { oxanium } from "@/fonts";
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from "next/headers";
|
||||
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: Lang }>
|
||||
}
|
||||
|
||||
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: t.siteTitle,
|
||||
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: t.siteTitle,
|
||||
description: 'An amazing gathering of hackers and open source enthusiasts.',
|
||||
images: ['https://cebula.camp/web-app-manifest-512x512.png'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default async function RootLayout({
|
||||
|
|
@ -13,14 +72,33 @@ export default async function RootLayout({
|
|||
params
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: { locale: Lang }
|
||||
params: Promise<{ locale: Lang }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
const currentLang = locales.includes(locale) ? locale : "en"
|
||||
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}>
|
||||
<html lang={currentLang} className={`${oxanium.className} ${defaultTheme}`}>
|
||||
<Head>
|
||||
<title>{t.siteTitle}</title>
|
||||
</Head>
|
||||
<body className="bg-background text:foreground antialiased">
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
14
src/app/[locale]/loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="relative h-full w-full max-w-[50vw] max-h-[50vh] motion-reduce:animate-bounce motion-safe:animate-spin">
|
||||
<Image src="/web-app-manifest-512x512.png" alt="Loading..." fill className="object-contain" priority />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
21
src/app/[locale]/not-found.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import { jgs7 } from "@/fonts"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div>
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm flex-col">
|
||||
<section className={`text-6xl flex flex-col text-center`}>
|
||||
<span className={`${jgs7.className} text-8xl`}>404</span>
|
||||
<span>Page not found</span>
|
||||
</section>
|
||||
<div className="fixed h-full w-full max-w-[50vw] max-h-[50vh] motion-safe:animate-ping ">
|
||||
<Image src="/web-app-manifest-512x512.png" alt="Loading..." fill className="object-contain" priority />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,119 +1,17 @@
|
|||
"use client"
|
||||
|
||||
import { Nav } from "@/components/nav"
|
||||
import { translations } from "@/i18n/translations"
|
||||
import { useEffect, useRef } from "react"
|
||||
import LandingPage from "@/components/landing-page";
|
||||
import { getLocale, Lang } from "@/i18n/locales";
|
||||
import { translations } from "@/i18n/translations";
|
||||
|
||||
|
||||
export default function Home() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const t = translations.pl // For now using Polish, could be made dynamic
|
||||
export default async function Home(
|
||||
{ params }
|
||||
:
|
||||
{ params: Promise<{ locale: Lang }> }
|
||||
) {
|
||||
const { locale } = await params
|
||||
const currentLocale = getLocale(locale)
|
||||
const t = translations[currentLocale];
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (videoRef.current) {
|
||||
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);
|
||||
return () => window.removeEventListener('scroll', throttledHandleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav t={t} />
|
||||
|
||||
<main className="flex flex-col min-h-screen">
|
||||
<section className="h-screen relative overflow-hidden dark:bg-black">
|
||||
<div className="absolute inset-0 bg-black opacity-80">
|
||||
<video ref={videoRef} autoPlay muted loop playsInline className="w-full h-full object-cover parallax-video ">
|
||||
<source src="/cebula.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
<div className="relative z-10 container mx-auto px-4 h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl md:text-8xl font-bold tracking-tighter mb-6 light:text-background">{t.hero.title}</h1>
|
||||
<p className="text-xl md:text-2xl text-primary">{t.hero.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" className="py-24 bg-background">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold mb-8 tracking-tighter">{t.about.title}</h2>
|
||||
<div className="text-lg text-muted-foreground max-w-3xl mx-auto whitespace-pre-line">
|
||||
{t.about.description}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="where" className="py-24 bg-background/90">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold mb-8 tracking-tighter">{t.where.title}</h2>
|
||||
<div className="text-lg text-muted-foreground">
|
||||
<p>{t.where.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="when" className="py-24 bg-background">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold mb-8 tracking-tighter">{t.when.title}</h2>
|
||||
<div className="text-lg text-muted-foreground">
|
||||
<p className="text-primary text-3xl">{t.when.date}</p>
|
||||
<p className="mt-4">{t.when.extra}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tickets" className="py-24 bg-background/90">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold mb-8 tracking-tighter">{t.tickets.title}</h2>
|
||||
<div className="text-lg text-muted-foreground">
|
||||
<p>{t.tickets.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="accommodation" className="py-24 bg-background">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold mb-8 tracking-tighter">{t.accommodation.title}</h2>
|
||||
<div className="text-lg text-muted-foreground max-w-3xl mx-auto">
|
||||
<p>{t.accommodation.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="food" className="py-24 bg-background/90">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold mb-8 tracking-tighter">{t.food.title}</h2>
|
||||
<div className="text-lg text-muted-foreground max-w-3xl mx-auto">
|
||||
<p>{t.food.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="py-24 bg-background">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold mb-8 tracking-tighter">{t.contact.title}</h2>
|
||||
<div className="text-lg text-muted-foreground">
|
||||
<a href={`mailto:${t.contact.email}`} className="text-primary hover:underline">
|
||||
{t.contact.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
return <LandingPage t={t} currentLocale={currentLocale} />
|
||||
}
|
||||
|
||||
|
|
|
|||
38
src/app/[locale]/pages/[slug]/layout.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NavContainer } from "@/components/nav-container";
|
||||
import { getLocale, Lang } from "@/i18n/locales";
|
||||
import { translations } from "@/i18n/translations";
|
||||
|
||||
export default async function MdxLayout({ children, params }: { children: React.ReactNode, params: Promise<{ locale: Lang }> }) {
|
||||
const { locale } = await (params);
|
||||
const currentLang = getLocale(locale);
|
||||
|
||||
const t = translations[currentLang];
|
||||
return (
|
||||
<div>
|
||||
<NavContainer title={t.nav.title} >{null}</NavContainer>
|
||||
<section className="container mx-auto px-4 py-10">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<article
|
||||
className="prose prose-invert max-w-none
|
||||
prose-h1:font-press-start prose-h1:text-4xl md:prose-h1:text-5xl prose-h1:mb-8 prose-h1:text-center prose-h1:text-foreground
|
||||
prose-h2:font-press-start prose-h2:text-2xl md:prose-h2:text-3xl prose-h2:text-foreground/80
|
||||
prose-h3:font-press-start prose-h3:text-xl md:prose-h3:text-2xl prose-h3:text-foreground/60
|
||||
prose-h4:font-press-start prose-h4:text-l md:prose-h4:text-xl prose-h4:text-foreground/40
|
||||
prose-p:text-muted-foreground prose-p:leading-relaxed
|
||||
prose-a:text-foreground prose-a:no-underline hover:prose-a:text-foreground/80 prose-a:transition-colors
|
||||
prose-strong:text-foreground prose-strong:font-bold
|
||||
prose-code:text-foreground prose-code:bg-muted/20 prose-code:px-1 prose-code:rounded
|
||||
prose-pre:bg-muted/20 prose-pre:border prose-pre:border-muted
|
||||
prose-img:rounded-lg prose-img:border prose-img:border-muted
|
||||
prose-blockquote:border-primary prose-blockquote:text-muted-foreground
|
||||
prose-ul:text-muted-foreground prose-ol:text-muted-foreground
|
||||
prose-li:marker:text-foreground"
|
||||
>
|
||||
{children}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
51
src/app/[locale]/pages/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { getLocale, Lang } from "@/i18n/locales";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; locale: Lang }>;
|
||||
}) {
|
||||
const { slug, locale } = await params;
|
||||
const currentLocale = getLocale(locale);
|
||||
|
||||
const isReallyProperSlug = /^[a-zA-Z0-9_-]+$/.test(slug);
|
||||
|
||||
if (!isReallyProperSlug) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Resolving path: ", `@/pages/${currentLocale}/${slug}.mdx`);
|
||||
const pagemodule = await import(`@/pages/${currentLocale}/${slug}.mdx`);
|
||||
const Post = pagemodule.default;
|
||||
|
||||
return <Post />;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [
|
||||
{
|
||||
locale: "pl",
|
||||
slug: "privacy",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
slug: "privacy",
|
||||
},
|
||||
{
|
||||
locale: "pl",
|
||||
slug: "rules",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
slug: "rules",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const dynamicParams = false;
|
||||
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.104955057760804, 17.087378768775697]} 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.104955057760804, 17.087378768775697]}>
|
||||
<Popup>
|
||||
{t.details.where.location}
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
368
src/components/landing-page.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"use client"
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
|
||||
|
||||
import { jgs7, oxanium } from '@/fonts';
|
||||
import { Lang } from '@/i18n/locales';
|
||||
import { Translations } from "@/i18n/translations";
|
||||
import { getWikiUrl, getCfpScheduleUrl, getEmailUrl } from '@/lib/external-links';
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from 'next/link';
|
||||
import { ReactElement, useEffect, useRef } from "react";
|
||||
import { MainpageNav } from './nav';
|
||||
import { NewsletterPopup } from './newsletter-form';
|
||||
import { useTheme } from "./providers";
|
||||
import { Button } from './ui/button';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
|
||||
function Heading({ children }: { children: ReactElement | string }) {
|
||||
return <h2 className="text-5xl font-bold tracking-tighter max-w-3xl mx-auto">{children}</h2>
|
||||
}
|
||||
|
||||
function Subheading({ children }: { children: ReactElement | string }) {
|
||||
return <h3 className="text-3xl font-bold tracking-tighter max-w-3xl mx-auto">{children}</h3>
|
||||
}
|
||||
|
||||
function TinyHeading({ children }: { children: ReactElement | string }) {
|
||||
return <h4 className="text-lg font-bold tracking-tighter max-w-3xl mx-auto mb-2">{children}</h4>
|
||||
}
|
||||
|
||||
function TinyTextWrapper({ children }: { children: ReactElement }) {
|
||||
return <div className="text-sm text-muted-foreground max-w-3xl mx-auto whitespace-pre-line">
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
function TextWrapper({ children }: { children: ReactElement }) {
|
||||
return <div className="text-lg text-muted-foreground max-w-3xl mx-auto whitespace-pre-line">
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
function NewSection({
|
||||
id,
|
||||
children,
|
||||
after
|
||||
}: {
|
||||
id: string
|
||||
children: ReactElement;
|
||||
after?: ReactElement;
|
||||
}) {
|
||||
return (<section id={id} className="bg-background">
|
||||
<div className="container mx-auto px-4 gap-6 flex flex-col">
|
||||
{children}
|
||||
{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', `_twok.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
|
||||
<source
|
||||
key={`uhd-${type}`}
|
||||
media="(max-width: 3840px)"
|
||||
src={src.replace('.mp4', `_uhd.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
|
||||
<source
|
||||
key={`full-${type}`}
|
||||
media="(min-width: 3841px)"
|
||||
src={src.replace('.mp4', `_full.${type}`)}
|
||||
type={sourceType}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
function Video({ sourceBase, hidden }: {
|
||||
sourceBase: string;
|
||||
hidden: boolean;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || hidden) return;
|
||||
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, currentLocale }: { t: Translations, currentLocale: Lang }
|
||||
) {
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MainpageNav t={t} currentLocale={currentLocale} />
|
||||
<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 ${jgs7.className}`}>
|
||||
<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.details.when.date}</p>
|
||||
<div className='flex flex-col space-y-4 items-center justify-center m-auto'>
|
||||
<div className='flex flex-row gap-4'>
|
||||
<Button className={`${oxanium.className} text-xl uppercase cursor-pointer`}>
|
||||
<Link href={getCfpScheduleUrl(currentLocale)} className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{t.contribute.agenda.title}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className={`${oxanium.className} text-xl uppercase cursor-pointer`}>
|
||||
<Link href={getWikiUrl(currentLocale)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
{t.nav.wiki}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<NewsletterPopup t={t} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NewSection id="about">
|
||||
<>
|
||||
<div>
|
||||
<Heading>{t.about.title}</Heading>
|
||||
</div>
|
||||
<section>
|
||||
<TextWrapper><p>{t.about.description}</p></TextWrapper>
|
||||
</section>
|
||||
<section>
|
||||
<Subheading>{t.details.when.title}</Subheading>
|
||||
<TextWrapper><p className={`text-primary text-3xl ${jgs7.className}`}>{t.details.when.date}</p></TextWrapper>
|
||||
<TextWrapper><p>{t.details.when.extra}</p></TextWrapper>
|
||||
</section>
|
||||
<section>
|
||||
<Subheading>{t.details.where.title}</Subheading>
|
||||
<TextWrapper><p>{t.details.where.location}</p></TextWrapper>
|
||||
<LazyLeafletMap t={t} />
|
||||
</section>
|
||||
</>
|
||||
</NewSection>
|
||||
|
||||
<NewSection id="tickets">
|
||||
<>
|
||||
<div>
|
||||
<Heading>{t.tickets.title}</Heading>
|
||||
</div>
|
||||
<section>
|
||||
<TextWrapper><p className="text-2xl font-bold text-primary">{t.tickets.status}</p></TextWrapper>
|
||||
</section>
|
||||
</>
|
||||
</NewSection>
|
||||
|
||||
<NewSection id="contribute">
|
||||
<>
|
||||
<div>
|
||||
<Heading>{t.contribute.agenda.title}</Heading>
|
||||
</div>
|
||||
<section>
|
||||
<TextWrapper>
|
||||
<div className="border border-border rounded-md bg-muted/30 p-8 text-center">
|
||||
<div className="mb-6">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto text-primary mb-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-lg text-muted-foreground mb-4">
|
||||
{t.contribute.agenda.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button className={`${oxanium.className} text-xl uppercase cursor-pointer`} size="lg">
|
||||
<Link href={getCfpScheduleUrl(currentLocale)}>
|
||||
{t.contribute.agenda.viewButton}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TextWrapper>
|
||||
</section>
|
||||
</>
|
||||
</NewSection>
|
||||
|
||||
<NewSection id="details">
|
||||
<>
|
||||
<div>
|
||||
<Heading>{t.details.title}</Heading>
|
||||
</div>
|
||||
<section>
|
||||
<Subheading>{t.faq.accommodation.title}</Subheading>
|
||||
<TextWrapper><p>{t.faq.accommodation.description}</p></TextWrapper>
|
||||
</section>
|
||||
<section>
|
||||
<Subheading>{t.faq.accesibility.title}</Subheading>
|
||||
<TextWrapper><p>{t.faq.accesibility.description} <a href={getEmailUrl()}>{t.contact.email}</a></p></TextWrapper>
|
||||
</section>
|
||||
<section>
|
||||
<Subheading>{t.faq.food.title}</Subheading>
|
||||
<TextWrapper><p>{t.faq.food.description}</p></TextWrapper>
|
||||
</section>
|
||||
<section>
|
||||
<Subheading>{t.faq.transport.title}</Subheading>
|
||||
<TextWrapper><p>{t.faq.transport.description}</p></TextWrapper>
|
||||
</section>
|
||||
<section>
|
||||
<Subheading>{t.faq.documents.title}</Subheading>
|
||||
<TextWrapper><p>
|
||||
<a className='hover:text-primary' href={`/${currentLocale}/pages/rules`}>📜 {t.faq.documents.rules}</a></p></TextWrapper>
|
||||
<TextWrapper><p>
|
||||
<a className='hover:text-primary' href={`/${currentLocale}/pages/privacy`}>📜 {t.faq.documents.privacyPolicy}</a></p></TextWrapper>
|
||||
<TextWrapper><p>
|
||||
<a className='hover:text-primary' href={getWikiUrl(currentLocale)} target="_blank" rel="noopener noreferrer">📚 {t.faq.documents.wiki}</a></p></TextWrapper>
|
||||
</section>
|
||||
</>
|
||||
</NewSection>
|
||||
|
||||
<NewSection id="contact">
|
||||
<>
|
||||
<div>
|
||||
<Heading>{t.contact.title}</Heading>
|
||||
</div>
|
||||
<section>
|
||||
<TextWrapper><p>{t.contact.details.line1}</p></TextWrapper>
|
||||
<br />
|
||||
<TextWrapper><p>📬 <a href={getEmailUrl()}>{t.contact.email}</a> {t.contact.details.line2}</p></TextWrapper>
|
||||
<br />
|
||||
<TextWrapper><p>{t.contact.details.line3}</p></TextWrapper>
|
||||
<br />
|
||||
<TextWrapper><p>{t.contact.details.line4}</p></TextWrapper>
|
||||
</section>
|
||||
</>
|
||||
</NewSection>
|
||||
|
||||
<NewSection id="credits">
|
||||
<>
|
||||
<section className='text-sm'>
|
||||
<TinyHeading>{t.credits.title}</TinyHeading>
|
||||
<TinyTextWrapper><p>{t.credits.usedFonts}</p></TinyTextWrapper>
|
||||
<TinyTextWrapper><p><a className="hover:underline" href="https://velvetyne.fr/fonts/jgs-font/">{t.credits.jgs7}</a></p></TinyTextWrapper>
|
||||
<TinyTextWrapper><p><a className="hover:underline" href="https://fonts.google.com/specimen/Oxanium">{t.credits.oxanium}</a></p></TinyTextWrapper>
|
||||
</section>
|
||||
</>
|
||||
</NewSection>
|
||||
</main>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
@ -2,15 +2,53 @@
|
|||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import type { translations } from "@/i18n/translations"
|
||||
import type { Sections, translations } from "@/i18n/translations"
|
||||
import { Menu } from "lucide-react"
|
||||
import ScrollSpy from "react-scrollspy-navigation"
|
||||
import { LanguageSelector } from "./ui/language-selector"
|
||||
|
||||
function NavContent({
|
||||
t,
|
||||
linksOrder,
|
||||
externalLinks
|
||||
}: {
|
||||
t: typeof translations.pl,
|
||||
linksOrder: Array<Sections>,
|
||||
externalLinks: Record<string, string>
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex flex-col gap-4 mt-8">
|
||||
<ScrollSpy activeClass="nav-active" offsetTop={80}>
|
||||
|
||||
{linksOrder.map((value) => {
|
||||
const isExternal = value in externalLinks;
|
||||
const href = isExternal ? externalLinks[value] : `#${value}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={value}
|
||||
href={href}
|
||||
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
className="text-lg hover:text-primary transition-colors"
|
||||
>
|
||||
{t.nav[value]}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</ScrollSpy>
|
||||
<LanguageSelector />
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileNav({
|
||||
t,
|
||||
linksOrder,
|
||||
externalLinks,
|
||||
}: {
|
||||
t: typeof translations.pl
|
||||
linksOrder: Array<keyof (typeof translations.pl)["nav"]>
|
||||
linksOrder: Array<Sections>
|
||||
externalLinks: Record<string, string>
|
||||
}) {
|
||||
return (
|
||||
<Sheet >
|
||||
|
|
@ -20,17 +58,11 @@ export function MobileNav({
|
|||
<span className="sr-only">{t.mobileNav.toggleMenu}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[80vw] sm:w-[385px]">
|
||||
<SheetContent side="right" className="w-[80vw] sm:w-[385px] z-max">
|
||||
<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="text-lg hover:text-primary transition-colors">
|
||||
{t.nav[value]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<NavContent t={t} linksOrder={linksOrder} externalLinks={externalLinks} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
|
|
|||
40
src/components/nav-container.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "./providers";
|
||||
import { LanguageSelector } from "./ui/language-selector";
|
||||
|
||||
export function NavContainer({ children, title, }: { children: React.ReactNode, title: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
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">
|
||||
<Link href="/" className="text-xl font-bold tracking-tighter hover:text-primary transition-colors">
|
||||
<h1>{title}</h1>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{children}
|
||||
<LanguageSelector />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<SunIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MoonIcon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,122 +1,62 @@
|
|||
"use client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { translations } from "@/i18n/translations"
|
||||
import { type translations } from "@/i18n/translations"
|
||||
import { Lang } from '@/i18n/locales'
|
||||
import { getWikiUrl } from '@/lib/external-links'
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MoonIcon, SunIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import ScrollSpy from 'react-scrollspy-navigation'
|
||||
import { MobileNav } from "./mobile-nav"
|
||||
import { NavContainer } from "./nav-container"
|
||||
|
||||
|
||||
const linksOrder: Array<keyof (typeof translations.pl)["nav"]> = [
|
||||
"about",
|
||||
"where",
|
||||
"when",
|
||||
"tickets",
|
||||
"accommodation",
|
||||
"food",
|
||||
"contact",
|
||||
'about',
|
||||
'tickets',
|
||||
'contribute',
|
||||
'details',
|
||||
'wiki',
|
||||
'contact'
|
||||
]
|
||||
|
||||
export function Nav({
|
||||
export function MainpageNav({
|
||||
t,
|
||||
currentLocale = 'pl' as Lang
|
||||
}: {
|
||||
t: typeof translations.pl
|
||||
currentLocale?: Lang
|
||||
}) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("dark")
|
||||
const [activeSection, setActiveSection] = useState<keyof (typeof translations.pl)["nav"]>("about")
|
||||
|
||||
useEffect(() => {
|
||||
// Determine the user's preferred color scheme
|
||||
const preferredTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
setTheme(preferredTheme)
|
||||
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(preferredTheme)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
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}`);
|
||||
const externalLinks: Record<string, string> = {
|
||||
'wiki': getWikiUrl(currentLocale)
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 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 z-50 bg-background/80 backdrop-blur-sm border-b">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<a href="#" className="text-xl font-bold tracking-tighter hover:text-primary transition-colors">
|
||||
CEBULACAMP
|
||||
</a>
|
||||
<div className="flex items-center">
|
||||
{/* Desktop Navigation */}
|
||||
<NavContainer title={t.nav.title}>
|
||||
<>
|
||||
<div className="hidden md:flex md:items-center md:gap-4 lg:gap-8">
|
||||
{linksOrder.map((value) => (
|
||||
<ScrollSpy activeClass="nav-active" offsetTop={80}>
|
||||
|
||||
{linksOrder.map((value) => {
|
||||
const isExternal = value in externalLinks;
|
||||
const href = isExternal ? externalLinks[value] : `#${value}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={value}
|
||||
href={`#${value}`}
|
||||
className={cn("text-sm hover:text-primary transition-colors relative group", {
|
||||
'text-primary': activeSection === value
|
||||
})}
|
||||
href={href}
|
||||
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
className="text-sm md:text-md hover:text-primary transition-colors relative group will-change-[color]"
|
||||
>
|
||||
{t.nav[value]}
|
||||
<span className="absolute inset-x-0 -bottom-1 h-0.5 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform" />
|
||||
<span data-sub={value} className={cn("absolute inset-x-0 -bottom-1 h-0.5 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform will-change-transform", {
|
||||
})} />
|
||||
</a>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ScrollSpy>
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<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} />
|
||||
<MobileNav t={t} linksOrder={linksOrder} externalLinks={externalLinks} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
</NavContainer>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
131
src/components/newsletter-form.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { subscribe } from "@/actions/newsletter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { oxanium } from "@/fonts";
|
||||
import { Translations } from "@/i18n/translations";
|
||||
import {
|
||||
NewsletterFormSchema,
|
||||
newsletterFormSchema,
|
||||
} from "@/schemas/newsletter";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
export function NewsletterPopup({ t }: { t: Translations }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className={`${oxanium.className} text-xl mt-4 uppercase cursor-pointer`}>{t.newsletter.popupButton}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.newsletter.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.newsletter.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SubscriptionForm t={t} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionForm({ t }: { t: Translations }) {
|
||||
const form = useForm<NewsletterFormSchema>({
|
||||
resolver: zodResolver(newsletterFormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
name: "",
|
||||
uodo: true,
|
||||
},
|
||||
});
|
||||
|
||||
const succeded = form.formState.isValid && form.formState.isSubmitSuccessful;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(subscribe)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={t.newsletter.emailField} type="email" required {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder={t.newsletter.nameField} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="uodo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="uodo"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel htmlFor="uodo">
|
||||
{t.newsletter.policyPrivacyCheckboxTitle}
|
||||
</FormLabel>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t.newsletter.policyPrivacyCheckboxDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={form.formState.isSubmitting || succeded}
|
||||
type="submit"
|
||||
className="w-full cursor-pointer uppercase"
|
||||
>
|
||||
{succeded
|
||||
? t.newsletter.submitSuccess
|
||||
: form.formState.isSubmitting
|
||||
? t.newsletter.submitPending
|
||||
: t.newsletter.subscribeButton
|
||||
}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from "react"
|
||||
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'
|
||||
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-none 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",
|
||||
|
|
|
|||
76
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
30
src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
122
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/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}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
178
src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
22
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
38
src/components/ui/language-selector.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
|
||||
import { Lang } from "@/i18n/locales";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { Button } from "./button";
|
||||
import { LanguagesIcon } from "lucide-react";
|
||||
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const params = useParams<{ locale: Lang }>();
|
||||
const pathname = usePathname() ?? ''
|
||||
|
||||
const replacements = {
|
||||
'pl': 'en',
|
||||
'en': 'pl',
|
||||
}
|
||||
|
||||
const lang = params?.locale || 'pl';
|
||||
const otherLang = replacements[lang];
|
||||
const changedLang = pathname.replace(`/${lang}`, `/${otherLang}`)
|
||||
|
||||
return (<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:ml-6 w-15"
|
||||
>
|
||||
<Link
|
||||
href={changedLang}
|
||||
className="flex space-x-2 items-center"
|
||||
>
|
||||
<LanguagesIcon className="h-5 w-5 mr-1" />
|
||||
<span>{otherLang.toUpperCase()}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</>);
|
||||
};
|
||||
|
|
@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"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}
|
||||
|
|
@ -64,7 +64,7 @@ const SheetContent = React.forwardRef<
|
|||
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-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<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>
|
||||
|
|
|
|||
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
|
Before Width: | Height: | Size: 25 KiB |
10
src/fonts.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import localFont from "next/font/local";
|
||||
export const oxanium = localFont({
|
||||
src: "./fonts/Oxanium-VariableFont_wght.ttf",
|
||||
variable: "--font-oxanium",
|
||||
});
|
||||
|
||||
export const jgs7 = localFont({
|
||||
src: "./fonts/jgs7.ttf",
|
||||
variable: "--font-jgs7",
|
||||
});
|
||||
BIN
src/fonts/Oxanium-VariableFont_wght.ttf
Normal file
BIN
src/fonts/jgs7.ttf
Normal file
BIN
src/fonts/jgs7.woff
Normal file
BIN
src/fonts/jgs7.woff2
Normal file
|
|
@ -1,14 +1,77 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@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 {
|
||||
html {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
|
|
@ -72,9 +135,7 @@ html {
|
|||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
|
@ -89,3 +150,20 @@ html {
|
|||
.parallax-video {
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Fix for leaflet's weird z-index */
|
||||
.z-max {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
section {
|
||||
scroll-margin-top: calc(var(--spacing) * 16 + var(--spacing) * 4);
|
||||
}
|
||||
|
||||
.nav-active {
|
||||
color: var(--color-primary) !important;
|
||||
& span {
|
||||
--tw-scale-x: 100%;
|
||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,9 @@
|
|||
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];
|
||||
|
|
|
|||
|
|
@ -1,59 +1,232 @@
|
|||
export const translations = {
|
||||
pl: {
|
||||
const common = {
|
||||
jgs7: "JGS font Jgs font by Adel Faure. Distributed by velvetyne.fr",
|
||||
oxanium: "Oxanium",
|
||||
orgaEmail: "orga@cebula.camp",
|
||||
};
|
||||
|
||||
const pl = {
|
||||
siteTitle: "CebulaCamp 2025",
|
||||
nav: {
|
||||
about: "O nas",
|
||||
when: "Kiedy",
|
||||
where: "Gdzie",
|
||||
food: "Wyżywienie",
|
||||
contact: "Kontakt",
|
||||
title: "CEBULACAMP",
|
||||
about: "O wydarzeniu",
|
||||
tickets: "Bilety",
|
||||
accommodation: "Nocleg",
|
||||
contribute: "Agenda",
|
||||
details: "FAQ",
|
||||
contact: "Kontakt",
|
||||
wiki: "Wiki",
|
||||
},
|
||||
mobileNav: {
|
||||
toggleMenu: "Aktywnuj menu",
|
||||
toggleMenu: "Aktywuj menu",
|
||||
menu: "Menu",
|
||||
|
||||
},
|
||||
hero: {
|
||||
title: "CEBULACAMP 2025",
|
||||
subtitle: "REAKTYWACJA",
|
||||
},
|
||||
about: {
|
||||
title: "O nas",
|
||||
title: "O wydarzeniu",
|
||||
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\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.",
|
||||
"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.",
|
||||
},
|
||||
details: {
|
||||
title: "FAQ",
|
||||
where: {
|
||||
title: "Gdzie",
|
||||
title: "Lokalizacja",
|
||||
location:
|
||||
"klub Łącznik, Tramwajowa 1-3, Wrocław, obok Hackerspace Wrocław",
|
||||
"Wydarzenie odbędzie się w Centrum Kultury Akademickiej i Inicjatyw Lokalnych Czasoprzestrzeń na terenie dawnej zajezdni tramwajowej. Centralnym punktem spotkań będzie klub Łącznik gdzie będzie miejsce na prelekcje i wspólne hackowanie.\n\nKlub „Łą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",
|
||||
"Chętnych do pomocy w przygotowaniach zapraszamy już na Day 0, 27 sierpnia",
|
||||
},
|
||||
},
|
||||
tickets: {
|
||||
title: "Bilety",
|
||||
status: "soon",
|
||||
status: `Bilety na Cebula Camp 2025: Reaktywacja zostały wyprzedane!`,
|
||||
},
|
||||
contribute: {
|
||||
title: "Zgłoś się!",
|
||||
agenda: {
|
||||
title: "Agenda",
|
||||
description: "Zobacz pełny harmonogram wydarzeń, prelekcji i warsztatów",
|
||||
viewButton: "Zobacz agendę",
|
||||
},
|
||||
},
|
||||
faq: {
|
||||
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.",
|
||||
description: `Dla uczestników przewidziano możliwość noclegu we własnych namiotach na zielonym trawniku znajdującym się na terenie zajezdni. Teren całej zajezdni jest ogrodzony, natomiast nie jest strzeżony. Toalety i prysznic są dostępne dla wszystkich uczestników, a ich infrastruktura została dostosowana do potrzeb osób o ograniczonej sprawności ruchowej.\n\nOsoby preferujące inne warianty noclegowe mogą skorzystać z oferty Hotelu Zoo lub pobliskich akademików.`,
|
||||
},
|
||||
accesibility: {
|
||||
title: "Dostępność",
|
||||
description: `Cały teren znajduje się na poziomie gruntu. Warto jednak pamiętać, że część nawierzchni jest wyłożona kostką brukową, co może być trudniejsze do pokonania dla osób korzystających z wózków inwalidzkich. Osoby potrzebujące jakiegokolwiek wsparcia w zakresie dostępności prosimy o kontakt: `,
|
||||
},
|
||||
food: {
|
||||
title: "Wyżywienie",
|
||||
description:
|
||||
"we własnym zakresie, w okolicy dostępne są knajpy z dowozem, wieczory planujemy umilić wspólnym grillowaniem.",
|
||||
"W bezpośrednim sąsiedztwie znajduje się Biedronka oraz wiele lokali gastronomicznych oferujących dowóz jedzenia, co zapewni uczestnikom wygodę i dostęp do posiłków przez cały czas trwania wydarzenia. Planujemy również mały kącik kawowo-herbaciany oraz wieczory ze wspólnym grillowaniem.",
|
||||
},
|
||||
transport: {
|
||||
title: "Transport i parking",
|
||||
description:
|
||||
"Jako że wydarzenie organizowane jest terenie zajezdni tramwajowej to mamy w pobliżu przystanek linii tramwajowych i autobusowych dojeżdżających do centrum oraz dworca głównego. Na terenie zajezdni znajduje się parking.",
|
||||
},
|
||||
documents: {
|
||||
title: "Dokumenty",
|
||||
rules: "Regulamin wydarzenia",
|
||||
privacyPolicy: "Polityka prywatności",
|
||||
wiki: "Wiki CebulaCamp",
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: "Kontakt",
|
||||
email: "contact@cebula.camp",
|
||||
email: common.orgaEmail,
|
||||
details: {
|
||||
line1: `Masz pytania, pomysły, albo po prostu chcesz się przywitać?`,
|
||||
line2: `— napisz śmiało.`,
|
||||
line3: `Szukasz sposobu, żeby się zaangażować, poprowadzić warsztat, zasponsorować coś szalonego albo po prostu poczuć klimat? Odezwij się! Jesteśmy małą, przyjazną ekipą i czytamy każdą wiadomość (tak, nawet te dziwne).`,
|
||||
line4: `Widzimy się we Wrocławiu 🧅`,
|
||||
},
|
||||
},
|
||||
en: {
|
||||
// English translations would go here
|
||||
credits: {
|
||||
title: "Uznania",
|
||||
usedFonts: "Użyte fonty:",
|
||||
oxanium: common.oxanium,
|
||||
jgs7: common.jgs7,
|
||||
},
|
||||
|
||||
newsletter: {
|
||||
emailField: "E-mail",
|
||||
nameField: "Imię/ksywa (opcjonalne)",
|
||||
popupButton: "Zapisz się do newslettera",
|
||||
title: "Newsletter CebulaCamp 2025",
|
||||
description: "Zasubskrybuj aby otrzymywać najnowsze wiadomości",
|
||||
subscribeButton: "Subskrybuj",
|
||||
submitSuccess: "Dziękujemy za subskrypcję!",
|
||||
submitPending: "Subskrybuję...",
|
||||
policyPrivacyCheckboxTitle: "Akceptuję politykę prywatności",
|
||||
policyPrivacyCheckboxDescription:
|
||||
"Zgadzam się na przetwarzanie przekazanych danych osobowych przez Hackerspace Wrocław w ramach otrzymywania wiadomości na temat wydarzenia.",
|
||||
},
|
||||
};
|
||||
|
||||
const en = {
|
||||
siteTitle: "CebulaCamp 2025",
|
||||
nav: {
|
||||
title: "CEBULACAMP",
|
||||
about: "About the event",
|
||||
tickets: "Tickets",
|
||||
contribute: "Schedule",
|
||||
details: "FAQ",
|
||||
contact: "Contact",
|
||||
wiki: "Wiki",
|
||||
},
|
||||
mobileNav: {
|
||||
toggleMenu: "Toggle menu",
|
||||
menu: "Menu",
|
||||
},
|
||||
hero: {
|
||||
title: "CEBULACAMP 2025",
|
||||
subtitle: "REACTIVATED",
|
||||
},
|
||||
about: {
|
||||
title: "About the event",
|
||||
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.",
|
||||
},
|
||||
details: {
|
||||
title: "FAQ",
|
||||
where: {
|
||||
title: "Location",
|
||||
location: `The event will take place at the “Centre for Academic Culture and Local Initiatives Czasoprzestrzeń” on the premises of a former tram depot. The central meeting point will be the “Łącznik” club, where there will be a place for lectures and communal hacking.\n\nClub "Łącznik", 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: `Tickets for Cebula Camp 2025: Reactivated are sold out!`,
|
||||
},
|
||||
contribute: {
|
||||
title: "Contribute!",
|
||||
agenda: {
|
||||
title: "Schedule",
|
||||
description: "View the full schedule of events, talks, and workshops",
|
||||
viewButton: "View agenda",
|
||||
},
|
||||
},
|
||||
faq: {
|
||||
accommodation: {
|
||||
title: "Accommodation",
|
||||
description: `Participants will have the option of staying overnight in their own tents on the green lawn located on the depot grounds. The entire depot area is fenced, but not guarded. Toilets and showers are available to all participants, and their infrastructure has been adapted to the needs of people with mobility issues.\n\nPeople who prefer other accommodation options can take advantage of the offer of the Zoo Hotel or nearby dormitories.`,
|
||||
},
|
||||
accesibility: {
|
||||
title: "Accessibility",
|
||||
description: `The entire area is at ground level. However, it is worth remembering that part of the surface is paved with cobblestones, which may be more difficult for people using wheelchairs. People who need any support in terms of accessibility, please contact: `,
|
||||
},
|
||||
food: {
|
||||
title: "Food",
|
||||
description:
|
||||
"In the immediate vicinity there is a Biedronka (discount grocery store) and many restaurants offering food delivery, which should provide participants with convenience and access to meals throughout the event. We are also planning a small coffee and tea corner and evenings with a communal barbecue.",
|
||||
},
|
||||
transport: {
|
||||
title: "Transport and parking",
|
||||
description:
|
||||
"As the event is organized at a tram depot, there is a tram and bus stop nearby, going to the city center and the main rail station. There is a parking lot at the venue.",
|
||||
},
|
||||
documents: {
|
||||
title: "Documents",
|
||||
rules: "Terms and Conditions",
|
||||
privacyPolicy: "Privacy Policy",
|
||||
wiki: "CebulaCamp Wiki",
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: "Contact",
|
||||
email: common.orgaEmail,
|
||||
details: {
|
||||
line1: `Got questions, cool ideas, or just want to say hi?`,
|
||||
line2: `— we're listening.`,
|
||||
line3: `Whether you’re looking to help out, run a workshop, sponsor something wild, or just want to vibe with the crew—drop us a line. We're a small, friendly team and we read every message (yes, even the weird ones).`,
|
||||
line4: `See you in Wrocław 🧅`,
|
||||
},
|
||||
},
|
||||
credits: {
|
||||
title: "Credits",
|
||||
usedFonts: "Used fonts:",
|
||||
oxanium: common.oxanium,
|
||||
jgs7: common.jgs7,
|
||||
},
|
||||
|
||||
newsletter: {
|
||||
emailField: "E-mail",
|
||||
nameField: "Name/nickname (optional)",
|
||||
popupButton: "Subscribe to newsletter",
|
||||
title: "Newsletter CebulaCamp 2025",
|
||||
description: "Subscribe for live news",
|
||||
subscribeButton: "Subscribe",
|
||||
submitSuccess: "Thank you for subscribing!",
|
||||
submitPending: "Subscribing...",
|
||||
policyPrivacyCheckboxTitle: "I accept the Privacy Policy",
|
||||
policyPrivacyCheckboxDescription:
|
||||
"I agree to the processing of the provided personal data by “Stowarzyszenie Hackerspace Wrocław” for the purpose of receiving information about the event.",
|
||||
},
|
||||
};
|
||||
|
||||
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"];
|
||||
|
|
|
|||
35
src/lib/external-links.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Lang } from "@/i18n/locales";
|
||||
|
||||
export const EXTERNAL_URLS = {
|
||||
email: "orga@cebula.camp",
|
||||
} as const;
|
||||
|
||||
export function getWikiUrl(locale: Lang): string {
|
||||
return `https://wiki.cebula.camp/${locale}/home`;
|
||||
}
|
||||
|
||||
export function getCfpScheduleUrl(locale: Lang): string {
|
||||
return `https://cfp.cebula.camp/camp-2025/locale/set?locale=${locale}&next=/camp-2025/schedule/`;
|
||||
}
|
||||
|
||||
export function getCfpUrl(): string {
|
||||
return `https://cfp.cebula.camp/camp-2025/cfp`;
|
||||
}
|
||||
|
||||
export function getCfpArtUrl(): string {
|
||||
return `https://cfp.cebula.camp/camp-2025-art/cfp`;
|
||||
}
|
||||
|
||||
export function getEmailUrl(): string {
|
||||
return `mailto:${EXTERNAL_URLS.email}`;
|
||||
}
|
||||
|
||||
export function getExternalLinks(locale: Lang) {
|
||||
return {
|
||||
wiki: getWikiUrl(locale),
|
||||
cfpSchedule: getCfpScheduleUrl(locale),
|
||||
cfp: getCfpUrl(),
|
||||
cfpArt: getCfpArtUrl(),
|
||||
email: getEmailUrl(),
|
||||
};
|
||||
}
|
||||
7
src/mdx-components.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { MDXComponents } from "mdx/types";
|
||||
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
...components,
|
||||
};
|
||||
}
|
||||
|
|
@ -27,5 +27,7 @@ export function middleware(request: NextRequest) {
|
|||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
matcher: "/((?!api|static|.*\\..*|_next).*)",
|
||||
|
||||
// matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
109
src/pages/en/privacy.mdx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Cebula Camp 2025 Privacy Policy
|
||||
|
||||
(note: this is an informal translation, the legally binding document is its [Polish version](https://cebula.camp/pl/pages/privacy)).
|
||||
|
||||
This policy applies to our websites (later: Websites) operating under the following URLs: [cebula.camp](https://cebula.camp), [cfp.cebula.camp](https://cfp.cebula.camp), [tickets.cebula.camp](https://tickets.cebula.camp), and [news.cebula.camp](https://news.cebula.camp).
|
||||
|
||||
The Administrator and Operator of personal data is “Stowarzyszenie Hackerspace Wrocław” (registered under KRS number: 0000531222) with its registered address at ul. Wróblewskiego 38, 51-627 Wrocław.
|
||||
|
||||
For inquiries regarding personal data, contact us at: [kontakt@hswro.org](mailto:kontakt@hswro.org).
|
||||
|
||||
---
|
||||
|
||||
## User Rights
|
||||
|
||||
Providing personal data is voluntary but necessary for providing our service.
|
||||
|
||||
By contacting the data administrator, users can request:
|
||||
|
||||
- Access to their personal data,
|
||||
- Correction,
|
||||
- Deletion,
|
||||
- Data transfer,
|
||||
- Restriction of processing,
|
||||
- Objection to further processing.
|
||||
|
||||
Users have the right to file a complaint regarding data processing by the Operator with the supervisory authority: Personal Data Protection Office. Contact details can be found at: [https://uodo.gov.pl/](https://uodo.gov.pl/).
|
||||
|
||||
---
|
||||
|
||||
## Collected Data
|
||||
|
||||
Websites collect personal data for the following purposes:
|
||||
|
||||
- Fulfilling of ordered services,
|
||||
- Newsletter distribution,
|
||||
- Collecting talk proposals.
|
||||
|
||||
Data collection methods include:
|
||||
|
||||
- Voluntarily entered data in forms, stored in the Operator's systems,
|
||||
- Storing cookies on users' devices.
|
||||
|
||||
---
|
||||
|
||||
## Ticket Sales
|
||||
|
||||
When selling tickets, we collect the following personal data:
|
||||
|
||||
1. Buyer's email address (for identification and ticket-related contact),
|
||||
2. Participant's name and preferred pronouns (if provided, for communication),
|
||||
3. Buyer's full name, address, and bank account number (for order processing).
|
||||
|
||||
Cookies are used to facilitate purchases and maintain cart status. We do not use tracking or marketing cookies.
|
||||
|
||||
For payments via bank transfer, our bank and authorized accounting personnel will have access to the sender's transaction details. These details will be processed and stored solely for accounting documentation purposes.
|
||||
|
||||
---
|
||||
|
||||
## Newsletter
|
||||
|
||||
For newsletter management, we collect:
|
||||
|
||||
1. Subscriber's email address,
|
||||
2. Subscriber's name or nickname (if provided).
|
||||
|
||||
The email and name (if provided) will be transmitted in an unencrypted form to the subscriber's email server. No personal data is shared with third parties.
|
||||
|
||||
Sent emails contain an embedded remote image ('tracking pixel'). This is used to analyze the approximate deliverability of sent messages. Images do not individually identify subscribers, and no metadata about the subscriber is collected.
|
||||
|
||||
---
|
||||
|
||||
## CFP (Call for Papers)
|
||||
|
||||
For collecting talk proposals, we gather:
|
||||
|
||||
1. Submitter's email address,
|
||||
2. Submitter's name or nickname.
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
|
||||
To ensure technical reliability, server logs are maintained. These may include:
|
||||
|
||||
- URLs of requested resources (pages, files),
|
||||
- Request timestamps,
|
||||
- Client station name (HTTP protocol identification),
|
||||
- Error information from HTTP transactions,
|
||||
- User browser details,
|
||||
- IP address information,
|
||||
- Diagnostic data related to self-service order processing,
|
||||
- Email correspondence related to the Operator.
|
||||
|
||||
---
|
||||
|
||||
## Data Processing Methods
|
||||
|
||||
In certain cases, the Administrator may share personal data with other recipients when necessary to fulfill a contract or comply with legal obligations. Recipients may include:
|
||||
|
||||
- Hosting service providers (data processing agreement),
|
||||
- Authorized employees and collaborators using the data for specific purposes.
|
||||
|
||||
Personal data is processed only as long as necessary for purposes defined by legal regulations (e.g., accounting). After this period, all data is anonymized.
|
||||
|
||||
Personal data is not transferred to third countries under data protection regulations, meaning it is not sent outside the European Union.
|
||||
|
||||
Login and personal data entry areas are protected with SSL encryption. This ensures that personal data and login credentials entered on the website are encrypted on the user's device and can only be read by the target server.
|
||||
|
||||
Return to the [homepage](/en/)
|
||||