Compare commits

...

60 commits

Author SHA1 Message Date
Dariusz Niemczyk cd3e323464
fix: eslint
All checks were successful
/ deploy (push) Successful in 1s
2025-08-28 18:35:52 +02:00
Dariusz Niemczyk 7bceaab551
refactor: centralize external links configuration
All checks were successful
/ deploy (push) Successful in 1s
- Create external-links.ts with all external URLs in one place
- Add locale-aware functions for wiki and CFP links
- Update nav.tsx and landing-page.tsx to use centralized config
- Remove duplicate URL definitions across components
2025-08-27 18:22:00 +02:00
Dariusz Niemczyk 8a629c3de8
feat: simplify navigation and add wiki link
All checks were successful
/ deploy (push) Successful in 3s
- Remove standalone agenda page (deleted agenda/page.tsx)
- Remove CFP/art submission sections from contribute area
- Add direct wiki link to navigation and FAQ documents
- Add wiki button alongside schedule button in hero section
- Update navigation to include wiki as external link
- Clean up translations by removing unused CFP/art text
2025-08-27 14:09:04 +02:00
Mewp 10bfa5e561 Fix schedule link 2025-08-21 16:11:49 +02:00
Dariusz Niemczyk 5127ee7967
fix: move hardcoded translation in landing-page to translations 2025-08-05 22:31:10 +02:00
Dariusz Niemczyk e69926a162
feat: add agenda page and update navigation
- Create dedicated agenda page with full-sized pretalx widget
- Update navigation: "Zgłoś się\!/CFP" → "Agenda/Schedule"
- Move agenda preview to top of contribute section
- Add calendar icon to agenda button in hero section
- Reorder hero buttons: agenda first, newsletter second
- Force light theme on agenda page for better widget compatibility
2025-08-05 20:55:56 +02:00
Dariusz Niemczyk f1ed363830
feat: new tickets texts
Some checks failed
/ deploy (push) Failing after 54s
2025-06-12 14:54:43 +02:00
Dariusz Niemczyk 78db02a03d
feat: modify the cfp/art texts
Some checks failed
/ deploy (push) Failing after 54s
2025-05-12 13:51:58 +02:00
Dariusz Niemczyk 956cd060f0
feat: add new texts
Some checks failed
/ deploy (push) Failing after 57s
2025-05-11 15:18:22 +02:00
palid 38d94270a3 Update src/pages/pl/rules.mdx
All checks were successful
/ deploy (push) Successful in 1s
2025-04-22 19:36:50 +00:00
palid e9c5416f89 Update src/pages/en/rules.mdx
Some checks failed
/ deploy (push) Has been cancelled
2025-04-22 19:36:35 +00:00
Dariusz Niemczyk 25a98cbb85
feat: add some longer contact page content
Some checks failed
/ deploy (push) Failing after 54s
2025-04-14 13:28:13 +02:00
Dariusz Niemczyk c1ead36042
fix: scrollspy offset
Some checks failed
/ deploy (push) Failing after 54s
2025-04-13 22:02:21 +02:00
Dariusz Niemczyk 65a8ea8ab8 fix: properly fix colors-sections
Some checks failed
/ deploy (push) Failing after 56s
2025-04-13 19:41:36 +00:00
q3k 29c1252633 landing: do not mention how many tickets there are per space
Some checks failed
/ deploy (push) Failing after 54s
This seems like an internal detail, and is probably likely to change
(ie. we will issue more vouchers as they legitimately run out and if it
won't starve other spaces).
2025-04-13 19:46:57 +02:00
Dariusz Niemczyk 6da5da5bee
fox: add header margin to scroll
Some checks failed
/ deploy (push) Failing after 53s
2025-04-13 19:01:03 +02:00
Dariusz Niemczyk 103e343f22 fix: links
Some checks failed
/ deploy (push) Failing after 53s
2025-04-13 16:58:59 +00:00
q3k 2c0d70975a look: new favicon
Some checks failed
/ deploy (push) Failing after 53s
The wireframe render is barely visible on a light background. This turns
it into a silhouette (preserving our colour scheme) and rotates it by
~45 degrees to make it fill out the frame more.
2025-04-13 18:13:44 +02:00
q3k af1a4e6c74 look: move language selector to RHS, don't use country flags
Some checks failed
/ deploy (push) Failing after 1s
The language selection being a flag never sat right wight me:

 1. Its positioning next to 'CEBULACAMP' implied more that it's a
    cebula.camp logo or intrinsic flag of the event or something.
 2. I think it's the first site I've ever seen that has a language
    selector on the left hand side of the navigation bar.
 3. Using country flags as language icons is wrong [1].

[1] - just ask the Swiss or the Belgians.
2025-04-13 17:03:40 +02:00
q3k 96861a4490 *: en translation fixes, fix ticket link
Some checks failed
/ deploy (push) Failing after 52s
2025-04-13 16:19:02 +02:00
Dariusz Niemczyk 0882f73e53
fix: incorrect links order for sections coloring
Some checks failed
/ deploy (push) Failing after 55s
2025-04-13 15:51:51 +02:00
Dariusz Niemczyk c30dea28bf
feat: new ticket details
Some checks failed
/ deploy (push) Failing after 31s
2025-04-13 15:48:22 +02:00
Dariusz Niemczyk c69447a9c1
fix: unused section
Some checks failed
/ deploy (push) Failing after 32s
2025-04-13 15:40:59 +02:00
Dariusz Niemczyk a40e78cf5d
fix: incorrect redirects on header
Some checks failed
/ deploy (push) Failing after 30s
2025-04-04 17:29:32 +02:00
Dariusz Niemczyk d2eff84d30
fix: broken language switch 2025-04-04 17:29:32 +02:00
Dariusz Niemczyk dc2b12403d
feat: add new pages texts 2025-04-04 17:29:31 +02:00
Dariusz Niemczyk b858551e6d
feat: add rules&privacy policy pages 2025-04-04 17:29:31 +02:00
Dariusz Niemczyk eb55bfe905
fix: properly build static pages this time
Some checks failed
/ deploy (push) Failing after 54s
2025-02-20 18:15:15 +01:00
Dariusz Niemczyk 53883f4148
fix: handle possible null value that tsc didnt catch
Some checks failed
/ deploy (push) Failing after 53s
2025-02-20 17:43:21 +01:00
Dariusz Niemczyk 9fc91d9480
feat: additionally sanitize slugs
Some checks failed
/ deploy (push) Failing after 40s
2025-02-15 01:17:49 +01:00
Dariusz Niemczyk 4bc8882620
feat: support markdown and add "privacy policy"
Some checks failed
/ deploy (push) Failing after 1s
2025-02-15 01:11:16 +01:00
Dariusz Niemczyk 281f3a9a89
feat: add a catch-all 404 2025-02-15 01:02:59 +01:00
Dariusz Niemczyk 0072eac5d2
feat: make language-selector more robust 2025-02-15 00:35:32 +01:00
Dariusz Niemczyk 615e4466de
feat: split nav in preparation for mdx 2025-02-15 00:35:20 +01:00
Dariusz Niemczyk e11dcdf071
feat: add better loading and 404 page
Some checks failed
/ deploy (push) Failing after 54s
2025-02-15 00:33:10 +01:00
Dariusz Niemczyk bb8c5ec6d0 SPINNING ONION YOOOOOOOOOOO
Some checks failed
/ deploy (push) Failing after 53s
2025-02-13 01:40:20 +00:00
Dariusz Niemczyk 07714ef782 cleanup: remove incorrectly placed oxanium font 2025-02-13 01:40:20 +00:00
Dariusz Niemczyk 41bfff9955
fix: mobile-nav navigation colors
Some checks failed
/ deploy (push) Failing after 54s
2025-02-13 02:31:50 +01:00
Dariusz Niemczyk 63fca6b5ad
fix: Fully optimize rendering of navigations
Fallback to vanillaJS due to React taking a significant
amount of rendering time when we wanted to only update
two classes on the navigation bar.

In addition to that, turns out Firefox has a decade-old
bug related to history.replaceState that causes it to
leak memory. This is a known issue and it's not going
to be fixed.
Our solution is to just get rid of the history.replaceState
and not update the URL when you're scrolling.

Tough luck, but hey, not gonna fix a decade old bug for a
simple throw-away website.
2025-02-13 02:31:45 +01:00
Dariusz Niemczyk f1caef1058
fix: laggy firefox due to replaceState leaking
Some checks failed
/ deploy (push) Failing after 53s
refs: https://bugzilla.mozilla.org/show_bug.cgi?id=1250972

Love me some decade old bugs.
2025-02-13 00:29:31 +01:00
Dariusz Niemczyk c6db42dff0
fix: accidentally commited debug code
Some checks failed
/ deploy (push) Failing after 54s
2025-02-12 23:39:37 +01:00
Dariusz Niemczyk fc46f7a4cd
fix: videos naming scheme
Some checks failed
/ deploy (push) Failing after 2s
2025-02-12 23:34:23 +01:00
Dariusz Niemczyk 19d53a0993
feat: another thumbnail cleanup (bigger now)
Some checks failed
/ deploy (push) Failing after 53s
2025-02-12 13:28:28 +01:00
Dariusz Niemczyk c3837d90be feat: cleanup thumbnail
Some checks failed
/ deploy (push) Failing after 53s
2025-02-12 12:22:07 +00:00
Dariusz Niemczyk dfdad2c16a
fix: package-lock
Some checks failed
/ deploy (push) Failing after 56s
2025-02-11 16:37:22 +01:00
Dariusz Niemczyk 137704a2f9
fix: fonts
Some checks failed
/ deploy (push) Failing after 1s
2025-02-11 15:21:21 +01:00
Dariusz Niemczyk d9b728aacf
chore: remove unnecessary lingui scripts
Some checks failed
/ deploy (push) Failing after 1s
2025-02-11 15:12:10 +01:00
Dariusz Niemczyk f044b17877
feat: add newsletter subscription 2025-02-11 15:12:10 +01:00
Mewp 02779b9d4a Add a CI job
Some checks failed
/ deploy (push) Failing after 0s
2025-02-11 14:02:26 +00:00
Mewp 477fc884be Fix public dir in nix build 2025-02-11 14:02:26 +00:00
Mewp 39f994fc0b Vendor the font 2025-02-11 14:02:26 +00:00
Mewp 8158b6817e Add a way to build the site with nix 2025-02-11 14:02:26 +00:00
q3k 772a6fb04d revert a88da1fa37
revert i18n: content pass on food and accomodation to reflect current state
2025-02-09 22:15:03 +00:00
q3k a88da1fa37 i18n: content pass on food and accomodation to reflect current state 2025-02-09 22:56:00 +01:00
q3k 1eddc9adf5 i18n: title consistency 2025-02-09 22:51:34 +01:00
q3k 6789cdd962 leaflet: place pin more accurately 2025-02-09 22:48:44 +01:00
q3k 8e09ea3a93 *: unlfs 2025-02-09 22:31:42 +01:00
Dariusz Niemczyk bfe470cb5a fix: replace 2k with twok for video 2025-02-09 21:12:22 +00:00
Dariusz Niemczyk 037b89a971 chore: remove unnecessary old favicon 2025-02-09 21:12:22 +00:00
q3k 0a213c7de4 i18n: more poking 2025-02-09 21:57:32 +01:00
92 changed files with 3992 additions and 450 deletions

View 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

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
public/videos/** filter=lfs diff=lfs merge=lfs -text
source-videos/** filter=lfs diff=lfs merge=lfs -text

26
default.nix Normal file
View 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
View 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
View 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; };
}
));
}

View file

@ -1,7 +1,14 @@
import createMDX from "@next/mdx";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
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);

2319
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,21 +6,26 @@
"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",
@ -30,9 +35,12 @@
"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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

View file

@ -1,6 +1,6 @@
{
"name": "CebulaCamp",
"short_name": "Cebula",
"name": "Cebula Camp 2025",
"short_name": "CebulaCamp",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
@ -18,4 +18,4 @@
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 127 KiB

View 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

Binary file not shown.

30
src/actions/newsletter.ts Normal file
View 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");
}

View file

@ -0,0 +1,6 @@
import { notFound } from "next/navigation";
export default function NotFoundDummy() {
console.log("Global catch-all 404 page");
notFound()
}

View file

@ -7,11 +7,10 @@ import Head from 'next/head';
import { ThemeProvider } from "@/components/providers";
import { translations } from "@/i18n/translations";
import { Oxanium } from "next/font/google";
import { headers } from "next/headers";
const oxanium = Oxanium({ subsets: ["latin-ext"] })
import { oxanium } from "@/fonts";
import type { Metadata } from 'next';
import { headers } from "next/headers";
type Props = {
@ -29,7 +28,7 @@ export async function generateMetadata(
const t = translations[currentLang];
return {
title: "CebulaCamp",
title: t.siteTitle,
description: "An amazing gathering of hackers and open source enthusiasts.",
icons: {
icon: [
@ -60,7 +59,7 @@ export async function generateMetadata(
},
twitter: {
card: 'summary_large_image',
title: 'CebulaCamp',
title: t.siteTitle,
description: 'An amazing gathering of hackers and open source enthusiasts.',
images: ['https://cebula.camp/web-app-manifest-512x512.png'],
},
@ -95,13 +94,6 @@ export default async function RootLayout({
<html lang={currentLang} className={`${oxanium.className} ${defaultTheme}`}>
<Head>
<title>{t.siteTitle}</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="CebulaCamp" />
<link rel="manifest" href="/site.webmanifest" />
</Head>
<body className="bg-background text:foreground antialiased">
<ThemeProvider>

View file

@ -1,4 +1,14 @@
"use client"
import Image from "next/image"
export default function Loading() {
return <div>Loading...</div>
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>
)
}

View file

@ -1,5 +1,21 @@
"use client"
import { jgs7 } from "@/fonts"
import Image from "next/image"
export default function NotFound() {
return <div>Not Found</div>
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>
)
}

View file

@ -12,6 +12,6 @@ export default async function Home(
const currentLocale = getLocale(locale)
const t = translations[currentLocale];
return <LandingPage t={t} />
return <LandingPage t={t} currentLocale={currentLocale} />
}

View 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>
)
}

View 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;

View file

@ -13,14 +13,14 @@ export default function Map({ t }: {
return (
<div className='h-screen max-h-[400px] pt-8 relative mx-auto max-w-3xl'>
{/* @ts-expect-error dragging IS a valid prop. */}
<MapContainer dragging={false} center={[51.105173, 17.087157]} zoom={40} scrollWheelZoom={false} className="h-full w-full">
<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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[51.105173, 17.087157]}>
<Marker position={[51.104955057760804, 17.087378768775697]}>
<Popup>
{t.where.location}
{t.details.where.location}
</Popup>
</Marker>
</MapContainer>

View file

@ -4,30 +4,56 @@ import dynamic from 'next/dynamic';
import { Nav } from "@/components/nav";
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 Section({
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,
title,
paragraphs,
children,
after
}: {
id: string
title: string;
paragraphs: ReactElement;
children: ReactElement;
after?: ReactElement;
}) {
return (<section id={id} className="bg-background">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-bold tracking-tighter max-w-3xl mx-auto mb-2">{title}</h2>
<div className="text-lg text-muted-foreground max-w-3xl mx-auto whitespace-pre-line">
{paragraphs}
</div>
<div className="container mx-auto px-4 gap-6 flex flex-col">
{children}
{after}
</div>
</section>)
@ -66,7 +92,7 @@ function getSource({
<source
key={`twok-${type}`}
media="(max-width: 2560px)"
src={src.replace('.mp4', `_2k.${type}`)}
src={src.replace('.mp4', `_twok.${type}`)}
type={sourceType}
/>,
@ -78,9 +104,9 @@ function getSource({
/>,
<source
key={`original-${type}`}
key={`full-${type}`}
media="(min-width: 3841px)"
src={src.replace('.mp4', `_original.${type}`)}
src={src.replace('.mp4', `_full.${type}`)}
type={sourceType}
/>,
];
@ -93,6 +119,7 @@ function Video({ sourceBase, hidden }: {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (!videoRef.current || hidden) return;
const handleScroll = () => {
if (!videoRef.current || hidden) return;
@ -140,14 +167,15 @@ const LazyLeafletMap = dynamic(() => import('./event-map.lazy'), {
})
export default function LandingPage(
{ t }: { t: Translations }
{ t, currentLocale }: { t: Translations, currentLocale: Lang }
) {
const { theme } = useTheme()
return (
<div>
<Nav t={t} />
<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 ">
@ -156,33 +184,185 @@ export default function LandingPage(
<Video sourceBase="/videos/ceboola_gradient-white.mp4" hidden={theme === "dark"} />
</div>
<div className="relative z-10 container mx-auto px-4 h-full flex items-center justify-center">
<div className="text-center font-[JGS7]">
<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.when.date}</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>
<Section id="about" title={t.about.title} paragraphs={<p>{t.about.description}</p>} />
<Section id="where" title={t.where.title} paragraphs={<p>{t.where.location}</p>} after={<LazyLeafletMap t={t} />} />
<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>
<Section id="when" title={t.when.title} paragraphs={<>
<p className="text-primary text-3xl font-[JGS7]">{t.when.date}</p>
<p className="mt-4">{t.when.extra}</p></>}
/>
<Section id="tickets" title={t.tickets.title} paragraphs={<p>{t.tickets.status}</p>} />
<Section id="accommodation" title={t.accommodation.title} paragraphs={<p>{t.accommodation.description}</p>} />
<Section id="food" title={t.food.title} paragraphs={<p>{t.food.description}</p>} />
<Section id="contact" title={t.contact.title} paragraphs={<a href={`mailto:${t.contact.email}`}>{t.contact.email}</a>} />
<Section id="credits" title={t.credits.title} paragraphs={<>
<p>{t.credits.usedFonts}</p>
<p><a className="hover:underline" href="https://velvetyne.fr/fonts/jgs-font/">{t.credits.jgs7}</a></p>
<p><a className="hover:underline" href="https://fonts.google.com/specimen/Oxanium">{t.credits.oxanium}</a></p>
</>}
/>
<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>
</div >
)
}

View file

@ -3,41 +3,66 @@
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import type { Sections, translations } from "@/i18n/translations"
import { cn } from "@/lib/utils"
import { Menu } from "lucide-react"
import 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,
activeSection
externalLinks,
}: {
t: typeof translations.pl
linksOrder: Array<Sections>
activeSection: Sections
externalLinks: Record<string, string>
}) {
return (
<Sheet>
<Sheet >
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">{t.mobileNav.toggleMenu}</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[80vw] sm:w-[385px]">
<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={cn("text-lg hover:text-primary transition-colors", {
'text-primary': activeSection === value
})}>
{t.nav[value]}
</a>
))}
<LanguageSelector />
</nav>
<NavContent t={t} linksOrder={linksOrder} externalLinks={externalLinks} />
</SheetContent>
</Sheet>
)

View 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>
);
}

View file

@ -1,117 +1,62 @@
"use client"
import { Button } from "@/components/ui/button"
import { Sections, 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 { useTheme } from "./providers"
import { LanguageSelector } from "./ui/language-selector"
import { NavContainer } from "./nav-container"
const linksOrder: Array<keyof (typeof translations.pl)["nav"]> = [
"hero",
"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 } = useTheme()
const [activeSection, setActiveSection] = useState<Sections>("about")
useEffect(() => {
const options = {
root: null,
rootMargin: "-10px",
threshold: 0.5, // Adjust the visibility threshold as needed
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const target = entry.target.id as keyof (typeof translations.pl)["nav"]
if (entry.isIntersecting) {
setActiveSection(target);
if (history.replaceState) {
history.replaceState(null, "", `#${target}`);
}
}
});
}, options);
const sections = linksOrder.map(value => document.getElementById(value));
sections.forEach(section => {
if (section) {
observer.observe(section);
}
});
return () => {
sections.forEach(section => {
if (section) {
observer.unobserve(section);
}
});
};
}, []);
const externalLinks: Record<string, string> = {
'wiki': getWikiUrl(currentLocale)
}
return (
<nav className="fixed top-0 left-0 right-0 backdrop-blur-xs bg-background/40 border-b z-[10000]">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex gap-4">
<LanguageSelector />
<a href="#" className="text-xl font-bold tracking-tighter hover:text-primary transition-colors">
<h1>{t.nav.title}</h1>
</a>
</div>
<div className="flex items-center">
{/* Desktop Navigation */}
<div className="hidden md:flex md:items-center md:gap-4 lg:gap-8">
<NavContainer title={t.nav.title}>
<>
<div className="hidden md:flex md:items-center md:gap-4 lg:gap-8">
<ScrollSpy activeClass="nav-active" offsetTop={80}>
{linksOrder.map((value) => (
{linksOrder.map((value) => {
const isExternal = value in externalLinks;
const href = isExternal ? externalLinks[value] : `#${value}`;
return (
<a
key={value}
href={`#${value}`}
className={cn("text-sm md:text-md 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={cn("absolute inset-x-0 -bottom-1 h-0.5 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform", {
'scale-x-100': activeSection === value
<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>
))}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="ml-4"
>
{theme === "dark" ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
</Button>
{/* Mobile Navigation */}
<div className="md:hidden ml-2">
<MobileNav t={t} linksOrder={linksOrder} activeSection={activeSection} />
</div>
</div>
);
})}
</ScrollSpy>
</div>
</div>
</nav>
<div className="md:hidden ml-2">
<MobileNav t={t} linksOrder={linksOrder} externalLinks={externalLinks} />
</div>
</>
</NavContainer>
)
}

View 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>
);
}

View file

@ -1,22 +1,22 @@
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-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
@ -36,7 +36,7 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean
}

View 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 }

View 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 }

View 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
View 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,
}

View 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 }

View 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 }

View file

@ -2,17 +2,37 @@
import { Lang } from "@/i18n/locales";
import Link from "next/link";
import { useParams } from "next/navigation";
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 hash = globalThis?.window?.location?.hash || '';
if (lang === 'pl') return (<>
<Link suppressHydrationWarning className="pt-1" href={`/en${hash}`}>🇬🇧</Link></>);
if (lang === 'en') return (<>
<Link suppressHydrationWarning className="pt-1" href={`/pl${hash}`}>🇵🇱</Link></>);
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>
</>);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

10
src/fonts.ts Normal file
View 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",
});

Binary file not shown.

View file

@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin 'tailwindcss-animate';
@ -65,15 +66,6 @@
}
@layer utilities {
@font-face {
font-family: "JGS7";
src: url("/fonts/jgs7.woff2") format("woff2"),
url("/fonts/jgs7.woff") format("woff"),
url("/fonts/jgs7.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
html {
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
@ -159,11 +151,19 @@
transition: transform 0.25s ease-out;
}
.jgs7 {
font-family: "JGS7" !important;
}
/* 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);
}
}

View file

@ -5,17 +5,15 @@ const common = {
};
const pl = {
siteTitle: "CebulaCamp",
siteTitle: "CebulaCamp 2025",
nav: {
title: "CEBULACAMP",
hero: "Cebula",
about: "O nas",
when: "Kiedy",
where: "Gdzie",
food: "Wyżywienie",
contact: "Kontakt",
about: "O wydarzeniu",
tickets: "Bilety",
accommodation: "Nocleg",
contribute: "Agenda",
details: "FAQ",
contact: "Kontakt",
wiki: "Wiki",
},
mobileNav: {
toggleMenu: "Aktywuj menu",
@ -26,38 +24,71 @@ const pl = {
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\nReaktywacja wydarzenia po długiej przerwie od ostatniej edycji w 2021. Tym razem widzimy się we Wrocławiu, w budynku starej zajezdni tramwajowej który zamienimy na łączone hackcenter i salę prelekcyjną.\n\nSpodziewaj się ciekawych prezentacji, dziwnych instalacji artystycznych i mnóstwa dyskusji. Możesz opowiedzieć o swoim projekcie, zademonstrować skonstruowane zabawki, albo wspólnie coś stworzyć podczas eventu.",
},
where: {
title: "Gdzie",
location:
"Klub „Łącznik”, Tramwajowa 1-3, Wrocław, obok Hackerspace Wrocław",
},
when: {
title: "Kiedy",
date: "28-31.08.2025",
extra:
"Chętnych do pomocy w przygotowaniach zapraszamy już na Day 0, 27 sierpnia",
details: {
title: "FAQ",
where: {
title: "Lokalizacja",
location:
"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",
},
},
tickets: {
title: "Bilety",
status: "Soon ™",
status: `Bilety na Cebula Camp 2025: Reaktywacja zostały wyprzedane!`,
},
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.",
contribute: {
title: "Zgłoś się!",
agenda: {
title: "Agenda",
description: "Zobacz pełny harmonogram wydarzeń, prelekcji i warsztatów",
viewButton: "Zobacz agendę",
},
},
food: {
title: "Wyżywienie",
description:
"We własnym zakresie, w okolicy dostępne są knajpy z dowozem, wieczory planujemy umilić wspólnym grillowaniem.",
faq: {
accommodation: {
title: "Nocleg",
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:
"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: 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 🧅`,
},
},
credits: {
title: "Uznania",
@ -65,20 +96,32 @@ const pl = {
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",
hero: "Onion",
about: "About us",
when: "When",
where: "Where",
food: "Food",
contact: "Contact",
about: "About the event",
tickets: "Tickets",
accommodation: "Accomodation",
contribute: "Schedule",
details: "FAQ",
contact: "Contact",
wiki: "Wiki",
},
mobileNav: {
toggleMenu: "Toggle menu",
@ -89,38 +132,70 @@ const en = {
subtitle: "REACTIVATED",
},
about: {
title: "About us",
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.",
},
where: {
title: "Where",
location:
"“Łącznik” Club, Tramwajowa 1-3, Wrocław, next to Hackerspace Wrocław",
},
when: {
title: "When",
date: "28-31.08.2025",
extra:
"Those willing to help with preparations are welcome to join on Day 0, August 27",
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: "soon",
status: `Tickets for Cebula Camp 2025: Reactivated are sold out!`,
},
accommodation: {
title: "Accommodation",
description:
"In the true camp spirit, we're preparing a fenced area for pitching tents with toilets and shower facilities. The number of tent spots is limited, first-come-first-served basis. There's also the possibility of arranging your own accommodation in a nearby hotel or student dormitories.",
contribute: {
title: "Contribute!",
agenda: {
title: "Schedule",
description: "View the full schedule of events, talks, and workshops",
viewButton: "View agenda",
},
},
food: {
title: "Food",
description:
"Self-catering, there are restaurants with delivery in the area, and we plan to enhance the evenings with communal barbecues.",
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 youre 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",
@ -128,6 +203,20 @@ const en = {
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: {

35
src/lib/external-links.ts Normal file
View 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
View file

@ -0,0 +1,7 @@
import type { MDXComponents } from "mdx/types";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
};
}

109
src/pages/en/privacy.mdx Normal file
View 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/)

43
src/pages/en/rules.mdx Normal file
View file

@ -0,0 +1,43 @@
Terms and Conditions
# Terms and Conditions of Cebula Camp 2025 event
(note: this is an informal translation, the legally binding document is its [Polish version](https://cebula.camp/pl/pages/rules)).
1. The organizer of the Cebula Camp event (hereinafter referred to as the Event) is “Stowarzyszenie Hackerspace Wrocław” (registered under KRS number 0000531222).
2. The Administrator of the personal data of the Event participants is “Stowarzyszenie Hackerspace Wrocław” (registered under KRS number 0000531222).
3. The Event takes place from August 28 to August 31, 2025, at “Centrum Kultury Akademickiej i Inicjatyw Lokalnych Czasoprzestrzeń”.
4. The Event is a closed and paid event.
5. Each participant of the Event is required to purchase a ticket.
6. The purchase of a ticket entitling participation in the Event constitutes acceptance of these Terms and Conditions.
7. The Event is intended exclusively for adults. The Organizer reserves the right to validate a participant's ID or other document proving their age.
8. Each participant of the Event is required to wear a provided identifier (badge or similar). Only Organizers and Event Staff are authorized to check identifiers. Any loss of an identifier must be immediately reported to the Organizers.
9. The Organizers are not responsible for lost or stolen items. Items found during the Event (including documents) will be available for retrieval from the Organizers.
10. Bringing animals to the Event is prohibited, except for assistance dogs.
11. It is forbidden to take photos, record videos, or stream live video at the Event without the prior explicit consent of all persons appearing in the frame.
12. The Organizer reserves the right to record, stream, and photograph presenters after obtaining their written consent.
13. It is prohibited to be significantly under the influence of psychoactive substances while at the Event.
14. The Organizer reserves the right to remove any individuals from the Event premises who violate the Terms and Conditions, disrupt order, or pose a threat.
15. The Organizer is not responsible for and does not endorse any opinions expressed by participants during the Event.
16. The Terms and Conditions of the Event are available at https://cebula.camp/en/pages/rules.
17. The Organizer reserves the right to amend these Terms and Conditions. Any changes will be communicated via email and made available on the website.
18. In disputed or unspecified matters, the final decision rests with the Organizers, in accordance with Polish law.
Return to the [homepage](/en/)

103
src/pages/pl/privacy.mdx Normal file
View file

@ -0,0 +1,103 @@
Polityka prywatności
# Polityka Prywatności Cebula Camp 2025
Niniejsza polityka dotyczy stron funkcjonujących pod adresami url: [cebula.camp](http://cebula.camp/), [cfp.cebula.camp](http://cfp.cebula.camp/),[tickets.cebula.camp](http://tickets.cebula.camp/) oraz [news.cebula.camp](http://news.cebula.camp/).
Administratorem oraz Operatorem danych osobowych jest Stowarzyszenie Hackerspace Wrocław (KRS: 0000531222) z siedzibą przy ul. Wróblewskiego 38, 51-627 Wrocław.
Do kontaktu z administratorem danych osobowych służy adres email kontakt@hswro.org.
## Prawa użytkownika
Podanie danych osobowych jest dobrowolne, lecz niezbędne do realizacji usługi.
Użytkownik, poprzez kontakt z administratorem danych, może zażądać dostępu do swoich danych osobowych celem ich sprostowania, usunięcia, przeniesienia, ograniczenia przetwarzania lub może wnieść sprzeciw wobec ich dalszego przetwarzania.
Użytkownik ma prawo wnieść skargę dotyczącą przetwarzania jego danych przez Operatora do organu nadzorczego: Urzędu Ochrony Danych Osobowych. Kontakt do organu nadzorczego podanyjest na stronie [https://uodo.gov.pl/](https://uodo.gov.pl/).
## Zbierane dane
Strony wykorzystują dane osobowe w następujących celach:
- Realizacji zamówionych usług
- Prowadzenia newslettera
- Zbierania propozycji wystąpień
Strony realizują funkcje pozyskiwania informacji o użytkownikach i ich zachowaniu w następujący sposób:
- Poprzez dobrowolnie wprowadzone do formularzy dane, które zostają zapisane w systemach Operatora
- Poprzez zapisywanie w urządzeniach końcowych plików cookie (tzw. „ciasteczka”)
### Sprzedaż biletów
Przy sprzedaży biletów zbieramy następujące dane osobowe:
1. Adres e-mail kupującego (celem identyfikacji kupującego i kontaktu w sprawie biletów)
2. Imię oraz preferowane zaimki uczestnika, jeżeli podane (celem kontaktu z uczestnikiem)
3. Imię i nazwisko, adres oraz numer konta bankowego osoby opłacającej zamówienie (celem realizacji zamówienia)
Przy sprzedaży biletów stosujemy ciasteczka celem usprawnienia procesu zakupu i utrzymania stanu koszyka zakupów. Nie stosujemy ciasteczek celem śledzenia użytkownika i nie stosujemy ciasteczek marketingowych.
Przy płatności przelewem celem dokonania zakupu biletów nasz bank oraz osoby upoważnione do dostępu do danych księgowych Stowarzyszenia Hackerspace Wrocław będą miały wgląd w dane podmiotu wysyłającego przelew. Dane te będą przetwarzane i przechowywane wyłącznie celem utrzymania dokumentacji księgowej.
### Newsletter
Przy obsłudze newslettera zbieramy następujące dane osobowe celem świadczenia usługi:
1. Adres e-mail subskrybenta
2. Imię lub pseudonim subskrybenta, jeżeli podane
Adres e-mail i imię subskrybenta (jeżeli podane) będą przesyłane w formie nieszyfrowanej do serwera e-mail subskrybenta. Dane osobowe nie są przekazywane innym podmiotom trzecim.
Wysyłane wiadomości e-mail zawierają osadzony zdalny obrazek ('tracking pixel'). Fakt pobrania obrazka przez klienta pocztowego subskrybenta używane jest do zgrubnej analizy osiągalności wysłanych wiadomości. Obrazki nie identyfikują indywidualnie subskrybentów. Nie zbieramy metadanych na temat subskrybenta podczas serwowania obrazka.
### CFP
Przy obsłudze zbierania propozycji wystąpień zbieramy następujące dane osobowe celem świadczenia usługi:
1. Adres e-mail osoby zgłaszającej wystąpienie
2. Imię lub pseudonim zgłaszającej wystąpienie
### Logi
W celu zapewnienia niezawodności technicznej prowadzone są logi na poziomie serwera. Zapisowi mogą podlegać:
- zasoby określone identyfikatorem URL (adresy żądanych zasobów stron, plików),
- czas nadejścia zapytania,
- nazwę stacji klienta identyfikacja realizowana przez protokół HTTP,
- informacje o błędach jakie nastąpiły przy realizacji transakcji HTTP,
- informacje o przeglądarce użytkownika,
- informacje o adresie IP,
- informacje diagnostyczne związane z procesem samodzielnego zamawiania usług poprzez rejestratory na stronie,
- informacje związane z obsługą poczty elektronicznej kierowanej do Operatora oraz wysyłanej przez Operatora.
## Sposoby przetwarzania danych
W niektórych sytuacjach Administrator ma prawo przekazywać Twoje dane osobowe innym odbiorcom, jeśli będzie to niezbędne do wykonania zawartej z Tobą umowy lub do zrealizowania obowiązków ciążących na Administratorze. Dotyczy to takich grup odbiorców:
- partner obsługujący hosting stron internetowych na zasadzie powierzenia
- upoważnieni pracownicy i współpracownicy, którzy korzystają z danych w poszczególnych celach
Twoje dane osobowe przetwarzane przez Administratora nie dłużej, niż jest to konieczne do wykonania związanych z nimi czynności określonych osobnymi przepisami (np. o prowadzeniu rachunkowości), po tym czasie wszelkie dane zostaną zanonimizowane.
Dane osobowe nie są przekazywane od krajów trzecich w rozumieniu przepisów o ochronie danych osobowych. Oznacza to, że nie przesyłamy ich poza teren Unii Europejskiej.
Miejsca logowania i wprowadzania danych osobowych są chronione w warstwie transmisji (certyfikat SSL). Dzięki temu dane osobowe i dane logowania, wprowadzone na stronie, zostają zaszyfrowane w komputerze użytkownika i mogą być odczytane jedynie na docelowym serwerze.
Wróć do [strony głównej](/pl/)

41
src/pages/pl/rules.mdx Normal file
View file

@ -0,0 +1,41 @@
Regulamin
# Regulamin Cebula Camp 2025
1. Organizatorem Wydarzenia Cebula Camp (dalej zwanego Wydarzeniem) jest Stowarzyszenie Hackerspace Wrocław (zarejestrowane pod numerem KRS 0000531222).
2. Administratorem danych osobowych uczestników Wydarzenia jest Stowarzyszenie Hackerspace Wrocław (zarejestrowane pod numerem KRS 0000531222).
3. Wydarzenie odbywa się w dniach 28-31 sierpnia 2025 roku na terenie Centrum Kultury Akademickiej i Inicjatyw Lokalnych Czasoprzestrzeń.
4. Wydarzenie jest imprezą zamkniętą i odpłatną.
5. Każdy uczestnik Wydarzenia jest zobowiązany do wykupienia biletu.
6. Zakup biletu uprawniającego do udziału w Wydarzeniu jest równoznaczny z akceptacją niniejszego Regulaminu.
7. Wydarzenie jest przeznaczone wyłącznie dla osób pełnoletnich. Organizator zastrzega sobie prawo do sprawdzenia dokumentu poświadczającego wiek uczestnika.
8. Każdy uczestnik Wydarzenia jest zobowiązany do noszenia przydzielonego mu identyfikatora. Osobami mogącymi sprawdzać identyfikatory są Organizatorzy i Obsługa. Zgubienie identyfikatora należy niezwłocznie zgłosić Organizatorom.
9. Organizatorzy nie ponoszą odpowiedzialności za rzeczy zgubione lub skradzione. Rzeczy znalezione podczas trwania Wydarzenia (również dokumenty) będą możliwe do odbioru u Organizatorów.
10. Obowiązuje zakaz wprowadzania zwierząt na teren Wydarzenia, za wyjątkiem psów asystujących.
11. Na terenie Wydarzenia zakazane jest robienie zdjęć, nagrywania filmów lub streamowania wideo bez uprzedniej wyraźnej zgody wszystkich osób znajdujących się w kadrze.
12. Organizator zastrzega sobie możliwość nagrywania, streamowania i fotografowania prezenterów, po uprzednim uzyskaniu ich pisemnej zgody.
13. Na terenie Wydarzenia obowiązuje zakaz przebywania w stanie znacznego odurzenia substancjami psychoaktywnymi.
14. Organizator zastrzega sobie prawo do usunięcia z terenu Wydarzenia osób nieprzestrzegających Regulaminu, zakłócających porządek lub stwarzających zagrożenie.
15. Organizator nie ponosi odpowiedzialności i nie utożsamia się z opiniami wygłaszanymi przez Uczestników podczas trwania Wydarzenia.
16. Regulamin Wydarzenia jest dostępny na stronie https://cebula.camp/pl/pages/rules.
17. Organizator zastrzega sobie prawo do zmiany Regulaminu. Zmiany w regulaminie będą komunikowane drogą mailową oraz dostępne na stronie.
18. W kwestiach spornych bądź nieokreślonych regulaminem, ostateczną decyzję podejmują Organizatorzy w zgodzie z przepisami prawa polskiego
Wróć do [strony głównej](/pl/)

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const newsletterFormSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
uodo: z.boolean().default(false),
});
export type NewsletterFormSchema = z.infer<typeof newsletterFormSchema>;