diff --git a/app/about/page.tsx b/app/about/page.tsx index 3c24982..a7e6658 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,6 +1,8 @@ import { GitPullRequestArrowIcon } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; +import { logout } from "@/actions/auth"; +import { DockNav } from "@/components/dock-nav"; import { Footer } from "@/components/footer"; import { Navbar } from "@/components/navbar"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -11,9 +13,10 @@ export const metadata: Metadata = { export default function Home() { return ( -
+
+
diff --git a/app/forum/components/logout-button.tsx b/app/forum/components/logout-button.tsx index 6bf2ac6..d29435e 100644 --- a/app/forum/components/logout-button.tsx +++ b/app/forum/components/logout-button.tsx @@ -13,9 +13,9 @@ export function LogoutButton() { className="grid place-items-center" > {pending ? ( - + ) : ( - + )} ); diff --git a/app/forum/layout.tsx b/app/forum/layout.tsx index 92d7320..7fe8b89 100644 --- a/app/forum/layout.tsx +++ b/app/forum/layout.tsx @@ -1,18 +1,9 @@ -import { - HomeIcon, - InfoIcon, - MessageCirclePlusIcon, - SquareCodeIcon, -} from "lucide-react"; import type { Metadata } from "next"; -import Link from "next/link"; import { redirect } from "next/navigation"; - import { logout } from "@/actions/auth"; -import { Button } from "@/components/ui/button"; +import { DockNav } from "@/components/dock-nav"; import { getSession } from "@/lib/auth"; import { ForumNavbar } from "./components/forum-navbar"; -import { LogoutButton } from "./components/logout-button"; export const metadata: Metadata = { title: "Umedu — Private Forum", @@ -37,7 +28,8 @@ export default async function ForumLayout({ {children} - + + {/*
@@ -56,7 +48,7 @@ export default async function ForumLayout({
-
+
*/} ); } diff --git a/app/globals.css b/app/globals.css index 181792e..63496a0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -135,4 +135,32 @@ [role="button"]:not(:disabled) { cursor: pointer; } + + .shiny-text { + display: inline-block; + background-image: linear-gradient( + 120deg, + rgba(255, 255, 255, 0) 40%, + rgba(255, 255, 255, 0.8) 50%, + rgba(255, 255, 255, 0) 60% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; /* ensure text shows the background gradient */ + color: transparent; + } + + .animate-shine { + animation: shine var(--shine-duration, 5s) linear infinite; + } + + @keyframes shine { + 0% { + background-position: 100%; + } + 100% { + background-position: -100%; + } + } } diff --git a/app/page.tsx b/app/page.tsx index a6d0e4d..b61a44c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,13 +1,30 @@ import { MessageSquareTextIcon } from "lucide-react"; import Link from "next/link"; +import DecryptedText from "@/components/DecryptedText"; +import { FlatMap } from "@/components/flat-map"; import { Footer } from "@/components/footer"; import { HighlightText } from "@/components/highlight-text"; +import LightRays from "@/components/LightRays"; import { Navbar } from "@/components/navbar"; +import ShinyText from "@/components/ShinyText"; import { Button } from "@/components/ui/button"; export default function Home() { return ( -
+
+
@@ -15,12 +32,19 @@ export default function Home() { umedu

- open-source, anonymous, encrypted, private edu forums + open-source, anonymous, + + , private edu forums

@@ -49,13 +73,22 @@ export default function Home() { email. No personal information is stored, ensuring your privacy is protected.{" "} - - Learn more → + + Learn more{" "} + + {" "} + → +
- +
+ +
); diff --git a/bun.lock b/bun.lock index d216d58..b8e0cd3 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-form": "^1.23.8", "@tanstack/react-pacer": "^0.16.4", "@tanstack/react-query": "^5.90.5", @@ -27,15 +28,18 @@ "drizzle-orm": "^0.44.7", "drizzle-seed": "^0.3.1", "lucide-react": "^0.548.0", + "motion": "^12.23.24", "nanoid": "^5.1.6", "next": "15.5.6", "next-themes": "^0.4.6", + "ogl": "^1.0.11", "react": "^19.2.0", "react-dom": "^19.2.0", "react-intersection-observer": "^9.16.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "svg-dotted-map": "^2.0.1", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", @@ -315,6 +319,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], @@ -331,6 +337,8 @@ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -499,6 +507,8 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "framer-motion": ["framer-motion@12.23.24", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="], @@ -671,6 +681,12 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "motion": ["motion@12.23.24", "", { "dependencies": { "framer-motion": "^12.23.24", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw=="], + + "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], + + "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], @@ -683,6 +699,8 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "ogl": ["ogl@1.0.11", "", {}, "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -747,6 +765,8 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "svg-dotted-map": ["svg-dotted-map@2.0.1", "", {}, "sha512-eeI2XzIKm23gmSVr7ASTMNVJvxAvBfyL30tN33Y/DcZCJXvC/Br/cxQp9Ts6jDK/e7fkE5TpZStEfduPqPXrIw=="], + "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], "tailwindcss": ["tailwindcss@4.1.16", "", {}, "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="], diff --git a/components.json b/components.json index a64445d..f54adcf 100644 --- a/components.json +++ b/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "registries": { + "@magicui": "https://magicui.design/r/{name}.json" + } } diff --git a/components/DecryptedText.tsx b/components/DecryptedText.tsx new file mode 100644 index 0000000..c5ae637 --- /dev/null +++ b/components/DecryptedText.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { type HTMLMotionProps, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +interface DecryptedTextProps extends HTMLMotionProps<"span"> { + text: string; + speed?: number; + maxIterations?: number; + sequential?: boolean; + revealDirection?: "start" | "end" | "center"; + useOriginalCharsOnly?: boolean; + characters?: string; + className?: string; + encryptedClassName?: string; + parentClassName?: string; + animateOn?: "view" | "hover" | "both"; +} + +export default function DecryptedText({ + text, + speed = 50, + maxIterations = 10, + sequential = false, + revealDirection = "start", + useOriginalCharsOnly = false, + characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+", + className = "", + parentClassName = "", + encryptedClassName = "", + animateOn = "hover", + ...props +}: DecryptedTextProps) { + const [displayText, setDisplayText] = useState(text); + const [isHovering, setIsHovering] = useState(false); + const [isScrambling, setIsScrambling] = useState(false); + const [revealedIndices, setRevealedIndices] = useState>( + new Set(), + ); + const [hasAnimated, setHasAnimated] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + let interval: NodeJS.Timeout; + let currentIteration = 0; + + const getNextIndex = (revealedSet: Set): number => { + const textLength = text.length; + switch (revealDirection) { + case "start": + return revealedSet.size; + case "end": + return textLength - 1 - revealedSet.size; + case "center": { + const middle = Math.floor(textLength / 2); + const offset = Math.floor(revealedSet.size / 2); + const nextIndex = + revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1; + + if ( + nextIndex >= 0 && + nextIndex < textLength && + !revealedSet.has(nextIndex) + ) { + return nextIndex; + } + for (let i = 0; i < textLength; i++) { + if (!revealedSet.has(i)) return i; + } + return 0; + } + default: + return revealedSet.size; + } + }; + + const availableChars = useOriginalCharsOnly + ? Array.from(new Set(text.split(""))).filter((char) => char !== " ") + : characters.split(""); + + const shuffleText = ( + originalText: string, + currentRevealed: Set, + ): string => { + if (useOriginalCharsOnly) { + const positions = originalText.split("").map((char, i) => ({ + char, + isSpace: char === " ", + index: i, + isRevealed: currentRevealed.has(i), + })); + + const nonSpaceChars = positions + .filter((p) => !p.isSpace && !p.isRevealed) + .map((p) => p.char); + + for (let i = nonSpaceChars.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [nonSpaceChars[i], nonSpaceChars[j]] = [ + nonSpaceChars[j], + nonSpaceChars[i], + ]; + } + + let charIndex = 0; + return positions + .map((p) => { + if (p.isSpace) return " "; + if (p.isRevealed) return originalText[p.index]; + return nonSpaceChars[charIndex++]; + }) + .join(""); + } else { + return originalText + .split("") + .map((char, i) => { + if (char === " ") return " "; + if (currentRevealed.has(i)) return originalText[i]; + return availableChars[ + Math.floor(Math.random() * availableChars.length) + ]; + }) + .join(""); + } + }; + + if (isHovering) { + setIsScrambling(true); + interval = setInterval(() => { + setRevealedIndices((prevRevealed) => { + if (sequential) { + if (prevRevealed.size < text.length) { + const nextIndex = getNextIndex(prevRevealed); + const newRevealed = new Set(prevRevealed); + newRevealed.add(nextIndex); + setDisplayText(shuffleText(text, newRevealed)); + return newRevealed; + } else { + clearInterval(interval); + setIsScrambling(false); + return prevRevealed; + } + } else { + setDisplayText(shuffleText(text, prevRevealed)); + currentIteration++; + if (currentIteration >= maxIterations) { + clearInterval(interval); + setIsScrambling(false); + setDisplayText(text); + } + return prevRevealed; + } + }); + }, speed); + } else { + setDisplayText(text); + setRevealedIndices(new Set()); + setIsScrambling(false); + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [ + isHovering, + text, + speed, + maxIterations, + sequential, + revealDirection, + characters, + useOriginalCharsOnly, + ]); + + useEffect(() => { + if (animateOn !== "view" && animateOn !== "both") return; + + const observerCallback = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasAnimated) { + setIsHovering(true); + setHasAnimated(true); + } + }); + }; + + const observerOptions = { + root: null, + rootMargin: "0px", + threshold: 0.1, + }; + + const observer = new IntersectionObserver( + observerCallback, + observerOptions, + ); + const currentRef = containerRef.current; + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) observer.unobserve(currentRef); + }; + }, [animateOn, hasAnimated]); + + const hoverProps = + animateOn === "hover" || animateOn === "both" + ? { + onMouseEnter: () => setIsHovering(true), + onMouseLeave: () => setIsHovering(false), + } + : {}; + + return ( + + {displayText} + + + + ); +} diff --git a/components/LightRays.tsx b/components/LightRays.tsx new file mode 100644 index 0000000..208530e --- /dev/null +++ b/components/LightRays.tsx @@ -0,0 +1,450 @@ +"use client"; + +import { Mesh, Program, Renderer, Triangle } from "ogl"; +import { useEffect, useRef, useState } from "react"; + +export type RaysOrigin = + | "top-center" + | "top-left" + | "top-right" + | "right" + | "left" + | "bottom-center" + | "bottom-right" + | "bottom-left"; + +interface LightRaysProps { + raysOrigin?: RaysOrigin; + raysColor?: string; + raysSpeed?: number; + lightSpread?: number; + rayLength?: number; + pulsating?: boolean; + fadeDistance?: number; + saturation?: number; + followMouse?: boolean; + mouseInfluence?: number; + noiseAmount?: number; + distortion?: number; + className?: string; +} + +const DEFAULT_COLOR = "#ffffff"; + +const hexToRgb = (hex: string): [number, number, number] => { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return m + ? [ + parseInt(m[1], 16) / 255, + parseInt(m[2], 16) / 255, + parseInt(m[3], 16) / 255, + ] + : [1, 1, 1]; +}; + +const getAnchorAndDir = ( + origin: RaysOrigin, + w: number, + h: number, +): { anchor: [number, number]; dir: [number, number] } => { + const outside = 0.2; + switch (origin) { + case "top-left": + return { anchor: [0, -outside * h], dir: [0, 1] }; + case "top-right": + return { anchor: [w, -outside * h], dir: [0, 1] }; + case "left": + return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] }; + case "right": + return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] }; + case "bottom-left": + return { anchor: [0, (1 + outside) * h], dir: [0, -1] }; + case "bottom-center": + return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] }; + case "bottom-right": + return { anchor: [w, (1 + outside) * h], dir: [0, -1] }; + default: // "top-center" + return { anchor: [0.5 * w, -outside * h], dir: [0, 1] }; + } +}; + +const LightRays: React.FC = ({ + raysOrigin = "top-center", + raysColor = DEFAULT_COLOR, + raysSpeed = 1, + lightSpread = 1, + rayLength = 2, + pulsating = false, + fadeDistance = 1.0, + saturation = 1.0, + followMouse = true, + mouseInfluence = 0.1, + noiseAmount = 0.0, + distortion = 0.0, + className = "", +}) => { + const containerRef = useRef(null); + const uniformsRef = useRef(null); + const rendererRef = useRef(null); + const mouseRef = useRef({ x: 0.5, y: 0.5 }); + const smoothMouseRef = useRef({ x: 0.5, y: 0.5 }); + const animationIdRef = useRef(null); + const meshRef = useRef(null); + const cleanupFunctionRef = useRef<(() => void) | null>(null); + const [isVisible, setIsVisible] = useState(false); + const observerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + setIsVisible(entry.isIntersecting); + }, + { threshold: 0.1 }, + ); + + observerRef.current.observe(containerRef.current); + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (!isVisible || !containerRef.current) return; + + if (cleanupFunctionRef.current) { + cleanupFunctionRef.current(); + cleanupFunctionRef.current = null; + } + + const initializeWebGL = async () => { + if (!containerRef.current) return; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + if (!containerRef.current) return; + + const renderer = new Renderer({ + dpr: Math.min(window.devicePixelRatio, 2), + alpha: true, + }); + rendererRef.current = renderer; + + const gl = renderer.gl; + gl.canvas.style.width = "100%"; + gl.canvas.style.height = "100%"; + + while (containerRef.current.firstChild) { + containerRef.current.removeChild(containerRef.current.firstChild); + } + containerRef.current.appendChild(gl.canvas); + + const vert = ` +attribute vec2 position; +varying vec2 vUv; +void main() { + vUv = position * 0.5 + 0.5; + gl_Position = vec4(position, 0.0, 1.0); +}`; + + const frag = `precision highp float; + +uniform float iTime; +uniform vec2 iResolution; + +uniform vec2 rayPos; +uniform vec2 rayDir; +uniform vec3 raysColor; +uniform float raysSpeed; +uniform float lightSpread; +uniform float rayLength; +uniform float pulsating; +uniform float fadeDistance; +uniform float saturation; +uniform vec2 mousePos; +uniform float mouseInfluence; +uniform float noiseAmount; +uniform float distortion; + +varying vec2 vUv; + +float noise(vec2 st) { + return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); +} + +float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord, + float seedA, float seedB, float speed) { + vec2 sourceToCoord = coord - raySource; + vec2 dirNorm = normalize(sourceToCoord); + float cosAngle = dot(dirNorm, rayRefDirection); + + float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2; + + float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001)); + + float distance = length(sourceToCoord); + float maxDistance = iResolution.x * rayLength; + float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0); + + float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0); + float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0; + + float baseStrength = clamp( + (0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) + + (0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)), + 0.0, 1.0 + ); + + return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y); + + vec2 finalRayDir = rayDir; + if (mouseInfluence > 0.0) { + vec2 mouseScreenPos = mousePos * iResolution.xy; + vec2 mouseDirection = normalize(mouseScreenPos - rayPos); + finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence)); + } + + vec4 rays1 = vec4(1.0) * + rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349, + 1.5 * raysSpeed); + vec4 rays2 = vec4(1.0) * + rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234, + 1.1 * raysSpeed); + + fragColor = rays1 * 0.5 + rays2 * 0.4; + + if (noiseAmount > 0.0) { + float n = noise(coord * 0.01 + iTime * 0.1); + fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n); + } + + float brightness = 1.0 - (coord.y / iResolution.y); + fragColor.x *= 0.1 + brightness * 0.8; + fragColor.y *= 0.3 + brightness * 0.6; + fragColor.z *= 0.5 + brightness * 0.5; + + if (saturation != 1.0) { + float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114)); + fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation); + } + + fragColor.rgb *= raysColor; +} + +void main() { + vec4 color; + mainImage(color, gl_FragCoord.xy); + gl_FragColor = color; +}`; + + const uniforms = { + iTime: { value: 0 }, + iResolution: { value: [1, 1] }, + + rayPos: { value: [0, 0] }, + rayDir: { value: [0, 1] }, + + raysColor: { value: hexToRgb(raysColor) }, + raysSpeed: { value: raysSpeed }, + lightSpread: { value: lightSpread }, + rayLength: { value: rayLength }, + pulsating: { value: pulsating ? 1.0 : 0.0 }, + fadeDistance: { value: fadeDistance }, + saturation: { value: saturation }, + mousePos: { value: [0.5, 0.5] }, + mouseInfluence: { value: mouseInfluence }, + noiseAmount: { value: noiseAmount }, + distortion: { value: distortion }, + }; + uniformsRef.current = uniforms; + + const geometry = new Triangle(gl); + const program = new Program(gl, { + vertex: vert, + fragment: frag, + uniforms, + }); + const mesh = new Mesh(gl, { geometry, program }); + meshRef.current = mesh; + + const updatePlacement = () => { + if (!containerRef.current || !renderer) return; + + renderer.dpr = Math.min(window.devicePixelRatio, 2); + + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current; + renderer.setSize(wCSS, hCSS); + + const dpr = renderer.dpr; + const w = wCSS * dpr; + const h = hCSS * dpr; + + uniforms.iResolution.value = [w, h]; + + const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h); + uniforms.rayPos.value = anchor; + uniforms.rayDir.value = dir; + }; + + const loop = (t: number) => { + if (!rendererRef.current || !uniformsRef.current || !meshRef.current) { + return; + } + + uniforms.iTime.value = t * 0.001; + + if (followMouse && mouseInfluence > 0.0) { + const smoothing = 0.92; + + smoothMouseRef.current.x = + smoothMouseRef.current.x * smoothing + + mouseRef.current.x * (1 - smoothing); + smoothMouseRef.current.y = + smoothMouseRef.current.y * smoothing + + mouseRef.current.y * (1 - smoothing); + + uniforms.mousePos.value = [ + smoothMouseRef.current.x, + smoothMouseRef.current.y, + ]; + } + + try { + renderer.render({ scene: mesh }); + animationIdRef.current = requestAnimationFrame(loop); + } catch (error) { + console.warn("WebGL rendering error:", error); + return; + } + }; + + window.addEventListener("resize", updatePlacement); + updatePlacement(); + animationIdRef.current = requestAnimationFrame(loop); + + cleanupFunctionRef.current = () => { + if (animationIdRef.current) { + cancelAnimationFrame(animationIdRef.current); + animationIdRef.current = null; + } + + window.removeEventListener("resize", updatePlacement); + + if (renderer) { + try { + const canvas = renderer.gl.canvas; + const loseContextExt = + renderer.gl.getExtension("WEBGL_lose_context"); + if (loseContextExt) { + loseContextExt.loseContext(); + } + + if (canvas && canvas.parentNode) { + canvas.parentNode.removeChild(canvas); + } + } catch (error) { + console.warn("Error during WebGL cleanup:", error); + } + } + + rendererRef.current = null; + uniformsRef.current = null; + meshRef.current = null; + }; + }; + + initializeWebGL(); + + return () => { + if (cleanupFunctionRef.current) { + cleanupFunctionRef.current(); + cleanupFunctionRef.current = null; + } + }; + }, [ + isVisible, + raysOrigin, + raysColor, + raysSpeed, + lightSpread, + rayLength, + pulsating, + fadeDistance, + saturation, + followMouse, + mouseInfluence, + noiseAmount, + distortion, + ]); + + useEffect(() => { + if (!uniformsRef.current || !containerRef.current || !rendererRef.current) + return; + + const u = uniformsRef.current; + const renderer = rendererRef.current; + + u.raysColor.value = hexToRgb(raysColor); + u.raysSpeed.value = raysSpeed; + u.lightSpread.value = lightSpread; + u.rayLength.value = rayLength; + u.pulsating.value = pulsating ? 1.0 : 0.0; + u.fadeDistance.value = fadeDistance; + u.saturation.value = saturation; + u.mouseInfluence.value = mouseInfluence; + u.noiseAmount.value = noiseAmount; + u.distortion.value = distortion; + + const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current; + const dpr = renderer.dpr; + const { anchor, dir } = getAnchorAndDir(raysOrigin, wCSS * dpr, hCSS * dpr); + u.rayPos.value = anchor; + u.rayDir.value = dir; + }, [ + raysColor, + raysSpeed, + lightSpread, + raysOrigin, + rayLength, + pulsating, + fadeDistance, + saturation, + mouseInfluence, + noiseAmount, + distortion, + ]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!containerRef.current || !rendererRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + mouseRef.current = { x, y }; + }; + + if (followMouse) { + window.addEventListener("mousemove", handleMouseMove); + return () => window.removeEventListener("mousemove", handleMouseMove); + } + }, [followMouse]); + + return ( +
+ ); +}; + +export default LightRays; diff --git a/components/ShinyText.tsx b/components/ShinyText.tsx new file mode 100644 index 0000000..26e0fb9 --- /dev/null +++ b/components/ShinyText.tsx @@ -0,0 +1,34 @@ +import type React from "react"; + +interface ShinyTextProps { + text: string; + disabled?: boolean; + duration?: number; + className?: string; +} + +const ShinyText: React.FC = ({ + text, + disabled = false, + duration = 5, + className = "", +}) => { + const animationDuration = `${duration}s`; + + return ( +
+ {text} +
+ ); +}; + +export default ShinyText; diff --git a/components/dock-nav.tsx b/components/dock-nav.tsx new file mode 100644 index 0000000..665d880 --- /dev/null +++ b/components/dock-nav.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { HomeIcon, InfoIcon, MessageCirclePlusIcon } from "lucide-react"; +import Link from "next/link"; +import type React from "react"; +import { LogoutButton } from "@/app/forum/components/logout-button"; +import { buttonVariants } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { Dock, DockIcon } from "./ui/dock"; + +export type IconProps = React.HTMLAttributes; + +const Icons = { + github: (props: IconProps) => ( + + + + ), +}; + +const DATA = { + navbar: [ + { href: "/", icon: HomeIcon, label: "Home" }, + { href: "/about", icon: InfoIcon, label: "About" }, + { + href: "/forum/submit", + icon: MessageCirclePlusIcon, + label: "Create Post", + }, + ], + secondary: { + Source: { + name: "Source code", + url: "https://github.com/omsimos/umedu", + icon: Icons.github, + }, + }, +}; + +export function DockNav({ logout }: { logout: () => Promise }) { + return ( +
+ + + {DATA.navbar.map((item) => ( + + + + + + + + +

{item.label}

+
+
+
+ ))} + + {Object.entries(DATA.secondary).map(([name, social]) => ( + + + + + + + + +

{name}

+
+
+
+ ))} + + + +
+ + +
+ +

Logout

+
+
+
+
+
+
+ ); +} diff --git a/components/flat-map.tsx b/components/flat-map.tsx new file mode 100644 index 0000000..4e24b12 --- /dev/null +++ b/components/flat-map.tsx @@ -0,0 +1,88 @@ +import { DottedMap } from "./ui/dotted-map"; + +const markers = [ + { + lat: 40.7128, + lng: -74.006, + size: 0.3, + }, // New York + { + lat: 34.0522, + lng: -118.2437, + size: 0.3, + }, // Los Angeles + { + lat: 51.5074, + lng: -0.1278, + size: 0.3, + }, // London + { + lat: -33.8688, + lng: 151.2093, + size: 0.3, + }, // Sydney + { + lat: 48.8566, + lng: 2.3522, + size: 0.3, + }, // Paris + { + lat: 35.6762, + lng: 139.6503, + size: 0.3, + }, // Tokyo + { + lat: 55.7558, + lng: 37.6176, + size: 0.3, + }, // Moscow + { + lat: 39.9042, + lng: 116.4074, + size: 0.3, + }, // Beijing + { + lat: 28.6139, + lng: 77.209, + size: 0.3, + }, // New Delhi + { + lat: -23.5505, + lng: -46.6333, + size: 0.3, + }, // São Paulo + { + lat: 1.3521, + lng: 103.8198, + size: 0.3, + }, // Singapore + { + lat: 25.2048, + lng: 55.2708, + size: 0.3, + }, // Dubai + { + lat: 52.52, + lng: 13.405, + size: 0.3, + }, // Berlin + { + lat: 19.4326, + lng: -99.1332, + size: 0.3, + }, // Mexico City + { + lat: -26.2041, + lng: 28.0473, + size: 0.3, + }, // Johannesburg +]; + +export function FlatMap() { + return ( +
+
+ +
+ ); +} diff --git a/components/footer.tsx b/components/footer.tsx index 71550b3..f5115ea 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -8,8 +8,8 @@ export function Footer() { umedu {" "} by{" "} - - @josh.xfi + + @omsimos
diff --git a/components/ui/dock.tsx b/components/ui/dock.tsx new file mode 100644 index 0000000..29eaef8 --- /dev/null +++ b/components/ui/dock.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import type { MotionProps } from "motion/react"; +import { + type MotionValue, + motion, + useMotionValue, + useSpring, + useTransform, +} from "motion/react"; +import React, { type PropsWithChildren, useRef } from "react"; + +import { cn } from "@/lib/utils"; + +export interface DockProps extends VariantProps { + className?: string; + iconSize?: number; + iconMagnification?: number; + disableMagnification?: boolean; + iconDistance?: number; + direction?: "top" | "middle" | "bottom"; + children: React.ReactNode; +} + +const DEFAULT_SIZE = 40; +const DEFAULT_MAGNIFICATION = 60; +const DEFAULT_DISTANCE = 140; +const DEFAULT_DISABLEMAGNIFICATION = false; + +const dockVariants = cva( + "supports-backdrop-blur:bg-white/10 supports-backdrop-blur:dark:bg-black/10 mx-auto mt-8 flex h-[58px] w-max items-center justify-center gap-2 rounded-2xl border p-2 backdrop-blur-md", +); + +const Dock = React.forwardRef( + ( + { + className, + children, + iconSize = DEFAULT_SIZE, + iconMagnification = DEFAULT_MAGNIFICATION, + disableMagnification = DEFAULT_DISABLEMAGNIFICATION, + iconDistance = DEFAULT_DISTANCE, + direction = "middle", + ...props + }, + ref, + ) => { + const mouseX = useMotionValue(Infinity); + + const renderChildren = () => { + return React.Children.map(children, (child) => { + if ( + React.isValidElement(child) && + child.type === DockIcon + ) { + return React.cloneElement(child, { + ...child.props, + mouseX: mouseX, + size: iconSize, + magnification: iconMagnification, + disableMagnification: disableMagnification, + distance: iconDistance, + }); + } + return child; + }); + }; + + return ( + mouseX.set(e.pageX)} + onMouseLeave={() => mouseX.set(Infinity)} + {...props} + className={cn(dockVariants({ className }), { + "items-start": direction === "top", + "items-center": direction === "middle", + "items-end": direction === "bottom", + })} + > + {renderChildren()} + + ); + }, +); + +Dock.displayName = "Dock"; + +export interface DockIconProps + extends Omit, "children"> { + size?: number; + magnification?: number; + disableMagnification?: boolean; + distance?: number; + mouseX?: MotionValue; + className?: string; + children?: React.ReactNode; + props?: PropsWithChildren; +} + +const DockIcon = ({ + size = DEFAULT_SIZE, + magnification = DEFAULT_MAGNIFICATION, + disableMagnification, + distance = DEFAULT_DISTANCE, + mouseX, + className, + children, + ...props +}: DockIconProps) => { + const ref = useRef(null); + const padding = Math.max(6, size * 0.2); + const defaultMouseX = useMotionValue(Infinity); + + const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => { + const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 }; + return val - bounds.x - bounds.width / 2; + }); + + const targetSize = disableMagnification ? size : magnification; + + const sizeTransform = useTransform( + distanceCalc, + [-distance, 0, distance], + [size, targetSize, size], + ); + + const scaleSize = useSpring(sizeTransform, { + mass: 0.1, + stiffness: 150, + damping: 12, + }); + + return ( + +
{children}
+
+ ); +}; + +DockIcon.displayName = "DockIcon"; + +export { Dock, DockIcon, dockVariants }; diff --git a/components/ui/dotted-map.tsx b/components/ui/dotted-map.tsx new file mode 100644 index 0000000..ecf8642 --- /dev/null +++ b/components/ui/dotted-map.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import { createMap } from "svg-dotted-map"; + +import { cn } from "@/lib/utils"; + +interface Marker { + lat: number; + lng: number; + size?: number; +} + +export interface DottedMapProps extends React.SVGProps { + width?: number; + height?: number; + mapSamples?: number; + markers?: Marker[]; + dotColor?: string; + markerColor?: string; + dotRadius?: number; + stagger?: boolean; +} + +export function DottedMap({ + width = 150, + height = 75, + mapSamples = 5000, + markers = [], + markerColor = "#FF6900", + dotRadius = 0.2, + stagger = true, + className, + style, +}: DottedMapProps) { + const { points, addMarkers } = createMap({ + width, + height, + mapSamples, + }); + + const processedMarkers = addMarkers(markers); + + // Compute stagger helpers in a single, simple pass + const { xStep, yToRowIndex } = React.useMemo(() => { + const sorted = [...points].sort((a, b) => a.y - b.y || a.x - b.x); + const rowMap = new Map(); + let step = 0; + let prevY = Number.NaN; + let prevXInRow = Number.NaN; + + for (const p of sorted) { + if (p.y !== prevY) { + // new row + prevY = p.y; + prevXInRow = Number.NaN; + if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size); + } + if (!Number.isNaN(prevXInRow)) { + const delta = p.x - prevXInRow; + if (delta > 0) step = step === 0 ? delta : Math.min(step, delta); + } + prevXInRow = p.x; + } + + return { xStep: step || 1, yToRowIndex: rowMap }; + }, [points]); + + return ( + + {points.map((point, index) => { + const rowIndex = yToRowIndex.get(point.y) ?? 0; + const offsetX = stagger && rowIndex % 2 === 1 ? xStep / 2 : 0; + return ( + + ); + })} + {processedMarkers.map((marker, index) => { + const rowIndex = yToRowIndex.get(marker.y) ?? 0; + const offsetX = stagger && rowIndex % 2 === 1 ? xStep / 2 : 0; + return ( + + ); + })} + + ); +} diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..ed700c2 --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,61 @@ +"use client"; + +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/package.json b/package.json index 4f0aa01..75a3278 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-form": "^1.23.8", "@tanstack/react-pacer": "^0.16.4", "@tanstack/react-query": "^5.90.5", @@ -40,15 +41,18 @@ "drizzle-orm": "^0.44.7", "drizzle-seed": "^0.3.1", "lucide-react": "^0.548.0", + "motion": "^12.23.24", "nanoid": "^5.1.6", "next": "15.5.6", "next-themes": "^0.4.6", + "ogl": "^1.0.11", "react": "^19.2.0", "react-dom": "^19.2.0", "react-intersection-observer": "^9.16.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", + "svg-dotted-map": "^2.0.1", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2",