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() {