From e8d59281a811f538b5366c088319a0c5a4d884cb Mon Sep 17 00:00:00 2001
From: Caleb
+ {code.slice(1, -1)}
+ ,
+ );
+ } else if (link) {
+ const linkMatch = link.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
+ if (linkMatch) {
+ parts.push(
+
+ {linkMatch[1]}
+ ,
+ );
+ } else {
+ parts.push(full);
+ }
+ } else if (channel) {
+ parts.push(
+
+ {channel}
+ ,
+ );
+ }
+
+ lastIndex = regex.lastIndex;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return <>{parts}>;
}
function ContentBlock({ block }: { block: PageContent }) {
@@ -59,7 +102,7 @@ function ContentBlock({ block }: { block: PageContent }) {
color: "#e2e2f0",
}}
>
-
+
{linkMatch[1]}
- ,
+ ,
);
} else {
parts.push(full);
diff --git a/components/LinkPreviewCard.tsx b/components/LinkPreviewCard.tsx
new file mode 100644
index 0000000..917cf98
--- /dev/null
+++ b/components/LinkPreviewCard.tsx
@@ -0,0 +1,218 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+interface PreviewData {
+ title: string | null;
+ description: string | null;
+ image: string | null;
+ url: string;
+}
+
+const previewCache = new Map();
+
+export function LinkPreviewCard({
+ href,
+ children,
+}: {
+ href: string;
+ children: React.ReactNode;
+}) {
+ const [hovering, setHovering] = useState(false);
+ const [data, setData] = useState(null);
+ const [imgError, setImgError] = useState(false);
+ const [pos, setPos] = useState({ top: 0, left: 0 });
+ const [mounted, setMounted] = useState(false);
+ const anchorRef = useRef(null);
+ const hideTimeoutRef = useRef | null>(null);
+ const fetchTimeoutRef = useRef | null>(null);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
+ if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!hovering) return;
+
+ const handleScroll = () => {
+ cancelHide();
+ setHovering(false);
+ };
+
+ window.addEventListener("scroll", handleScroll, { passive: true });
+ return () => window.removeEventListener("scroll", handleScroll);
+ }, [hovering]);
+
+ const fetchPreview = () => {
+ if (previewCache.has(href)) {
+ setData(previewCache.get(href) || null);
+ return;
+ }
+ fetch(`/api/link-preview?url=${encodeURIComponent(href)}`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((json) => {
+ previewCache.set(href, json);
+ setData(json);
+ })
+ .catch(() => {
+ previewCache.set(href, null);
+ setData(null);
+ });
+ };
+
+ const cancelHide = () => {
+ if (hideTimeoutRef.current) {
+ clearTimeout(hideTimeoutRef.current);
+ hideTimeoutRef.current = null;
+ }
+ };
+
+ const scheduleHide = () => {
+ cancelHide();
+ hideTimeoutRef.current = setTimeout(() => setHovering(false), 200);
+ };
+
+ const handleLinkEnter = () => {
+ cancelHide();
+ const rect = anchorRef.current?.getBoundingClientRect();
+ if (rect) {
+ setPos({ top: rect.bottom + 8, left: rect.left });
+ }
+ setHovering(true);
+ setImgError(false);
+ fetchTimeoutRef.current = setTimeout(fetchPreview, 150);
+ };
+
+ const handleLinkLeave = () => {
+ if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current);
+ scheduleHide();
+ };
+
+ const handleCardEnter = () => {
+ cancelHide();
+ };
+
+ const handleCardLeave = () => {
+ scheduleHide();
+ };
+
+ const hasContent = Boolean(data?.description);
+
+ return (
+ <>
+
+ {children}
+
+ {mounted &&
+ createPortal(
+
+ {hovering && hasContent && (
+
+
+
+
+
+
+
+ {data?.image && !imgError && (
+
setImgError(true)}
+ className="absolute top-0 right-0 w-14 h-14 object-cover"
+ style={{ border: "1px solid rgba(99,102,241,0.12)" }}
+ />
+ )}
+
+
+ {data?.title || href}
+
+ {data?.description && (
+
+ {data.description}
+
+ )}
+
+
+
+ )}
+ ,
+ document.body,
+ )}
+ >
+ );
+}
From b7eeb2b363064be8afbf137efc4dd0f145f27610 Mon Sep 17 00:00:00 2001
From: Caleb
Date: Thu, 9 Jul 2026 01:56:14 +0300
Subject: [PATCH 5/6] chore: organize component files
---
app/partners/page.tsx | 2 +-
app/resources/page.tsx | 2 +-
app/rules/page.tsx | 2 +-
components/{ => bits}/BorderGlow.tsx | 0
components/{ => bits}/FuzzyText.tsx | 0
components/{ => bits}/ShinyText.tsx | 0
components/{ => bits}/SoftAurora.tsx | 0
components/{ => bits}/TargetCursor.tsx | 0
components/home/BelongSection.tsx | 2 +-
components/home/FeaturesSection.tsx | 2 +-
components/home/HeroSection.tsx | 4 ++--
components/home/StatsSection.tsx | 2 +-
components/{ => site}/Footer.tsx | 0
components/{ => site}/Navbar.tsx | 0
components/{ => site}/Section.tsx | 0
15 files changed, 8 insertions(+), 8 deletions(-)
rename components/{ => bits}/BorderGlow.tsx (100%)
rename components/{ => bits}/FuzzyText.tsx (100%)
rename components/{ => bits}/ShinyText.tsx (100%)
rename components/{ => bits}/SoftAurora.tsx (100%)
rename components/{ => bits}/TargetCursor.tsx (100%)
rename components/{ => site}/Footer.tsx (100%)
rename components/{ => site}/Navbar.tsx (100%)
rename components/{ => site}/Section.tsx (100%)
diff --git a/app/partners/page.tsx b/app/partners/page.tsx
index 3515b84..25c0f30 100644
--- a/app/partners/page.tsx
+++ b/app/partners/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import ShinyText from "@/components/ShinyText";
+import ShinyText from "@/components/bits/ShinyText";
import { fadeInUp, staggerContainer } from "@/lib/animations";
import { motion } from "framer-motion";
import { ArrowRight, ExternalLink } from "lucide-react";
diff --git a/app/resources/page.tsx b/app/resources/page.tsx
index 401dedc..e406705 100644
--- a/app/resources/page.tsx
+++ b/app/resources/page.tsx
@@ -1,7 +1,7 @@
"use client";
import Badge from "@/components/Badge";
-import ShinyText from "@/components/ShinyText";
+import ShinyText from "@/components/bits/ShinyText";
import {
Drawer,
DrawerClose,
diff --git a/app/rules/page.tsx b/app/rules/page.tsx
index a64066a..ab21073 100644
--- a/app/rules/page.tsx
+++ b/app/rules/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import ShinyText from "@/components/ShinyText";
+import ShinyText from "@/components/bits/ShinyText";
import { fadeInUp, staggerContainer } from "@/lib/animations";
import { motion } from "framer-motion";
diff --git a/components/BorderGlow.tsx b/components/bits/BorderGlow.tsx
similarity index 100%
rename from components/BorderGlow.tsx
rename to components/bits/BorderGlow.tsx
diff --git a/components/FuzzyText.tsx b/components/bits/FuzzyText.tsx
similarity index 100%
rename from components/FuzzyText.tsx
rename to components/bits/FuzzyText.tsx
diff --git a/components/ShinyText.tsx b/components/bits/ShinyText.tsx
similarity index 100%
rename from components/ShinyText.tsx
rename to components/bits/ShinyText.tsx
diff --git a/components/SoftAurora.tsx b/components/bits/SoftAurora.tsx
similarity index 100%
rename from components/SoftAurora.tsx
rename to components/bits/SoftAurora.tsx
diff --git a/components/TargetCursor.tsx b/components/bits/TargetCursor.tsx
similarity index 100%
rename from components/TargetCursor.tsx
rename to components/bits/TargetCursor.tsx
diff --git a/components/home/BelongSection.tsx b/components/home/BelongSection.tsx
index ba01ac0..1bc4b30 100644
--- a/components/home/BelongSection.tsx
+++ b/components/home/BelongSection.tsx
@@ -2,7 +2,7 @@
import { fadeInUp, staggerContainer } from "@/lib/animations";
import { motion } from "framer-motion";
-import SoftAurora from "../SoftAurora";
+import SoftAurora from "../bits/SoftAurora";
const memberTypes = [
"Solo founders figuring it out as they go",
diff --git a/components/home/FeaturesSection.tsx b/components/home/FeaturesSection.tsx
index e13ec1c..9084ef6 100644
--- a/components/home/FeaturesSection.tsx
+++ b/components/home/FeaturesSection.tsx
@@ -12,7 +12,7 @@ import {
Users,
Wrench,
} from "lucide-react";
-import BorderGlow from "../BorderGlow";
+import BorderGlow from "../bits/BorderGlow";
const features = [
{
diff --git a/components/home/HeroSection.tsx b/components/home/HeroSection.tsx
index 1996dcc..d272f60 100644
--- a/components/home/HeroSection.tsx
+++ b/components/home/HeroSection.tsx
@@ -5,8 +5,8 @@ import data from "@/lib/staticdata.config";
import { motion } from "framer-motion";
import { ArrowDown, ArrowRight } from "lucide-react";
import { useEffect, useRef } from "react";
-import ShinyText from "../ShinyText";
-import SoftAurora from "../SoftAurora";
+import ShinyText from "../bits/ShinyText";
+import SoftAurora from "../bits/SoftAurora";
export default function HeroSection() {
const canvasRef = useRef(null);
diff --git a/components/home/StatsSection.tsx b/components/home/StatsSection.tsx
index 7070eae..96231e8 100644
--- a/components/home/StatsSection.tsx
+++ b/components/home/StatsSection.tsx
@@ -5,7 +5,7 @@ import { hexToHslString } from "@/lib/utils";
import { motion, useInView } from "framer-motion";
import { Code as Code2, MessageCircle, Users } from "lucide-react";
import { useEffect, useRef, useState } from "react";
-import BorderGlow from "../BorderGlow";
+import BorderGlow from "../bits/BorderGlow";
const stats = [
{ icon: Users, value: 500, label: "Members", suffix: "+", color: "#6366f1" },
diff --git a/components/Footer.tsx b/components/site/Footer.tsx
similarity index 100%
rename from components/Footer.tsx
rename to components/site/Footer.tsx
diff --git a/components/Navbar.tsx b/components/site/Navbar.tsx
similarity index 100%
rename from components/Navbar.tsx
rename to components/site/Navbar.tsx
diff --git a/components/Section.tsx b/components/site/Section.tsx
similarity index 100%
rename from components/Section.tsx
rename to components/site/Section.tsx
From 33894d9e9b84acd2b7d87310d1e93c8278f8495e Mon Sep 17 00:00:00 2001
From: Caleb
Date: Thu, 9 Jul 2026 02:09:23 +0300
Subject: [PATCH 6/6] feat: add safe guards to link preview card component
---
app/api/link-preview/route.ts | 54 ++++++++++++++++++++++++++++++---
app/layout.tsx | 4 +--
app/not-found.tsx | 2 +-
app/page.tsx | 2 +-
app/pages/[slug]/PageClient.tsx | 2 +-
components/LinkPreviewCard.tsx | 2 ++
vercel.json | 25 +++++++++++++++
7 files changed, 81 insertions(+), 10 deletions(-)
create mode 100644 vercel.json
diff --git a/app/api/link-preview/route.ts b/app/api/link-preview/route.ts
index 1db0e87..1b5c037 100644
--- a/app/api/link-preview/route.ts
+++ b/app/api/link-preview/route.ts
@@ -34,6 +34,33 @@ function decodeHtmlEntities(str: string): string {
.replace(/>/g, ">");
}
+const PRIVATE_HOSTNAME_PATTERNS = [
+ /^localhost$/i,
+ /^0\.0\.0\.0$/,
+ /^127\./,
+ /^10\./,
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
+ /^192\.168\./,
+ /^169\.254\./,
+ /^::1$/,
+ /^fc00:/i,
+ /^fe80:/i,
+];
+
+function isSafeUrl(candidate: string): boolean {
+ let parsed: URL;
+ try {
+ parsed = new URL(candidate);
+ } catch {
+ return false;
+ }
+ if (!["http:", "https:"].includes(parsed.protocol)) {
+ return false;
+ }
+ const hostname = parsed.hostname.toLowerCase();
+ return !PRIVATE_HOSTNAME_PATTERNS.some((pattern) => pattern.test(hostname));
+}
+
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
@@ -42,10 +69,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "missing url" }, { status: 400 });
}
- try {
- new URL(url);
- } catch {
- return NextResponse.json({ error: "invalid url" }, { status: 400 });
+ if (!isSafeUrl(url)) {
+ return NextResponse.json(
+ { error: "invalid or disallowed url" },
+ { status: 400 },
+ );
}
try {
@@ -54,6 +82,7 @@ export async function GET(request: NextRequest) {
const res = await fetch(url, {
signal: controller.signal,
+ redirect: "error",
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; LinkPreviewBot/1.0; +https://example.com/bot)",
@@ -66,7 +95,21 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "failed to fetch" }, { status: 502 });
}
+ const contentLength = res.headers.get("content-length");
+ if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) {
+ return NextResponse.json(
+ { error: "response too large" },
+ { status: 502 },
+ );
+ }
+
const html = await res.text();
+ if (html.length > 1024 * 1024) {
+ return NextResponse.json(
+ { error: "response too large" },
+ { status: 502 },
+ );
+ }
const rawTitle = extractMeta(html, "og:title") || extractTitleTag(html);
const rawDescription =
@@ -76,7 +119,8 @@ export async function GET(request: NextRequest) {
let image: string | null = null;
if (rawImage) {
try {
- image = new URL(rawImage, url).toString();
+ const resolvedImage = new URL(rawImage, url).toString();
+ image = isSafeUrl(resolvedImage) ? resolvedImage : null;
} catch {
image = null;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 8a11ee8..6c614e7 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,5 +1,5 @@
-import Footer from "@/components/Footer";
-import Navbar from "@/components/Navbar";
+import Footer from "@/components/site/Footer";
+import Navbar from "@/components/site/Navbar";
import type { Metadata } from "next";
import { Geist, Geist_Mono, Pixelify_Sans } from "next/font/google";
import "./globals.css";
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 30f930a..2c52910 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,6 +1,6 @@
"use client";
-import FuzzyText from "@/components/FuzzyText";
+import FuzzyText from "@/components/bits/FuzzyText";
import { fadeInUp } from "@/lib/animations";
import { motion } from "framer-motion";
import { ArrowLeft, ArrowRight } from "lucide-react";
diff --git a/app/page.tsx b/app/page.tsx
index 8fbbcc0..4270baa 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,10 +1,10 @@
+import TargetCursor from "@/components/bits/TargetCursor";
import BelongSection from "@/components/home/BelongSection";
import CTASection from "@/components/home/CTASection";
import FeaturesSection from "@/components/home/FeaturesSection";
import HeroSection from "@/components/home/HeroSection";
import ShowcaseSection from "@/components/home/ShowcaseSection";
import StatsSection from "@/components/home/StatsSection";
-import TargetCursor from "@/components/TargetCursor";
export default function Home() {
return (
diff --git a/app/pages/[slug]/PageClient.tsx b/app/pages/[slug]/PageClient.tsx
index efad2e5..0d8b210 100644
--- a/app/pages/[slug]/PageClient.tsx
+++ b/app/pages/[slug]/PageClient.tsx
@@ -40,7 +40,7 @@ function ApplySpecialClass({ text }: { text: string }) {
parts.push(
setImgError(true)}
className="absolute top-0 right-0 w-14 h-14 object-cover"
style={{ border: "1px solid rgba(99,102,241,0.12)" }}
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..0c4276c
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,25 @@
+{
+ "headers": [
+ {
+ "source": "/api/link-preview",
+ "headers": [
+ {
+ "key": "Access-Control-Allow-Origin",
+ "value": "https://devhub.vercel.app"
+ },
+ {
+ "key": "Access-Control-Allow-Methods",
+ "value": "GET, OPTIONS"
+ },
+ {
+ "key": "Access-Control-Allow-Headers",
+ "value": "Content-Type"
+ },
+ {
+ "key": "Vary",
+ "value": "Origin"
+ }
+ ]
+ }
+ ]
+}