feat: enhance SurfSense with new skills, blog section, and improve SEO metadata
Some checks failed
Build and Push Docker Images / tag_release (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Has been cancelled
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Has been cancelled

- Added multiple new skills to skills-lock.json from the repository `aaron-he-zhu/seo-geo-claude-skills`.
- Introduced `fuzzy-search` dependency in package.json for improved search functionality.
- Updated pnpm-lock.yaml to include the new `fuzzy-search` package.
- Enhanced SEO metadata across various pages, including canonical links and descriptions for better search visibility.
- Improved layout and structure of several components, including the homepage and changelog, to enhance user experience.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-11 23:38:12 -07:00
parent 61b3f0d7e3
commit 7ea840dbb2
120 changed files with 25729 additions and 352 deletions

View file

@ -0,0 +1,163 @@
import { loader } from "fumadocs-core/source";
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import { blog } from "@/.source/server";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { ArticleJsonLd } from "@/components/seo/json-ld";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
const source = loader({
baseUrl: "/blog",
source: blog.toFumadocsSource(),
});
interface BlogData {
title: string;
description: string;
date: string;
image?: string;
author?: string;
authorAvatar?: string;
tags?: string[];
body: React.ComponentType<{
components?: Record<string, React.ComponentType>;
}>;
}
interface BlogPageItem {
url: string;
slugs: string[];
data: BlogData;
}
export async function generateStaticParams() {
return source.getPages().map((page) => ({
slug: (page as BlogPageItem).slugs.join("/"),
}));
}
export async function generateMetadata(props: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await props.params;
const page = (source.getPages() as BlogPageItem[]).find(
(p) => p.slugs.join("/") === slug,
);
if (!page) return {};
return {
title: `${page.data.title} | SurfSense Blog`,
description: page.data.description,
alternates: {
canonical: `https://surfsense.com/blog/${slug}`,
},
openGraph: {
title: page.data.title,
description: page.data.description,
type: "article",
publishedTime: page.data.date,
authors: [page.data.author ?? "SurfSense Team"],
tags: page.data.tags,
images: page.data.image ? [{ url: page.data.image }] : [{ url: "/og-image.png" }],
},
twitter: {
card: "summary_large_image",
title: page.data.title,
description: page.data.description,
images: page.data.image ? [page.data.image] : ["/og-image.png"],
},
};
}
export default async function BlogPostPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const page = (source.getPages() as BlogPageItem[]).find(
(p) => p.slugs.join("/") === slug,
);
if (!page) notFound();
const MDX = page.data.body;
const date = new Date(page.data.date);
return (
<div className="min-h-screen relative pt-20">
<ArticleJsonLd
title={page.data.title}
description={page.data.description}
url={`https://surfsense.com/blog/${slug}`}
datePublished={page.data.date}
author={page.data.author ?? "SurfSense Team"}
image={page.data.image ? `https://surfsense.com${page.data.image}` : undefined}
/>
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-10 pb-20">
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog" },
{ name: page.data.title, href: `/blog/${slug}` },
]}
className="mb-8"
/>
{page.data.image && (
<div className="relative aspect-2/1 overflow-hidden rounded-2xl mb-8">
<Image
src={page.data.image}
alt={page.data.title}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
)}
<div className="space-y-4 mb-10">
<h1 className="text-3xl md:text-4xl font-bold tracking-tight text-balance">
{page.data.title}
</h1>
{page.data.tags && page.data.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{page.data.tags.map((tag: string) => (
<span
key={tag}
className="h-6 w-fit px-2.5 text-xs font-medium bg-muted text-muted-foreground rounded-full border flex items-center justify-center"
>
{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
{page.data.authorAvatar && (
<Image
src={page.data.authorAvatar}
alt={page.data.author ?? "SurfSense Team"}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
/>
)}
<span className="font-medium text-foreground">
{page.data.author ?? "SurfSense Team"}
</span>
<span>·</span>
<time dateTime={page.data.date}>{formatDate(date)}</time>
</div>
</div>
<div className="prose dark:prose-invert max-w-none prose-headings:scroll-mt-8 prose-headings:font-semibold prose-a:no-underline prose-headings:tracking-tight prose-headings:text-balance prose-p:tracking-tight prose-p:text-balance prose-img:rounded-xl prose-img:shadow-lg">
<MDX components={getMDXComponents()} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,228 @@
"use client";
import { Container } from "@/components/container";
import { format } from "date-fns";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import FuzzySearch from "fuzzy-search";
import type { BlogEntry } from "./page";
function truncate(text: string, length: number) {
return text.length > length ? `${text.slice(0, length)}` : text;
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
const featured = blogs[0];
if (!featured) {
return (
<div className="relative overflow-hidden bg-neutral-50 px-4 md:px-8 dark:bg-neutral-950">
<Container className="relative pt-12 pb-24 md:pt-20">
<p className="text-center text-neutral-500">No blog posts yet.</p>
</Container>
</div>
);
}
return (
<div className="relative overflow-hidden bg-neutral-50 px-4 pt-20 md:px-8 dark:bg-neutral-950">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.15),transparent)] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.12),transparent)]" />
<Container className="relative pt-12 pb-24 md:pt-20">
<header className="mb-10 md:mb-14">
<h1 className="text-4xl font-bold tracking-tight text-neutral-900 md:text-5xl dark:text-neutral-50">
Blog
</h1>
</header>
<MagazineFeatured blog={featured} />
<MagazineSearchGrid blogs={blogs} featuredSlug={featured.slug} />
</Container>
</div>
);
}
function MagazineFeatured({ blog }: { blog: BlogEntry }) {
return (
<Link
href={blog.url}
className="group/cover relative mb-14 block overflow-hidden rounded-3xl border border-neutral-200/80 bg-neutral-900 shadow-sm dark:border-neutral-800 dark:shadow-none"
>
<div className="md:aspect-[2.4/1] relative aspect-21/9 min-h-[220px]">
{blog.image ? (
<img
src={blog.image}
alt={blog.title}
className="h-full w-full object-cover transition duration-500 group-hover/cover:scale-[1.03]"
/>
) : null}
<div className="absolute inset-0 bg-linear-to-t from-black/85 via-black/35 to-transparent" />
<div className="absolute inset-0 flex flex-col justify-end p-6 md:p-10">
<span className="mb-2 inline-flex w-fit rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
Cover story
</span>
<h2 className="max-w-3xl font-serif text-2xl leading-tight font-medium text-white md:text-4xl">
{blog.title}
</h2>
<p className="mt-3 max-w-2xl text-sm text-white/85 md:text-base">
{truncate(blog.description, 160)}
</p>
<div className="mt-5 flex flex-wrap items-center gap-3 text-sm text-white/90">
<span className="flex items-center gap-2">
<img
src={blog.authorAvatar}
alt={blog.author}
width={28}
height={28}
className="h-7 w-7 rounded-full ring-2 ring-white/30"
/>
{blog.author}
</span>
<span className="text-white/50">·</span>
<time dateTime={blog.date}>
{format(new Date(blog.date), "MMMM d, yyyy")}
</time>
</div>
</div>
</div>
</Link>
);
}
function MagazineSearchGrid({
blogs: allBlogs,
featuredSlug,
}: {
blogs: BlogEntry[];
featuredSlug: string;
}) {
const [search, setSearch] = useState("");
const searcher = useMemo(
() =>
new FuzzySearch(allBlogs, ["title", "description"], {
caseSensitive: false,
}),
[allBlogs],
);
const [results, setResults] = useState(allBlogs);
useEffect(() => {
setResults(searcher.search(search));
}, [search, searcher]);
const gridItems = useMemo(() => {
if (search.trim()) {
return results;
}
return results.filter((b) => b.slug !== featuredSlug);
}, [results, search, featuredSlug]);
return (
<section aria-labelledby="archive-heading">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h2
id="archive-heading"
className="font-serif text-2xl font-medium text-neutral-900 dark:text-neutral-100"
>
From the archive
</h2>
<label className="relative w-full sm:max-w-md">
<span className="sr-only">Search articles</span>
<SearchIcon className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 text-neutral-400" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by title or topic…"
className="w-full rounded-full bg-white py-3 pr-4 pl-12 text-sm text-neutral-800 shadow-sm ring-1 shadow-black/10 ring-black/10 transition outline-none placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-2 focus:ring-neutral-200/80 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 dark:focus:border-neutral-500 dark:focus:ring-neutral-700/50"
/>
</label>
</div>
{gridItems.length === 0 ? (
<p className="rounded-2xl border border-dashed border-neutral-300 py-16 text-center text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">
No articles match that search.
</p>
) : (
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{gridItems.map((blog) => (
<li key={blog.slug}>
<MagazineCard blog={blog} />
</li>
))}
</ul>
)}
</section>
);
}
function MagazineCard({ blog }: { blog: BlogEntry }) {
return (
<Link
href={blog.url}
className="group/card flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition hover:-translate-y-0.5 hover:shadow-lg dark:border-neutral-800 dark:bg-neutral-900/50 dark:hover:border-neutral-700"
>
<div className="relative aspect-16/10 overflow-hidden bg-neutral-100 dark:bg-neutral-800">
{blog.image ? (
<img
src={blog.image}
alt={blog.title}
className="h-full w-full object-cover transition duration-300 group-hover/card:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-neutral-400">
No image
</div>
)}
</div>
<div className="flex flex-1 flex-col p-5">
<time
className="text-xs font-medium tracking-wider text-neutral-500 uppercase dark:text-neutral-400"
dateTime={blog.date}
>
{format(new Date(blog.date), "MMM d, yyyy")}
</time>
<h3 className="mt-2 font-serif text-lg leading-snug font-medium text-neutral-900 dark:text-neutral-100">
{blog.title}
</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
{truncate(blog.description, 110)}
</p>
<div className="mt-4 flex items-center gap-2 pt-4">
<img
src={blog.authorAvatar}
alt={blog.author}
width={24}
height={24}
className="h-6 w-6 rounded-full object-cover"
/>
<span className="text-xs text-neutral-600 dark:text-neutral-300">
{blog.author}
</span>
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,61 @@
import { loader } from "fumadocs-core/source";
import type { Metadata } from "next";
import { blog } from "@/.source/server";
import { BlogWithSearchMagazine } from "./blog-magazine";
export const metadata: Metadata = {
title: "Blog | SurfSense - AI Search & Knowledge Management",
description:
"Product updates, tutorials, and tips from the SurfSense team.",
alternates: {
canonical: "https://surfsense.com/blog",
},
};
const source = loader({
baseUrl: "/blog",
source: blog.toFumadocsSource(),
});
export interface BlogEntry {
title: string;
description: string;
date: string;
slug: string;
url: string;
image: string;
author: string;
authorAvatar: string;
}
export default async function BlogPage() {
const allPages = source.getPages() as Array<{
url: string;
slugs: string[];
data: {
title: string;
description: string;
date: string;
image?: string;
author?: string;
authorAvatar?: string;
};
}>;
const blogs: BlogEntry[] = allPages
.map((page) => ({
title: page.data.title,
description: page.data.description ?? "",
date: page.data.date,
slug: page.slugs.join("/"),
url: page.url,
image: page.data.image ?? "/og-image.png",
author: page.data.author ?? "SurfSense Team",
authorAvatar: page.data.authorAvatar ?? "/logo.png",
}))
.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
return <BlogWithSearchMagazine blogs={blogs} />;
}

View file

@ -1,12 +1,16 @@
import { loader } from "fumadocs-core/source";
import type { Metadata } from "next";
import { changelog } from "@/.source/server";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
export const metadata: Metadata = {
title: "Changelog | SurfSense",
description: "See what's new in SurfSense.",
description: "See what's new in SurfSense. Latest updates, features, and improvements.",
alternates: {
canonical: "https://surfsense.com/changelog",
},
};
const source = loader({
@ -42,6 +46,13 @@ export default async function ChangelogPage() {
<div className="max-w-5xl mx-auto relative">
<div className="p-6 flex items-center justify-between">
<div>
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Changelog", href: "/changelog" },
]}
className="mb-4"
/>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Changelog
</h1>

View file

@ -3,7 +3,10 @@ import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
export const metadata: Metadata = {
title: "Contact | SurfSense",
description: "Get in touch with the SurfSense team.",
description: "Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.",
alternates: {
canonical: "https://surfsense.com/contact",
},
};
const page = () => {

View file

@ -1,49 +1,23 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { HeroSection } from "@/components/homepage/hero-section";
import { getBearerToken } from "@/lib/auth-utils";
import { AuthRedirect } from "@/components/homepage/auth-redirect";
import { FeaturesCards } from "@/components/homepage/features-card";
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
const WhySurfSense = dynamic(
() => import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense })),
{ ssr: false }
);
const FeaturesCards = dynamic(
() => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })),
{ ssr: false }
);
const FeaturesBentoGrid = dynamic(
() =>
import("@/components/homepage/features-bento-grid").then((m) => ({
default: m.FeaturesBentoGrid,
})),
{ ssr: false }
);
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"), {
ssr: false,
});
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"));
const CTAHomepage = dynamic(
() => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })),
{ ssr: false }
);
export default function HomePage() {
const router = useRouter();
useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
}
}, [router]);
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<AuthRedirect />
<HeroSection />
<WhySurfSense />
<FeaturesCards />

View file

@ -1,14 +1,27 @@
import type { Metadata } from "next";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import PricingBasic from "@/components/pricing/pricing-section";
export const metadata: Metadata = {
title: "Pricing | SurfSense",
description: "Explore SurfSense plans and pricing options.",
title: "Pricing | SurfSense - Free AI Search Plans",
description:
"Explore SurfSense plans and pricing. Use ChatGPT, Claude AI, and any AI model free. Open source NotebookLM alternative for teams.",
alternates: {
canonical: "https://surfsense.com/pricing",
},
};
const page = () => {
return (
<div>
<div className="container mx-auto pt-24 px-4">
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Pricing", href: "/pricing" },
]}
/>
</div>
<PricingBasic />
</div>
);

View file

@ -3,6 +3,9 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Privacy Policy | SurfSense",
description: "Privacy Policy for SurfSense application",
alternates: {
canonical: "https://surfsense.com/privacy",
},
};
export default function PrivacyPolicy() {

View file

@ -3,6 +3,9 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Terms of Service | SurfSense",
description: "Terms of Service for SurfSense application",
alternates: {
canonical: "https://surfsense.com/terms",
},
};
export default function TermsOfService() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

@ -45,8 +45,17 @@ export async function generateMetadata(props: { params: Promise<{ slug?: string[
const page = getDocPage(params.slug);
if (!page) notFound();
const slugPath = params.slug ? params.slug.join("/") : "";
return {
title: page.data.title,
title: `${page.data.title} | SurfSense Docs`,
description: page.data.description,
alternates: {
canonical: `https://surfsense.com/docs${slugPath ? `/${slugPath}` : ""}`,
},
openGraph: {
title: `${page.data.title} | SurfSense Docs`,
description: page.data.description,
type: "article",
},
};
}

BIN
surfsense_web/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

@ -13,6 +13,7 @@ import { LocaleProvider } from "@/contexts/LocaleContext";
import { PlatformProvider } from "@/contexts/platform-context";
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
import { cn } from "@/lib/utils";
import { OrganizationJsonLd, SoftwareApplicationJsonLd, WebSiteJsonLd } from "@/components/seo/json-ld";
const roboto = Roboto({
subsets: ["latin"],
@ -36,82 +37,81 @@ export const viewport: Viewport = {
};
export const metadata: Metadata = {
title: "SurfSense - Open Source NotebookLM Alternative for Teams",
metadataBase: new URL("https://surfsense.com"),
alternates: {
canonical: "https://surfsense.com",
},
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI",
description:
"An open source, privacy focused alternative to NotebookLM for teams with no data limits, built for enterprise AI search and knowledge management.",
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
keywords: [
"enterprise ai",
"enterprise search",
"enterprise search software",
"chatgpt alternative free",
"ai enterprise search",
"enterprise search solutions",
"intranet search engine",
"federated search",
"enterprise search engine",
"what is enterprise search",
"enterprise knowledge management software",
"free chatgpt alternative",
"chatgpt free alternative",
"best enterprise search software",
"enterprise ai search",
"enterprise knowledge management",
"federated search engine",
"enterprise knowledge management system",
"free claude ai",
"what is enterprise search engine marketing",
"ai driven enterprise search",
"free alternative to chatgpt",
"free claude",
"alternative to chatgpt free",
"free ai chatbot like chatgpt",
"enterprise search software comparison",
"apps like chatgpt for free",
"free chatgpt no login",
"free ai chatbots like chatgpt",
"enterprise document search",
"search engine for intranet",
"chatgpt online",
"online chatgpt",
"chat gpt free",
"chatgpt free",
"free chatgpt",
"free chat gpt",
"chatgpt no login",
"chatgpt online free",
"chatgpt free online",
"chatgpt without login",
"free chatgpt without login",
"unified search engine",
"free chatgpt no login",
"chatgpt for free",
"claude ai free",
"claude free",
"free claude ai",
"free claude",
"chatgpt alternative free",
"free chatgpt alternative",
"free alternative to chatgpt",
"alternative to chatgpt free",
"chatgpt alternative online free",
"ai like chatgpt",
"sites like chatgpt",
"free ai chatbot like chatgpt",
"apps like chatgpt for free",
"free ai chatbots like chatgpt",
"best free alternative to chatgpt",
"free chatgpt alternative app",
"free chatgpt alternative with image upload",
"best free alternative to chatgpt",
"enterprise search engine open source",
"open source notebooklm alternative",
"free ai apps",
"ai with no restrictions",
"notebooklm alternative",
"notebooklm alternative for teams",
"open source notebooklm alternative",
"SurfSense",
],
openGraph: {
title: "SurfSense - Open Source NotebookLM Alternative for Teams",
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI",
description:
"An open source, privacy focused alternative to NotebookLM for teams with no data limits. Open source enterprise AI search and knowledge management.",
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude, and any AI model for free.",
url: "https://surfsense.com",
siteName: "SurfSense",
type: "website",
images: [
{
url: "https://surfsense.com/og-image.png",
url: "/og-image.png",
width: 1200,
height: 630,
alt: "SurfSense - Open Source NotebookLM Alternative for Teams",
alt: "SurfSense - Open Source NotebookLM Alternative with Free ChatGPT and Claude AI",
},
],
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: "SurfSense - Open Source NotebookLM Alternative for Teams",
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI",
description:
"An open source, privacy focused alternative to NotebookLM for teams with no data limits. Open source enterprise AI search and knowledge management.",
creator: "https://surfsense.com",
site: "https://surfsense.com",
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
creator: "@SurfSenseAI",
site: "@SurfSenseAI",
images: [
{
url: "https://surfsense.com/og-image-twitter.png",
url: "/og-image-twitter.png",
width: 1200,
height: 630,
alt: "SurfSense - Open Source NotebookLM Alternative for Teams",
alt: "SurfSense - Open Source NotebookLM Alternative with Free ChatGPT and Claude AI",
},
],
},
@ -129,6 +129,9 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://api.github.com" />
<OrganizationJsonLd />
<WebSiteJsonLd />
<SoftwareApplicationJsonLd />
</head>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
<PostHogProvider>

View file

@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Page Not Found | SurfSense",
description: "The page you're looking for doesn't exist. Explore SurfSense - open source enterprise AI search and knowledge management.",
};
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4 text-center">
<h1 className="text-8xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
404
</h1>
<p className="mt-4 text-xl text-neutral-600 dark:text-neutral-400">
The page you&apos;re looking for doesn&apos;t exist.
</p>
<p className="mt-2 text-base text-neutral-500 dark:text-neutral-500">
It may have been moved, or the URL might be incorrect.
</p>
<div className="mt-10 flex flex-col items-center gap-4 sm:flex-row">
<Link
href="/"
className="rounded-lg bg-black px-6 py-3 text-sm font-medium text-white transition hover:bg-neutral-800 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
Go Home
</Link>
<Link
href="/docs"
className="rounded-lg border border-neutral-200 px-6 py-3 text-sm font-medium text-neutral-700 transition hover:bg-neutral-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Browse Docs
</Link>
<Link
href="/blog"
className="rounded-lg border border-neutral-200 px-6 py-3 text-sm font-medium text-neutral-700 transition hover:bg-neutral-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Read Blog
</Link>
</div>
<nav className="mt-16 flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm text-neutral-500 dark:text-neutral-400">
<Link href="/pricing" className="hover:text-neutral-900 dark:hover:text-neutral-200">
Pricing
</Link>
<Link href="/contact" className="hover:text-neutral-900 dark:hover:text-neutral-200">
Contact
</Link>
<Link href="/changelog" className="hover:text-neutral-900 dark:hover:text-neutral-200">
Changelog
</Link>
</nav>
</div>
);
}

View file

@ -0,0 +1,22 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/dashboard/",
"/desktop/",
"/auth/",
"/api/",
"/invite/",
"/public/",
"/verify-token/",
],
},
],
sitemap: "https://surfsense.com/sitemap.xml",
};
}

View file

@ -1,259 +1,59 @@
import { loader } from "fumadocs-core/source";
import type { MetadataRoute } from "next";
import { blog, changelog } from "@/.source/server";
import { source as docsSource } from "@/lib/source";
// Returns a date rounded to the current hour (updates only once per hour)
function getHourlyDate(): Date {
const now = new Date();
now.setMinutes(0, 0, 0);
return now;
}
const blogSource = loader({
baseUrl: "/blog",
source: blog.toFumadocsSource(),
});
const changelogSource = loader({
baseUrl: "/changelog",
source: changelog.toFumadocsSource(),
});
const BASE_URL = "https://surfsense.com";
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = getHourlyDate();
const now = new Date();
now.setMinutes(0, 0, 0);
const lastModified = now;
return [
{
url: "https://www.surfsense.com/",
lastModified,
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/contact",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/pricing",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/privacy",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/terms",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
// Documentation pages
{
url: "https://www.surfsense.com/docs",
lastModified,
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/docs/installation",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/prerequisites",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/install-script",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/docker-compose",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/updating",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/dev-compose",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/migrate-from-allinone",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/manual-installation",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
// Connector documentation
{
url: "https://www.surfsense.com/docs/connectors/airtable",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/bookstack",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/circleback",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/clickup",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/confluence",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/discord",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/dropbox",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/elasticsearch",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/github",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/gmail",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-calendar",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-drive",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/jira",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/linear",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/luma",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-onedrive",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-teams",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/notion",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/obsidian",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/slack",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/web-crawler",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
// How-to documentation
{
url: "https://www.surfsense.com/docs/how-to/zero-sync",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/how-to/realtime-collaboration",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/how-to/web-search",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
// Developer documentation
{
url: "https://www.surfsense.com/docs/testing",
lastModified,
changeFrequency: "daily",
priority: 0.7,
},
{
url: "https://www.surfsense.com/docs/code-of-conduct",
lastModified,
changeFrequency: "daily",
priority: 0.7,
},
const staticPages: MetadataRoute.Sitemap = [
{ url: `${BASE_URL}/`, lastModified, changeFrequency: "daily", priority: 1 },
{ url: `${BASE_URL}/pricing`, lastModified, changeFrequency: "weekly", priority: 0.9 },
{ url: `${BASE_URL}/contact`, lastModified, changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE_URL}/blog`, lastModified, changeFrequency: "daily", priority: 0.9 },
{ url: `${BASE_URL}/changelog`, lastModified, changeFrequency: "weekly", priority: 0.7 },
{ url: `${BASE_URL}/announcements`, lastModified, changeFrequency: "weekly", priority: 0.6 },
{ url: `${BASE_URL}/docs`, lastModified, changeFrequency: "daily", priority: 1 },
{ url: `${BASE_URL}/privacy`, lastModified, changeFrequency: "monthly", priority: 0.3 },
{ url: `${BASE_URL}/terms`, lastModified, changeFrequency: "monthly", priority: 0.3 },
{ url: `${BASE_URL}/login`, lastModified, changeFrequency: "monthly", priority: 0.5 },
{ url: `${BASE_URL}/register`, lastModified, changeFrequency: "monthly", priority: 0.5 },
];
const docsPages: MetadataRoute.Sitemap = docsSource.getPages().map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
const blogPages: MetadataRoute.Sitemap = blogSource.getPages().map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
const changelogPages: MetadataRoute.Sitemap = changelogSource.getPages().map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.5,
}));
return [...staticPages, ...docsPages, ...blogPages, ...changelogPages];
}

View file

@ -0,0 +1,38 @@
---
title: "Why Enterprise Search Matters More Than Ever"
description: "As organizations generate more data across more tools, finding the right information at the right time has become a critical competitive advantage. Here's why enterprise search is no longer optional."
date: "2026-04-10"
image: "/og-image.png"
author: "SurfSense Team"
authorAvatar: "/logo.png"
tags: ["Enterprise Search", "Productivity", "Knowledge Management"]
---
## The Information Overload Problem
The average knowledge worker switches between **11 different applications** per day and spends nearly 20% of their time searching for information. That's an entire day each week lost to context-switching and hunting for answers buried in Slack threads, Google Docs, Notion pages, and email chains.
Enterprise search solves this by providing a **single, unified interface** to query across all your knowledge sources simultaneously.
## What Makes Modern Enterprise Search Different
Traditional enterprise search was little more than a keyword matcher slapped onto a file server. Modern solutions like SurfSense take a fundamentally different approach:
- **Semantic Understanding**: AI-powered search understands the *meaning* behind your query, not just the keywords
- **Federated Architecture**: Connect dozens of data sources without migrating data out of where it lives
- **Contextual Ranking**: Results are ranked by relevance to your role, recent activity, and team context
- **Real-time Indexing**: New content becomes searchable within minutes, not days
## The ROI of Getting Search Right
Organizations that invest in proper enterprise search see measurable improvements:
- **30% reduction** in time spent searching for information
- **25% faster** onboarding for new team members
- **40% fewer** duplicate documents and redundant work
## Getting Started
The best time to implement enterprise search was when your team hit 20 people. The second best time is now. Start by auditing which tools your team uses daily, then look for a solution that connects to all of them natively.
SurfSense connects to Slack, Google Drive, Notion, Confluence, GitHub, and dozens more — all with a single search bar.

View file

@ -15,7 +15,7 @@ export const Logo = ({
<Image
src="/icon-128.svg"
className={cn("select-none dark:invert", className)}
alt="logo"
alt="SurfSense"
width={128}
height={128}
priority={priority}

View file

@ -10,7 +10,6 @@ import {
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
import type { AnnouncementWithState } from "@/hooks/use-announcements";
@ -67,7 +66,7 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base leading-tight">{announcement.title}</CardTitle>
<h2 className="text-base font-semibold leading-tight tracking-tight">{announcement.title}</h2>
<Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0">
{config.label}
</Badge>

View file

@ -16,9 +16,9 @@ export function ContactFormGridWithDetails() {
<IconMailFilled className="h-6 w-6 text-blue-500" />
</FeatureIconContainer>
</div>
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
Contact
</h2>
<h1 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
Contact
</h1>
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
We'd love to hear from you!
</p>

View file

@ -0,0 +1,11 @@
import { cn } from "@/lib/utils";
export function Container({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return <div className={cn("mx-auto max-w-7xl px-4 sm:px-6 lg:px-8", className)}>{children}</div>;
}

View file

@ -0,0 +1,17 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { getBearerToken } from "@/lib/auth-utils";
export function AuthRedirect() {
const router = useRouter();
useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
}
}, [router]);
return null;
}

View file

@ -25,6 +25,10 @@ export function FooterNew() {
title: "Pricing",
href: "/pricing",
},
{
title: "Blog",
href: "/blog",
},
{
title: "Docs",
href: "/docs",

View file

@ -153,14 +153,13 @@ export function HeroSection() {
</h1>
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
<div>
<h2
className={cn(
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
)}
>
An open source, privacy focused alternative to NotebookLM for teams with no data
limits.
</h2>
<p
className={cn(
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
)}
>
A free, open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.
</p>
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
<DownloadButton />

View file

@ -180,11 +180,11 @@ export default function ExternalIntegrations() {
>
{/* Heading */}
<div className="text-center mb-12 md:mb-16 relative z-20 px-4">
<h3 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-[1.1] tracking-tight">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-[1.1] tracking-tight">
Integrate with your
<br />
team&apos;s most important tools
</h3>
</h2>
</div>
{/* Scrolling columns container — masked at edges so the page background shows through seamlessly */}

View file

@ -36,6 +36,7 @@ export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
const navItems = [
{ name: "Pricing", link: "/pricing" },
{ name: "Blog", link: "/blog" },
{ name: "Changelog", link: "/changelog" },
{ name: "Docs", link: "/docs" },
{ name: "Contact\u00A0Us", link: "/contact" },

View file

@ -72,7 +72,7 @@ export function Pricing({
return (
<div className="container mx-auto py-20">
<div className="text-center space-y-4 mb-12">
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h2>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h1>
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
</div>

View file

@ -0,0 +1,52 @@
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { BreadcrumbJsonLd } from "./json-ld";
interface BreadcrumbItem {
name: string;
href: string;
}
interface BreadcrumbNavProps {
items: BreadcrumbItem[];
className?: string;
}
export function BreadcrumbNav({ items, className }: BreadcrumbNavProps) {
const jsonLdItems = items.map((item) => ({
name: item.name,
url: `https://surfsense.com${item.href}`,
}));
return (
<>
<BreadcrumbJsonLd items={jsonLdItems} />
<nav aria-label="Breadcrumb" className={className}>
<ol className="flex items-center gap-1.5 text-sm text-muted-foreground">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={item.href} className="flex items-center gap-1.5">
{index > 0 && (
<ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
)}
{isLast ? (
<span className="font-medium text-foreground" aria-current="page">
{item.name}
</span>
) : (
<Link
href={item.href}
className="transition-colors hover:text-foreground"
>
{item.name}
</Link>
)}
</li>
);
})}
</ol>
</nav>
</>
);
}

View file

@ -0,0 +1,186 @@
interface JsonLdProps {
data: Record<string, unknown>;
}
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
export function OrganizationJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Organization",
name: "SurfSense",
url: "https://surfsense.com",
logo: "https://surfsense.com/logo.png",
description:
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
sameAs: [
"https://github.com/MODSetter/SurfSense",
"https://discord.gg/Cg2M4GUJ",
],
contactPoint: {
"@type": "ContactPoint",
email: "rohan@surfsense.com",
contactType: "sales",
},
}}
/>
);
}
export function WebSiteJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "WebSite",
name: "SurfSense",
url: "https://surfsense.com",
description:
"Open source NotebookLM alternative for teams with no data limits. Free ChatGPT, Claude AI, and any AI model.",
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: "https://surfsense.com/docs?search={search_term_string}",
},
"query-input": "required name=search_term_string",
},
}}
/>
);
}
export function SoftwareApplicationJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "SurfSense",
applicationCategory: "BusinessApplication",
operatingSystem: "Windows, macOS, Linux, Web",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
description: "Free plan with 500 pages included",
},
description:
"Open source NotebookLM alternative with free access to ChatGPT, Claude AI, and any model. Connect Slack, Google Drive, Notion, Confluence, GitHub, and dozens more data sources.",
url: "https://surfsense.com",
downloadUrl: "https://github.com/MODSetter/SurfSense/releases",
featureList: [
"Free access to ChatGPT, Claude AI, and any AI model",
"AI-powered semantic search across all connected tools",
"Federated search across Slack, Google Drive, Notion, Confluence, GitHub",
"No data limits with open source self-hosting",
"Real-time collaborative team chats",
"Document Q&A with citations",
"Report generation",
"Podcast and video generation from sources",
"Enterprise knowledge management",
"Self-hostable and privacy-focused",
],
}}
/>
);
}
export function ArticleJsonLd({
title,
description,
url,
datePublished,
author,
image,
}: {
title: string;
description: string;
url: string;
datePublished: string;
author: string;
image?: string;
}) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Article",
headline: title,
description,
url,
datePublished,
author: {
"@type": "Organization",
name: author,
},
publisher: {
"@type": "Organization",
name: "SurfSense",
logo: {
"@type": "ImageObject",
url: "https://surfsense.com/logo.png",
},
},
image: image || "https://surfsense.com/og-image.png",
mainEntityOfPage: {
"@type": "WebPage",
"@id": url,
},
}}
/>
);
}
export function BreadcrumbJsonLd({
items,
}: {
items: { name: string; url: string }[];
}) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: item.url,
})),
}}
/>
);
}
export function FAQJsonLd({
questions,
}: {
questions: { question: string; answer: string }[];
}) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: questions.map((q) => ({
"@type": "Question",
name: q.question,
acceptedAnswer: {
"@type": "Answer",
text: q.answer,
},
})),
}}
/>
);
}

View file

@ -97,6 +97,7 @@
"fumadocs-core": "^16.3.1",
"fumadocs-mdx": "^14.2.1",
"fumadocs-ui": "^16.3.1",
"fuzzy-search": "^3.2.1",
"geist": "^1.4.2",
"jotai": "^2.15.1",
"jotai-tanstack-query": "^0.11.0",

View file

@ -236,6 +236,9 @@ importers:
fumadocs-ui:
specifier: ^16.3.1
version: 16.6.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.577.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1)
fuzzy-search:
specifier: ^3.2.1
version: 3.2.1
geist:
specifier: ^1.4.2
version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
@ -5924,6 +5927,9 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fuzzy-search@3.2.1:
resolution: {integrity: sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg==}
gaxios@6.7.1:
resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
engines: {node: '>=14'}
@ -14435,6 +14441,8 @@ snapshots:
functions-have-names@1.2.3: {}
fuzzy-search@3.2.1: {}
gaxios@6.7.1:
dependencies:
extend: 3.0.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before After
Before After

View file

@ -16,6 +16,19 @@ export const changelog = defineDocs({
},
});
export const blog = defineDocs({
dir: "blog/content",
docs: {
schema: frontmatterSchema.extend({
date: z.string(),
image: z.string().optional(),
author: z.string().default("SurfSense Team"),
authorAvatar: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
},
});
export default defineConfig({
lastModifiedTime: "git",
mdxOptions: {

13
surfsense_web/types/fuzzy-search.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
declare module "fuzzy-search" {
interface FuzzySearchOptions {
caseSensitive?: boolean;
sort?: boolean;
}
class FuzzySearch<T> {
constructor(haystack: T[], keys?: string[], options?: FuzzySearchOptions);
search(needle: string): T[];
}
export default FuzzySearch;
}