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")} + +
+ + + +
+
+ + {t("link")} + +
+ +
+
+
+ ); +} 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 ( +
+ {children} + + +
+
+ ); +} + +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|.*\\..*).*)", +};