diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..abbd4ba
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+# Posthog
+NEXT_PUBLIC_POSTHOG_KEY=abc
+NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
diff --git a/.gitignore b/.gitignore
index 5ef6a52..3639bc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env*.example
# vercel
.vercel
diff --git a/bun.lock b/bun.lock
index 9e71afd..4ebd338 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,9 +14,11 @@
"lucide-react": "^0.502.0",
"motion": "^12.7.4",
"next": "15.3.1-canary.15",
+ "next-intl": "^4.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-use-measure": "^2.1.7",
+ "server-only": "^0.0.1",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.7",
"zod": "^3.24.3",
@@ -140,6 +142,16 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
+ "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.4", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.1", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA=="],
+
+ "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
+
+ "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/icu-skeleton-parser": "1.8.14", "tslib": "^2.8.0" } }, "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA=="],
+
+ "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.14", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "tslib": "^2.8.0" } }, "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ=="],
+
+ "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="],
+
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
@@ -286,6 +298,8 @@
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="],
+ "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
+
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
@@ -610,6 +624,8 @@
"debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
+ "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="],
+
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@@ -772,6 +788,8 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
+ "intl-messageformat": ["intl-messageformat@10.7.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.2", "tslib": "^2.8.0" } }, "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug=="],
+
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
@@ -914,8 +932,12 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+
"next": ["next@15.3.1-canary.15", "", { "dependencies": { "@next/env": "15.3.1-canary.15", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.1-canary.15", "@next/swc-darwin-x64": "15.3.1-canary.15", "@next/swc-linux-arm64-gnu": "15.3.1-canary.15", "@next/swc-linux-arm64-musl": "15.3.1-canary.15", "@next/swc-linux-x64-gnu": "15.3.1-canary.15", "@next/swc-linux-x64-musl": "15.3.1-canary.15", "@next/swc-win32-arm64-msvc": "15.3.1-canary.15", "@next/swc-win32-x64-msvc": "15.3.1-canary.15", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-K04SKrZ+HKrx1+rma4y9nfxilL5HnQ5KiL0biKwyQyhiG5xFLUqaHGzpiYflbWW+ufJWejk3cJJkhK/DUuB37Q=="],
+ "next-intl": ["next-intl@4.0.3", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^4.0.3" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-vw25WvRheZG6NzyXYePuhc/xrYHMhnu7cVcB49XoXaQsO4WMV0YDV6BllhRgI5Ne79hpoAQMyk/fxIKtkBVbNg=="],
+
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1012,6 +1034,8 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
+
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
@@ -1104,6 +1128,8 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+ "use-intl": ["use-intl@4.0.3", "", { "dependencies": { "@formatjs/fast-memoize": "^2.2.0", "@schummar/icu-type-parser": "1.21.5", "intl-messageformat": "^10.5.14" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-AeXB+5gPORJHj1BPCj0aNga0y4gpucHcqNk6B+xRSLlBt0Bz+QVtMmdtV1nvxG308rWqqkmfo6RVu9yIcZt+Hw=="],
+
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1130,6 +1156,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+ "@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.1", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg=="],
+
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
diff --git a/global.ts b/global.ts
new file mode 100644
index 0000000..a7ebdeb
--- /dev/null
+++ b/global.ts
@@ -0,0 +1,9 @@
+import type { routing } from "@/i18n/routing";
+import type messages from "./messages/en.json";
+
+declare module "next-intl" {
+ interface AppConfig {
+ Locale: (typeof routing.locales)[number];
+ Messages: typeof messages;
+ }
+}
diff --git a/messages/en.json b/messages/en.json
new file mode 100644
index 0000000..e59ea21
--- /dev/null
+++ b/messages/en.json
@@ -0,0 +1,24 @@
+{
+ "home": {
+ "title": "Sorting Algorithm Visualization",
+ "about": "I am a developer who loves to learn new things. I have been programming for over 10 years and I love to explore new technologies and languages. I am currently learning TypeScript and React."
+ },
+ "metadata": {
+ "title": "Sorting Algorithm Visualization",
+ "description": "A simple visualization of some basic sorting algorithms"
+ },
+ "not-found": {
+ "title": "Oops! This page has been sucked into the void",
+ "description": "The page you're looking for seems to have been pulled into the event horizon. Even light can't escape, but you still can!",
+ "link": "Escape to Safety"
+ },
+ "locale-switcher": {
+ "label": "Change Language",
+ "locale": "{locale, select, en {🇺 English} sk {🇸 Slovenský} other {Unknown}}"
+ },
+ "navigation": {
+ "home": "Home",
+ "playground": "Playground"
+ },
+ "visualizer": {}
+}
diff --git a/messages/sk.json b/messages/sk.json
new file mode 100644
index 0000000..5d16b04
--- /dev/null
+++ b/messages/sk.json
@@ -0,0 +1,23 @@
+{
+ "home": {
+ "title": "Vizualizácia algoritmov pre triedenie",
+ "about": "Som vývojár, ktorý rád sa zaujíma nových vecí. Som programoval od viac ako 10 rokov a rád sa zaujíma nových technológií a jazykov. V súčasnosti sa učím TypeScript a React."
+ },
+ "metadata": {
+ "title": "Vizualizácia algoritmov pre triedenie",
+ "description": "Jednoduchá vizualizácia niektorých základných algoritmov triedenia"
+ },
+ "not-found": {
+ "title": "Oops! Stránka bola zhltnutá do prázdnoty",
+ "description": "Stránka, ktorú hľadáte, bola vtiahnutá do horizontu. Ani svetlo nevie uniknúť, ale vy stále môžete!",
+ "link": "Späť do bezpečia"
+ },
+ "locale-switcher": {
+ "label": "Zmeniť jazyk",
+ "locale": "{locale, select, en {🇺 Anglický} sk {🇸 Slovenský} other {Neznámy}}"
+ },
+ "navigation": {
+ "home": "Domov",
+ "playground": "Vizualizácia"
+ }
+}
diff --git a/next.config.ts b/next.config.ts
index 6289d16..9173624 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
+import createNextIntlPlugin from "next-intl/plugin";
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
@@ -11,8 +12,6 @@ const nextConfig: NextConfig = {
experimental: {
reactCompiler: true,
ppr: true,
- dynamicIO: true,
- useCache: true,
},
typescript: {
ignoreBuildErrors: true,
@@ -22,4 +21,6 @@ const nextConfig: NextConfig = {
},
};
-export default nextConfig;
+const withNextIntl = createNextIntlPlugin();
+
+export default withNextIntl(nextConfig);
diff --git a/package.json b/package.json
index b597a7c..724c51e 100644
--- a/package.json
+++ b/package.json
@@ -24,9 +24,11 @@
"lucide-react": "^0.502.0",
"motion": "^12.7.4",
"next": "15.3.1-canary.15",
+ "next-intl": "^4.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-use-measure": "^2.1.7",
+ "server-only": "^0.0.1",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.7",
"zod": "^3.24.3"
diff --git a/src/app/[locale]/[...rest]/page.tsx b/src/app/[locale]/[...rest]/page.tsx
new file mode 100644
index 0000000..2dc2960
--- /dev/null
+++ b/src/app/[locale]/[...rest]/page.tsx
@@ -0,0 +1,5 @@
+import { notFound } from "next/navigation";
+
+export default function CatchAllPage() {
+ notFound();
+}
diff --git a/src/app/globals.css b/src/app/[locale]/globals.css
similarity index 100%
rename from src/app/globals.css
rename to src/app/[locale]/globals.css
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
new file mode 100644
index 0000000..e7f2d7a
--- /dev/null
+++ b/src/app/[locale]/layout.tsx
@@ -0,0 +1,62 @@
+import { Geist, Geist_Mono } from "next/font/google";
+import { notFound } from "next/navigation";
+import { hasLocale, type Locale, NextIntlClientProvider } from "next-intl";
+import { getTranslations, setRequestLocale } from "next-intl/server";
+import { routing } from "@/i18n/routing";
+import "./globals.css";
+import Navigation from "@/components/navigation";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export function generateStaticParams() {
+ return routing.locales.map(locale => ({ locale }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ locale: Locale }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "metadata" });
+
+ return {
+ title: t("title"),
+ description: t("description"),
+ };
+}
+
+export default async function LocaleLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ locale: Locale }>;
+}) {
+ const { locale } = await params;
+ if (!hasLocale(routing.locales, locale)) notFound();
+
+ // Enable static rendering
+ setRequestLocale(locale);
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/[locale]/not-found.tsx b/src/app/[locale]/not-found.tsx
new file mode 100644
index 0000000..77b1953
--- /dev/null
+++ b/src/app/[locale]/not-found.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { ArrowRight, Home } from "lucide-react";
+import { motion } from "motion/react";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { HoleBackground } from "@/components/animate-ui/hole-background";
+import { Link } from "@/i18n/navigation";
+
+export default function NotFoundPage() {
+ const t = useTranslations("not-found");
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) return null;
+
+ return (
+
+
+
+
+
+
+ 404
+
+
+ {t.rich("title", {
+ newline: chunks => (
+ {chunks}
+ ),
+ })}
+
+
+ {t("description")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx
new file mode 100644
index 0000000..53e8c59
--- /dev/null
+++ b/src/app/[locale]/page.tsx
@@ -0,0 +1,7 @@
+import { useTranslations } from "next-intl";
+
+export default function Home() {
+ const t = useTranslations("home");
+
+ return {t("title")}
;
+}
diff --git a/src/app/[locale]/playground/page.tsx b/src/app/[locale]/playground/page.tsx
new file mode 100644
index 0000000..8177a21
--- /dev/null
+++ b/src/app/[locale]/playground/page.tsx
@@ -0,0 +1,19 @@
+import { useTranslations } from "next-intl";
+import { HexagonBackground } from "@/components/animate-ui/hexagon-background";
+import SortingVisualizer from "@/components/sorting-visualizer";
+
+export default function Playground() {
+ const t = useTranslations("home");
+
+ return (
+
+
+
+ {t("title")}
+
+
{t("about")}
+
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 3c2d3c5..7bb5def 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,34 +1,9 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-export const metadata: Metadata = {
- title: "Sorting Algorithms Visualization",
- description: "A simple visualization of some basic sorting algorithms",
-};
-
+// Since we have a `not-found.tsx` page on the root, a layout file
+// is required, even if it's just passing children through.
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
- return (
-
-
- {children}
-
-
- );
+ return children;
}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..1fd9497
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import Error from "next/error";
+
+// This page renders when a route like `/unknown.txt` is requested.
+// In this case, the layout at `app/[locale]/layout.tsx` receives
+// an invalid value as the `[locale]` param and calls `notFound()`.
+
+export default function GlobalNotFound() {
+ return (
+
+
+ ;
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6655c61..089107b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,15 +1,6 @@
-import { HexagonBackground } from "@/components/animate-ui/hexagon-background";
-import SortingVisualizer from "@/components/sorting-visualizer";
+import { redirect } from "next/navigation";
-export default async function Home() {
- return (
-
-
-
- Sorting Algorithm Visualization
-
-
-
-
- );
+// This page only renders when the app is built statically (output: 'export')
+export default function RootPage() {
+ redirect("/en");
}
diff --git a/src/components/animate-ui/hole-background.tsx b/src/components/animate-ui/hole-background.tsx
new file mode 100644
index 0000000..24a1474
--- /dev/null
+++ b/src/components/animate-ui/hole-background.tsx
@@ -0,0 +1,384 @@
+"use client";
+
+import { motion } from "motion/react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+type HoleBackgroundProps = React.ComponentProps<"div"> & {
+ strokeColor?: string;
+ numberOfLines?: number;
+ numberOfDiscs?: number;
+ particleRGBColor?: [number, number, number];
+};
+
+function HoleBackground({
+ strokeColor = "#737373",
+ numberOfLines = 50,
+ numberOfDiscs = 50,
+ particleRGBColor = [255, 255, 255],
+ className,
+ children,
+ ...props
+}: HoleBackgroundProps) {
+ const canvasRef = React.useRef(null);
+ const animationFrameIdRef = React.useRef(0);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const stateRef = React.useRef({
+ discs: [],
+ lines: [],
+ particles: [],
+ clip: {},
+ startDisc: {},
+ endDisc: {},
+ rect: { width: 0, height: 0 },
+ render: { width: 0, height: 0, dpi: 1 },
+ particleArea: {},
+ linesCanvas: null,
+ });
+
+ const linear = (p: number) => p;
+ const easeInExpo = (p: number) => (p === 0 ? 0 : Math.pow(2, 10 * (p - 1)));
+
+ const tweenValue = React.useCallback(
+ (start: number, end: number, p: number, ease: "inExpo" | null = null) => {
+ const delta = end - start;
+ const easeFn = ease === "inExpo" ? easeInExpo : linear;
+ return start + delta * easeFn(p);
+ },
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Not needed with React Compiler (https://github.com/biomejs/biome/issues/5293)
+ [easeInExpo, linear],
+ );
+
+ const tweenDisc = React.useCallback(
+ (disc: { x: number; y: number; w: number; h: number; p: number }) => {
+ const { startDisc, endDisc } = stateRef.current;
+ disc.x = tweenValue(startDisc.x, endDisc.x, disc.p);
+ disc.y = tweenValue(startDisc.y, endDisc.y, disc.p, "inExpo");
+ disc.w = tweenValue(startDisc.w, endDisc.w, disc.p);
+ disc.h = tweenValue(startDisc.h, endDisc.h, disc.p);
+ },
+ [tweenValue],
+ );
+
+ const setSize = React.useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const rect = canvas.getBoundingClientRect();
+ stateRef.current.rect = { width: rect.width, height: rect.height };
+ stateRef.current.render = {
+ width: rect.width,
+ height: rect.height,
+ dpi: window.devicePixelRatio || 1,
+ };
+ canvas.width = stateRef.current.render.width * stateRef.current.render.dpi;
+ canvas.height =
+ stateRef.current.render.height * stateRef.current.render.dpi;
+ }, []);
+
+ const setDiscs = React.useCallback(() => {
+ const { width, height } = stateRef.current.rect;
+ stateRef.current.discs = [];
+ stateRef.current.startDisc = {
+ x: width * 0.5,
+ y: height * 0.45,
+ w: width * 0.75,
+ h: height * 0.7,
+ };
+ stateRef.current.endDisc = {
+ x: width * 0.5,
+ y: height * 0.95,
+ w: 0,
+ h: 0,
+ };
+ let prevBottom = height;
+ stateRef.current.clip = {};
+ for (let i = 0; i < numberOfDiscs; i++) {
+ const p = i / numberOfDiscs;
+ const disc = { p, x: 0, y: 0, w: 0, h: 0 };
+ tweenDisc(disc);
+ const bottom = disc.y + disc.h;
+ if (bottom <= prevBottom) {
+ stateRef.current.clip = { disc: { ...disc }, i };
+ }
+ prevBottom = bottom;
+ stateRef.current.discs.push(disc);
+ }
+ const clipPath = new Path2D();
+ const disc = stateRef.current.clip.disc;
+ clipPath.ellipse(disc.x, disc.y, disc.w, disc.h, 0, 0, Math.PI * 2);
+ clipPath.rect(disc.x - disc.w, 0, disc.w * 2, disc.y);
+ stateRef.current.clip.path = clipPath;
+ }, [tweenDisc, numberOfDiscs]);
+
+ const setLines = React.useCallback(() => {
+ const { width, height } = stateRef.current.rect;
+ stateRef.current.lines = [];
+ const linesAngle = (Math.PI * 2) / numberOfLines;
+ for (let i = 0; i < numberOfLines; i++) {
+ stateRef.current.lines.push([]);
+ }
+ stateRef.current.discs.forEach(
+ (disc: { x: number; y: number; w: number; h: number }) => {
+ for (let i = 0; i < numberOfLines; i++) {
+ const angle = i * linesAngle;
+ const p = {
+ x: disc.x + Math.cos(angle) * disc.w,
+ y: disc.y + Math.sin(angle) * disc.h,
+ };
+ stateRef.current.lines[i].push(p);
+ }
+ },
+ );
+ const offCanvas = document.createElement("canvas");
+ offCanvas.width = width;
+ offCanvas.height = height;
+ const ctx = offCanvas.getContext("2d");
+ if (!ctx) return;
+ stateRef.current.lines.forEach((line: { x: number; y: number }[]) => {
+ ctx.save();
+ let lineIsIn = false;
+ line.forEach((p1, j) => {
+ if (j === 0) return;
+ const p0 = line[j - 1]!;
+ if (
+ !lineIsIn &&
+ (ctx.isPointInPath(stateRef.current.clip.path, p1.x, p1.y) ||
+ ctx.isPointInStroke(stateRef.current.clip.path, p1.x, p1.y))
+ ) {
+ lineIsIn = true;
+ } else if (lineIsIn) {
+ ctx.clip(stateRef.current.clip.path);
+ }
+ ctx.beginPath();
+ ctx.moveTo(p0.x, p0.y);
+ ctx.lineTo(p1.x, p1.y);
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ ctx.closePath();
+ });
+ ctx.restore();
+ });
+ stateRef.current.linesCanvas = offCanvas;
+ }, [strokeColor, numberOfLines]);
+
+ const initParticle = React.useCallback(
+ (start: boolean = false) => {
+ const sx =
+ stateRef.current.particleArea.sx +
+ stateRef.current.particleArea.sw * Math.random();
+ const ex =
+ stateRef.current.particleArea.ex +
+ stateRef.current.particleArea.ew * Math.random();
+ const dx = ex - sx;
+ const y = start
+ ? stateRef.current.particleArea.h * Math.random()
+ : stateRef.current.particleArea.h;
+ const r = 0.5 + Math.random() * 4;
+ const vy = 0.5 + Math.random();
+ return {
+ x: sx,
+ sx,
+ dx,
+ y,
+ vy,
+ p: 0,
+ r,
+ c: `rgba(${particleRGBColor[0]}, ${particleRGBColor[1]}, ${particleRGBColor[2]}, ${Math.random()})`,
+ };
+ },
+ [particleRGBColor[0]],
+ );
+
+ const setParticles = React.useCallback(() => {
+ const { width, height } = stateRef.current.rect;
+ stateRef.current.particles = [];
+ const disc = stateRef.current.clip.disc;
+ stateRef.current.particleArea = {
+ sw: disc.w * 0.5,
+ ew: disc.w * 2,
+ h: height * 0.85,
+ };
+ stateRef.current.particleArea.sx =
+ (width - stateRef.current.particleArea.sw) / 2;
+ stateRef.current.particleArea.ex =
+ (width - stateRef.current.particleArea.ew) / 2;
+ const totalParticles = 100;
+ for (let i = 0; i < totalParticles; i++) {
+ stateRef.current.particles.push(initParticle(true));
+ }
+ }, [initParticle]);
+
+ const drawDiscs = React.useCallback(
+ (ctx: CanvasRenderingContext2D) => {
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = 2;
+ const outerDisc = stateRef.current.startDisc;
+ ctx.beginPath();
+ ctx.ellipse(
+ outerDisc.x,
+ outerDisc.y,
+ outerDisc.w,
+ outerDisc.h,
+ 0,
+ 0,
+ Math.PI * 2,
+ );
+ ctx.stroke();
+ ctx.closePath();
+ stateRef.current.discs.forEach(
+ (
+ disc: {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ i: number,
+ ) => {
+ if (i % 5 !== 0) return;
+ if (disc.w < stateRef.current.clip.disc.w - 5) {
+ ctx.save();
+ ctx.clip(stateRef.current.clip.path);
+ }
+ ctx.beginPath();
+ ctx.ellipse(disc.x, disc.y, disc.w, disc.h, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.closePath();
+ if (disc.w < stateRef.current.clip.disc.w - 5) {
+ ctx.restore();
+ }
+ },
+ );
+ },
+ [strokeColor],
+ );
+
+ const drawLines = React.useCallback((ctx: CanvasRenderingContext2D) => {
+ if (stateRef.current.linesCanvas) {
+ ctx.drawImage(stateRef.current.linesCanvas, 0, 0);
+ }
+ }, []);
+
+ const drawParticles = React.useCallback((ctx: CanvasRenderingContext2D) => {
+ ctx.save();
+ ctx.clip(stateRef.current.clip.path);
+ stateRef.current.particles.forEach(
+ (particle: { x: number; y: number; r: number; c: string }) => {
+ ctx.fillStyle = particle.c;
+ ctx.beginPath();
+ ctx.rect(particle.x, particle.y, particle.r, particle.r);
+ ctx.closePath();
+ ctx.fill();
+ },
+ );
+ ctx.restore();
+ }, []);
+
+ const moveDiscs = React.useCallback(() => {
+ stateRef.current.discs.forEach(
+ (disc: { x: number; y: number; w: number; h: number; p: number }) => {
+ disc.p = (disc.p + 0.001) % 1;
+ tweenDisc(disc);
+ },
+ );
+ }, [tweenDisc]);
+
+ const moveParticles = React.useCallback(() => {
+ stateRef.current.particles.forEach(
+ (
+ particle: {
+ x: number;
+ sx: number;
+ dx: number;
+ y: number;
+ vy: number;
+ p: number;
+ r: number;
+ c: string;
+ },
+ idx: number,
+ ) => {
+ particle.p = 1 - particle.y / stateRef.current.particleArea.h;
+ particle.x = particle.sx + particle.dx * particle.p;
+ particle.y -= particle.vy;
+ if (particle.y < 0) {
+ stateRef.current.particles[idx] = initParticle();
+ }
+ },
+ );
+ }, [initParticle]);
+
+ const tick = React.useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+ ctx.scale(stateRef.current.render.dpi, stateRef.current.render.dpi);
+ moveDiscs();
+ moveParticles();
+ drawDiscs(ctx);
+ drawLines(ctx);
+ drawParticles(ctx);
+ ctx.restore();
+ animationFrameIdRef.current = requestAnimationFrame(tick);
+ }, [moveDiscs, moveParticles, drawDiscs, drawLines, drawParticles]);
+
+ const init = React.useCallback(() => {
+ setSize();
+ setDiscs();
+ setLines();
+ setParticles();
+ }, [setSize, setDiscs, setLines, setParticles]);
+
+ React.useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ init();
+ tick();
+ const handleResize = () => {
+ setSize();
+ setDiscs();
+ setLines();
+ setParticles();
+ };
+ window.addEventListener("resize", handleResize);
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ cancelAnimationFrame(animationFrameIdRef.current);
+ };
+ }, [init, tick, setSize, setDiscs, setLines, setParticles]);
+
+ return (
+
+ );
+}
+
+export { HoleBackground, type HoleBackgroundProps };
diff --git a/src/components/locale-switcher.tsx b/src/components/locale-switcher.tsx
new file mode 100644
index 0000000..acf5723
--- /dev/null
+++ b/src/components/locale-switcher.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import clsx from "clsx";
+import { useParams } from "next/navigation";
+import { useLocale, useTranslations } from "next-intl";
+import { type Locale } from "next-intl";
+import { type ChangeEvent, type ReactNode, useTransition } from "react";
+import { usePathname, useRouter } from "@/i18n/navigation";
+import { routing } from "@/i18n/routing";
+
+export default function LocaleSwitcher() {
+ const t = useTranslations("locale-switcher");
+ const locale = useLocale();
+
+ return (
+
+ {routing.locales.map(cur => (
+
+ ))}
+
+ );
+}
+
+function LocaleSwitcherSelect({
+ children,
+ defaultValue,
+ label,
+}: {
+ children: ReactNode;
+ defaultValue: string;
+ label: string;
+}) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const pathname = usePathname();
+ const params = useParams();
+
+ function onSelectChange(event: ChangeEvent) {
+ const nextLocale = event.target.value as Locale;
+ startTransition(() => {
+ router.replace(
+ // @ts-expect-error -- TypeScript will validate that only known `params`
+ // are used in combination with a given `pathname`. Since the two will
+ // always match for the current route, we can skip runtime checks.
+ { pathname, params },
+ { locale: nextLocale },
+ );
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx
new file mode 100644
index 0000000..906030c
--- /dev/null
+++ b/src/components/navigation.tsx
@@ -0,0 +1,19 @@
+import { useTranslations } from "next-intl";
+import LocaleSwitcher from "./locale-switcher";
+import { Link } from "@/i18n/navigation";
+
+export default function Navigation() {
+ const t = useTranslations("navigation");
+
+ return (
+
+
+
+ );
+}
diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts
new file mode 100644
index 0000000..2c786c5
--- /dev/null
+++ b/src/i18n/navigation.ts
@@ -0,0 +1,5 @@
+import { createNavigation } from "next-intl/navigation";
+import { routing } from "./routing";
+
+export const { Link, redirect, usePathname, useRouter, getPathname } =
+ createNavigation(routing);
diff --git a/src/i18n/request.ts b/src/i18n/request.ts
new file mode 100644
index 0000000..94482a0
--- /dev/null
+++ b/src/i18n/request.ts
@@ -0,0 +1,16 @@
+import { hasLocale } from "next-intl";
+import { getRequestConfig } from "next-intl/server";
+import { routing } from "./routing";
+
+export default getRequestConfig(async ({ requestLocale }) => {
+ // Typically corresponds to the `[locale]` segment
+ const requested = await requestLocale;
+ const locale = hasLocale(routing.locales, requested)
+ ? requested
+ : routing.defaultLocale;
+
+ return {
+ locale,
+ messages: (await import(`../../messages/${locale}.json`)).default,
+ };
+});
diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts
new file mode 100644
index 0000000..561126b
--- /dev/null
+++ b/src/i18n/routing.ts
@@ -0,0 +1,6 @@
+import { defineRouting } from "next-intl/routing";
+
+export const routing = defineRouting({
+ locales: ["en", "sk"],
+ defaultLocale: "en",
+});
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..ed0e55f
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,11 @@
+import createMiddleware from "next-intl/middleware";
+import { routing } from "./i18n/routing";
+
+export default createMiddleware(routing);
+
+export const config = {
+ // Match all pathnames except for
+ // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
+ // - … the ones containing a dot (e.g. `favicon.ico`)
+ matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
+};