SurfSense/surfsense_web/components/homepage/footer.tsx
Claude 2a2a909ef4
Implement dynamic site appearance configuration system
This commit implements a comprehensive database-driven site configuration
system that allows administrators to control the visibility and behavior
of homepage elements, navigation links, footer sections, and route
availability without code changes.

Backend Changes:
- Added SiteConfiguration model with singleton pattern (id=1)
- Created migration 38_add_site_configuration_table.py
- Implemented site_configuration_routes.py with public + admin endpoints
- Added Pydantic schemas for validation (Base, Update, Read, Public)
- Registered routes in main app

Frontend Changes:
- Created SiteConfigContext.tsx for global state management
- Updated app/layout.tsx to wrap app in SiteConfigProvider
- Implemented RouteGuard component for disabled routes
- Updated navbar with conditional rendering (pricing, docs, github, signin)
- Updated hero-section with conditional buttons (get started, talk to us)
- Updated footer with conditional sections and custom copyright
- Applied route guards to pricing, contact, terms, privacy pages

Admin Panel:
- Created /dashboard/site-settings page with full UI
- Visual toggle switches for all configuration options
- Real-time updates via API with toast notifications
- Organized by section (Header, Homepage, Footer, Routes, Text)
- Dark mode support and responsive design

Configuration Options:
Header: show_pricing_link, show_docs_link, show_github_link, show_sign_in
Homepage: show_get_started_button, show_talk_to_us_button
Footer: show_pages_section, show_legal_section, show_register_section
Routes: disable_pricing/docs/contact/terms/privacy_route
Custom: custom_copyright text (max 200 chars)

Security:
- Superuser-only admin endpoints with JWT validation
- Public read-only endpoint for frontend consumption
- Input validation via Pydantic schemas
- Singleton pattern with database constraints
- Client-side route guards for disabled routes

Documentation:
- Added comprehensive section to claude.md
- Includes API docs, migration guide, testing checklist
- Example code snippets and configuration tables
- Security considerations and future enhancements

All configuration defaults to minimal/privacy-focused state (most
features disabled by default). Administrators can enable features
as needed via the admin panel.

Files Changed: 16 files (6 backend, 10 frontend, 1 documentation)
2025-11-18 10:58:31 +00:00

212 lines
6.6 KiB
TypeScript

"use client";
import {
IconBrandMastodon,
IconBook,
IconPhoto,
IconBrandGithub,
IconBrandGitlab,
IconBrandLinkedin,
IconWorld,
IconMail,
IconBrandMatrix,
IconDeviceTv,
} from "@tabler/icons-react";
import Link from "next/link";
import type React from "react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useSiteConfig } from "@/contexts/SiteConfigContext";
interface SocialMediaLink {
id: number;
platform: string;
url: string;
label: string | null;
}
// Map platform names to icons
const getPlatformIcon = (platform: string) => {
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
MASTODON: IconBrandMastodon,
PIXELFED: IconPhoto,
BOOKWYRM: IconBook,
GITHUB: IconBrandGithub,
GITLAB: IconBrandGitlab,
LINKEDIN: IconBrandLinkedin,
WEBSITE: IconWorld,
EMAIL: IconMail,
MATRIX: IconBrandMatrix,
PEERTUBE: IconDeviceTv,
LEMMY: IconWorld,
OTHER: IconWorld,
};
return iconMap[platform] || IconWorld;
};
export function Footer() {
const { config } = useSiteConfig();
const [socialLinks, setSocialLinks] = useState<SocialMediaLink[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchSocialLinks = async () => {
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const response = await fetch(`${backendUrl}/api/v1/social-media-links/public`);
if (response.ok) {
const links = await response.json();
setSocialLinks(links);
}
} catch (error) {
console.error("Error fetching social media links:", error);
} finally {
setIsLoading(false);
}
};
fetchSocialLinks();
}, []);
return (
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 w-full relative overflow-hidden">
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
<div className="flex flex-col items-center justify-center w-full relative">
<div className="mr-0 md:mr-4 md:flex mb-4">
<div className="flex items-center">
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
</div>
</div>
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
</div>
<div className="flex sm:flex-row flex-col justify-between mt-8 items-start sm:items-center w-full gap-8">
<p className="text-neutral-500 dark:text-neutral-400">
&copy; {config.custom_copyright || "SurfSense 2025"}
</p>
<div className="flex flex-col sm:flex-row gap-8 flex-1 justify-center">
{config.show_pages_section && (
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-neutral-700 dark:text-neutral-300 mb-2">Pages</h3>
{!config.disable_pricing_route && (
<Link
href="/pricing"
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Pricing
</Link>
)}
{!config.disable_docs_route && (
<Link
href="/docs"
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Documentation
</Link>
)}
{!config.disable_contact_route && (
<Link
href="/contact"
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Contact
</Link>
)}
</div>
)}
{config.show_legal_section && (
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-neutral-700 dark:text-neutral-300 mb-2">Legal</h3>
{!config.disable_terms_route && (
<Link
href="/terms"
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Terms of Service
</Link>
)}
{!config.disable_privacy_route && (
<Link
href="/privacy"
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Privacy Policy
</Link>
)}
</div>
)}
{config.show_register_section && (
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-neutral-700 dark:text-neutral-300 mb-2">Get Started</h3>
<Link
href="/register"
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Create Account
</Link>
<Link
href="/login"
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Sign In
</Link>
</div>
)}
</div>
{!isLoading && socialLinks.length > 0 && (
<div className="flex gap-4">
{socialLinks.map((link) => {
const IconComponent = getPlatformIcon(link.platform);
const ariaLabel = link.label || link.platform.toLowerCase();
return (
<Link
key={link.id}
href={link.url}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaLabel}
>
<IconComponent className="h-6 w-6 text-neutral-500 dark:text-neutral-300 hover:text-neutral-700 dark:hover:text-neutral-100 transition-colors" />
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "1px",
"--width": "5px",
"--fade-stop": "90%",
"--offset": offset || "200px",
"--color-dark": "rgba(255, 255, 255, 0.2)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"w-[calc(100%+var(--offset))] h-[var(--height)]",
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
);
};