mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-24 23:41:10 +02:00
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)
212 lines
6.6 KiB
TypeScript
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">
|
|
© {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>
|
|
);
|
|
};
|