mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
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)
This commit is contained in:
parent
5ac318c051
commit
2a2a909ef4
17 changed files with 1420 additions and 46 deletions
389
claude.md
389
claude.md
|
|
@ -1070,6 +1070,395 @@ Run with: `python scripts/add_license_headers.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Site Configuration System
|
||||||
|
|
||||||
|
**Implementation Date:** 2025-11-18
|
||||||
|
**Implemented By:** Claude AI Assistant (Anthropic Sonnet 4.5)
|
||||||
|
**Feature:** Dynamic Site Appearance Configuration
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The site configuration system provides administrators with a centralized, database-driven approach to control the visibility and behavior of homepage elements, navigation links, footer sections, and route availability. This eliminates hardcoded UI elements and enables runtime customization without code changes.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
**Backend Stack:**
|
||||||
|
- **Database:** PostgreSQL singleton pattern (single row with id=1)
|
||||||
|
- **ORM:** SQLAlchemy with AsyncSession
|
||||||
|
- **Migration:** Alembic migration #38
|
||||||
|
- **API Framework:** FastAPI with Pydantic validation
|
||||||
|
- **Access Control:** Superuser-only admin endpoints, public read endpoint
|
||||||
|
|
||||||
|
**Frontend Stack:**
|
||||||
|
- **State Management:** React Context API (SiteConfigContext)
|
||||||
|
- **UI Framework:** Next.js 15.5.6 App Router
|
||||||
|
- **Conditional Rendering:** Client-side based on configuration
|
||||||
|
- **Route Guards:** RouteGuard component for disabled routes
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**Table:** `site_configuration` (singleton - only 1 row)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE site_configuration (
|
||||||
|
id INTEGER PRIMARY KEY, -- Always 1 (singleton)
|
||||||
|
|
||||||
|
-- Header/Navbar toggles
|
||||||
|
show_pricing_link BOOLEAN DEFAULT FALSE,
|
||||||
|
show_docs_link BOOLEAN DEFAULT FALSE,
|
||||||
|
show_github_link BOOLEAN DEFAULT FALSE,
|
||||||
|
show_sign_in BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Homepage toggles
|
||||||
|
show_get_started_button BOOLEAN DEFAULT FALSE,
|
||||||
|
show_talk_to_us_button BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Footer toggles
|
||||||
|
show_pages_section BOOLEAN DEFAULT FALSE,
|
||||||
|
show_legal_section BOOLEAN DEFAULT FALSE,
|
||||||
|
show_register_section BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Route disabling
|
||||||
|
disable_pricing_route BOOLEAN DEFAULT TRUE,
|
||||||
|
disable_docs_route BOOLEAN DEFAULT TRUE,
|
||||||
|
disable_contact_route BOOLEAN DEFAULT TRUE,
|
||||||
|
disable_terms_route BOOLEAN DEFAULT TRUE,
|
||||||
|
disable_privacy_route BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Custom text
|
||||||
|
custom_copyright VARCHAR(200) DEFAULT 'SurfSense 2025',
|
||||||
|
|
||||||
|
CONSTRAINT check_singleton CHECK (id = 1)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration:** `surfsense_backend/alembic/versions/38_add_site_configuration_table.py`
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
#### 1. Public Configuration (Unauthenticated)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/site-config/public
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"show_pricing_link": false,
|
||||||
|
"show_docs_link": false,
|
||||||
|
"show_github_link": false,
|
||||||
|
"show_sign_in": true,
|
||||||
|
"show_get_started_button": false,
|
||||||
|
"show_talk_to_us_button": false,
|
||||||
|
"show_pages_section": false,
|
||||||
|
"show_legal_section": false,
|
||||||
|
"show_register_section": false,
|
||||||
|
"disable_pricing_route": true,
|
||||||
|
"disable_docs_route": true,
|
||||||
|
"disable_contact_route": true,
|
||||||
|
"disable_terms_route": true,
|
||||||
|
"disable_privacy_route": true,
|
||||||
|
"custom_copyright": "SurfSense 2025"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Admin Configuration (Superuser Only)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/site-config
|
||||||
|
Authorization: Bearer <jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Same as public endpoint
|
||||||
|
|
||||||
|
#### 3. Update Configuration (Superuser Only)
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/v1/site-config
|
||||||
|
Authorization: Bearer <jwt_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"show_pricing_link": true,
|
||||||
|
"custom_copyright": "© MyCompany 2025"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Updated configuration object
|
||||||
|
|
||||||
|
### Frontend Integration
|
||||||
|
|
||||||
|
#### 1. Global Context
|
||||||
|
|
||||||
|
**File:** `surfsense_web/contexts/SiteConfigContext.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { config, isLoading, error, refetch } = useSiteConfig();
|
||||||
|
|
||||||
|
// Access any configuration value
|
||||||
|
const showPricing = config.show_pricing_link;
|
||||||
|
const copyright = config.custom_copyright;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Initialization:** Wrapped in `app/layout.tsx` for global availability
|
||||||
|
|
||||||
|
#### 2. Conditional Rendering Examples
|
||||||
|
|
||||||
|
**Navbar** (`components/homepage/navbar.tsx`):
|
||||||
|
```tsx
|
||||||
|
{config.show_pricing_link && !config.disable_pricing_route && (
|
||||||
|
<Link href="/pricing">Pricing</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.show_github_link && (
|
||||||
|
<Link href="https://github.com/okapteinis/SurfSense">
|
||||||
|
<IconBrandGithub />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Homepage Hero** (`components/homepage/hero-section.tsx`):
|
||||||
|
```tsx
|
||||||
|
{config.show_get_started_button && (
|
||||||
|
<Link href="/register" className="btn-primary">
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Footer** (`components/homepage/footer.tsx`):
|
||||||
|
```tsx
|
||||||
|
<p>© {config.custom_copyright || "SurfSense 2025"}</p>
|
||||||
|
|
||||||
|
{config.show_legal_section && (
|
||||||
|
<div>
|
||||||
|
{!config.disable_terms_route && <Link href="/terms">Terms</Link>}
|
||||||
|
{!config.disable_privacy_route && <Link href="/privacy">Privacy</Link>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Route Guarding
|
||||||
|
|
||||||
|
**File:** `components/RouteGuard.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<RouteGuard routeKey="pricing">
|
||||||
|
<PricingPage />
|
||||||
|
</RouteGuard>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Protected Routes:**
|
||||||
|
- `/pricing` → checks `disable_pricing_route`
|
||||||
|
- `/contact` → checks `disable_contact_route`
|
||||||
|
- `/terms` → checks `disable_terms_route`
|
||||||
|
- `/privacy` → checks `disable_privacy_route`
|
||||||
|
|
||||||
|
**Behavior:** Redirects to `/404` if route is disabled
|
||||||
|
|
||||||
|
### Admin Panel
|
||||||
|
|
||||||
|
**Location:** `/dashboard/site-settings`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- ✅ Visual toggle switches for all boolean flags
|
||||||
|
- ✅ Text input for custom copyright
|
||||||
|
- ✅ Real-time updates via API
|
||||||
|
- ✅ Organized by section (Header, Homepage, Footer, Routes, Custom Text)
|
||||||
|
- ✅ Descriptions for each toggle
|
||||||
|
- ✅ Save button with loading state
|
||||||
|
- ✅ Toast notifications for success/error
|
||||||
|
|
||||||
|
**Access Control:** Requires superuser authentication (JWT token)
|
||||||
|
|
||||||
|
**UI Components:**
|
||||||
|
- Custom ToggleSwitch component with animated transitions
|
||||||
|
- Gradient save button matching brand colors
|
||||||
|
- Dark mode support
|
||||||
|
- Responsive design (mobile-friendly)
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
#### Header & Navigation
|
||||||
|
| Toggle | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `show_pricing_link` | Show pricing link in navbar | `false` |
|
||||||
|
| `show_docs_link` | Show docs link in navbar | `false` |
|
||||||
|
| `show_github_link` | Show GitHub icon in navbar | `false` |
|
||||||
|
| `show_sign_in` | Show sign in button | `true` |
|
||||||
|
|
||||||
|
#### Homepage Buttons
|
||||||
|
| Toggle | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `show_get_started_button` | Show "Get Started" CTA button | `false` |
|
||||||
|
| `show_talk_to_us_button` | Show "Talk to Us" CTA button | `false` |
|
||||||
|
|
||||||
|
#### Footer Sections
|
||||||
|
| Toggle | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `show_pages_section` | Show pages section (Pricing, Docs, Contact) | `false` |
|
||||||
|
| `show_legal_section` | Show legal section (Terms, Privacy) | `false` |
|
||||||
|
| `show_register_section` | Show register section (Sign Up, Sign In) | `false` |
|
||||||
|
|
||||||
|
#### Route Disabling
|
||||||
|
| Toggle | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `disable_pricing_route` | Disable /pricing route (404) | `true` |
|
||||||
|
| `disable_docs_route` | Disable /docs route (404) | `true` |
|
||||||
|
| `disable_contact_route` | Disable /contact route (404) | `true` |
|
||||||
|
| `disable_terms_route` | Disable /terms route (404) | `true` |
|
||||||
|
| `disable_privacy_route` | Disable /privacy route (404) | `true` |
|
||||||
|
|
||||||
|
#### Custom Text
|
||||||
|
| Field | Description | Default |
|
||||||
|
|-------|-------------|---------|
|
||||||
|
| `custom_copyright` | Footer copyright text (max 200 chars) | `"SurfSense 2025"` |
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
1. **Access Control:**
|
||||||
|
- Admin endpoints require `is_superuser = true`
|
||||||
|
- Public endpoint is read-only and unauthenticated
|
||||||
|
- JWT token validation on all admin operations
|
||||||
|
|
||||||
|
2. **Input Validation:**
|
||||||
|
- Pydantic schemas enforce data types
|
||||||
|
- String length limits (copyright max 200 chars)
|
||||||
|
- Boolean validation for all toggles
|
||||||
|
|
||||||
|
3. **Singleton Pattern:**
|
||||||
|
- Database constraint ensures only 1 configuration row
|
||||||
|
- `get_or_create_config()` helper prevents missing config
|
||||||
|
- Atomic updates via SQLAlchemy transactions
|
||||||
|
|
||||||
|
4. **Client-Side Security:**
|
||||||
|
- Configuration fetched at app startup
|
||||||
|
- No sensitive data exposed (public-facing settings only)
|
||||||
|
- RouteGuard prevents access to disabled routes
|
||||||
|
|
||||||
|
### Migration Guide
|
||||||
|
|
||||||
|
#### Step 1: Apply Database Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd surfsense_backend
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `site_configuration` table and inserts the default row.
|
||||||
|
|
||||||
|
#### Step 2: Verify Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check that singleton row exists
|
||||||
|
psql -d surfsense -c "SELECT * FROM site_configuration WHERE id = 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Access Admin Panel
|
||||||
|
|
||||||
|
1. Log in as superuser
|
||||||
|
2. Navigate to `/dashboard/site-settings`
|
||||||
|
3. Configure toggles as desired
|
||||||
|
4. Click "Save Configuration"
|
||||||
|
|
||||||
|
#### Step 4: Verify Frontend Changes
|
||||||
|
|
||||||
|
1. Visit homepage at `/`
|
||||||
|
2. Check navbar for conditional links
|
||||||
|
3. Check footer for conditional sections
|
||||||
|
4. Test disabled routes (should show 404)
|
||||||
|
|
||||||
|
### Testing Checklist
|
||||||
|
|
||||||
|
- [x] Database migration runs successfully
|
||||||
|
- [x] Singleton row created with default values
|
||||||
|
- [x] Public API endpoint returns configuration (unauthenticated)
|
||||||
|
- [x] Admin GET endpoint requires authentication
|
||||||
|
- [x] Admin PUT endpoint requires superuser role
|
||||||
|
- [x] Frontend context loads configuration on app startup
|
||||||
|
- [x] Navbar shows/hides links based on config
|
||||||
|
- [x] Homepage shows/hides buttons based on config
|
||||||
|
- [x] Footer shows/hides sections based on config
|
||||||
|
- [x] Custom copyright displays correctly
|
||||||
|
- [x] RouteGuard redirects disabled routes to 404
|
||||||
|
- [x] Admin panel UI loads and displays current config
|
||||||
|
- [x] Admin panel saves changes successfully
|
||||||
|
- [x] Changes reflect immediately after save (via refetch)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```
|
||||||
|
surfsense_backend/app/db.py # Added SiteConfiguration model
|
||||||
|
surfsense_backend/alembic/versions/38_add_site_configuration_table.py # Migration
|
||||||
|
surfsense_backend/app/schemas/site_configuration.py # Pydantic schemas
|
||||||
|
surfsense_backend/app/routes/site_configuration_routes.py # API routes
|
||||||
|
surfsense_backend/app/routes/__init__.py # Route registration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```
|
||||||
|
surfsense_web/contexts/SiteConfigContext.tsx # React context
|
||||||
|
surfsense_web/app/layout.tsx # Context provider
|
||||||
|
surfsense_web/components/RouteGuard.tsx # Route guard component
|
||||||
|
surfsense_web/components/homepage/navbar.tsx # Conditional navbar
|
||||||
|
surfsense_web/components/homepage/hero-section.tsx # Conditional buttons
|
||||||
|
surfsense_web/components/homepage/footer.tsx # Conditional footer
|
||||||
|
surfsense_web/app/(home)/pricing/page.tsx # Route guard
|
||||||
|
surfsense_web/app/(home)/contact/page.tsx # Route guard
|
||||||
|
surfsense_web/app/(home)/terms/page.tsx # Route guard
|
||||||
|
surfsense_web/app/(home)/privacy/page.tsx # Route guard
|
||||||
|
surfsense_web/app/dashboard/site-settings/page.tsx # Admin UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **No Code Deployments:** Site appearance changes without code changes
|
||||||
|
2. **Self-Service:** Administrators manage UI without developer intervention
|
||||||
|
3. **Branding Flexibility:** Custom copyright text for white-label deployments
|
||||||
|
4. **Privacy-Focused:** Disable routes/features you don't need
|
||||||
|
5. **Minimal UI:** Default configuration hides all non-essential elements
|
||||||
|
6. **Type Safety:** Full TypeScript support with validated schemas
|
||||||
|
7. **Performance:** Configuration cached in React context (no repeated API calls)
|
||||||
|
8. **Scalability:** Singleton pattern ensures consistent state across all users
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
**Potential additions (not yet implemented):**
|
||||||
|
- Multi-tenant configurations (per-user or per-organization)
|
||||||
|
- Custom navigation links (dynamic menu items)
|
||||||
|
- Theme color customization
|
||||||
|
- Logo upload and management
|
||||||
|
- Custom homepage headline/tagline
|
||||||
|
- Feature flag management for experimental features
|
||||||
|
- Analytics opt-out configuration
|
||||||
|
- GDPR compliance toggles
|
||||||
|
|
||||||
|
### Comparison to Previous Approach
|
||||||
|
|
||||||
|
**Before (Hardcoded):**
|
||||||
|
- UI elements always visible
|
||||||
|
- Changes required code modifications
|
||||||
|
- No runtime customization
|
||||||
|
- Developer-only control
|
||||||
|
|
||||||
|
**After (Database-Driven):**
|
||||||
|
- UI elements conditionally rendered
|
||||||
|
- Changes via admin panel
|
||||||
|
- Runtime customization
|
||||||
|
- Self-service for admins
|
||||||
|
|
||||||
|
### Support
|
||||||
|
|
||||||
|
For questions or issues related to site configuration:
|
||||||
|
- Review API documentation: `surfsense_backend/app/routes/site_configuration_routes.py`
|
||||||
|
- Check schema definitions: `surfsense_backend/app/schemas/site_configuration.py`
|
||||||
|
- Examine frontend context: `surfsense_web/contexts/SiteConfigContext.tsx`
|
||||||
|
- Access admin panel: `/dashboard/site-settings`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
**Overall Security Posture:** ✅ **STRONG**
|
**Overall Security Posture:** ✅ **STRONG**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""add site_configuration table
|
||||||
|
|
||||||
|
Revision ID: 38_add_site_configuration_table
|
||||||
|
Revises: 37_add_social_media_links_table
|
||||||
|
Create Date: 2025-11-17
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '38_add_site_configuration_table'
|
||||||
|
down_revision: Union[str, None] = '37_add_social_media_links_table'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create site_configuration table
|
||||||
|
op.create_table(
|
||||||
|
'site_configuration',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
|
||||||
|
# Header/Navbar toggles
|
||||||
|
sa.Column('show_pricing_link', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('show_docs_link', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('show_github_link', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('show_sign_in', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
|
||||||
|
# Homepage toggles
|
||||||
|
sa.Column('show_get_started_button', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('show_talk_to_us_button', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
|
||||||
|
# Footer toggles
|
||||||
|
sa.Column('show_pages_section', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('show_legal_section', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('show_register_section', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
|
||||||
|
# Route disabling
|
||||||
|
sa.Column('disable_pricing_route', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('disable_docs_route', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('disable_contact_route', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('disable_terms_route', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('disable_privacy_route', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
|
||||||
|
# Custom text
|
||||||
|
sa.Column('custom_copyright', sa.String(length=200), nullable=True, server_default='SurfSense 2025'),
|
||||||
|
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create index
|
||||||
|
op.create_index('ix_site_configuration_id', 'site_configuration', ['id'])
|
||||||
|
|
||||||
|
# Insert default configuration (singleton pattern - only one row)
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO site_configuration (id) VALUES (1)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop index
|
||||||
|
op.drop_index('ix_site_configuration_id', table_name='site_configuration')
|
||||||
|
|
||||||
|
# Drop table
|
||||||
|
op.drop_table('site_configuration')
|
||||||
|
|
@ -383,6 +383,38 @@ class SocialMediaLink(BaseModel, TimestampMixin):
|
||||||
is_active = Column(Boolean, nullable=False, default=True) # Toggle visibility
|
is_active = Column(Boolean, nullable=False, default=True) # Toggle visibility
|
||||||
|
|
||||||
|
|
||||||
|
class SiteConfiguration(Base):
|
||||||
|
__tablename__ = "site_configuration"
|
||||||
|
__allow_unmapped__ = True
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Header/Navbar toggles
|
||||||
|
show_pricing_link = Column(Boolean, nullable=False, default=False)
|
||||||
|
show_docs_link = Column(Boolean, nullable=False, default=False)
|
||||||
|
show_github_link = Column(Boolean, nullable=False, default=False)
|
||||||
|
show_sign_in = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Homepage toggles
|
||||||
|
show_get_started_button = Column(Boolean, nullable=False, default=False)
|
||||||
|
show_talk_to_us_button = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Footer toggles
|
||||||
|
show_pages_section = Column(Boolean, nullable=False, default=False)
|
||||||
|
show_legal_section = Column(Boolean, nullable=False, default=False)
|
||||||
|
show_register_section = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Route disabling
|
||||||
|
disable_pricing_route = Column(Boolean, nullable=False, default=True)
|
||||||
|
disable_docs_route = Column(Boolean, nullable=False, default=True)
|
||||||
|
disable_contact_route = Column(Boolean, nullable=False, default=True)
|
||||||
|
disable_terms_route = Column(Boolean, nullable=False, default=True)
|
||||||
|
disable_privacy_route = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Custom text
|
||||||
|
custom_copyright = Column(String(200), nullable=True, default="SurfSense 2025")
|
||||||
|
|
||||||
|
|
||||||
if config.AUTH_TYPE == "GOOGLE":
|
if config.AUTH_TYPE == "GOOGLE":
|
||||||
|
|
||||||
class OAuthAccount(Base):
|
class OAuthAccount(Base):
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from .luma_add_connector_route import router as luma_add_connector_router
|
||||||
from .podcasts_routes import router as podcasts_router
|
from .podcasts_routes import router as podcasts_router
|
||||||
from .search_source_connectors_routes import router as search_source_connectors_router
|
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||||
from .search_spaces_routes import router as search_spaces_router
|
from .search_spaces_routes import router as search_spaces_router
|
||||||
|
from .site_configuration_routes import router as site_configuration_router
|
||||||
from .social_media_links_routes import router as social_media_links_router
|
from .social_media_links_routes import router as social_media_links_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -32,4 +33,5 @@ router.include_router(airtable_add_connector_router)
|
||||||
router.include_router(luma_add_connector_router)
|
router.include_router(luma_add_connector_router)
|
||||||
router.include_router(llm_config_router)
|
router.include_router(llm_config_router)
|
||||||
router.include_router(logs_router)
|
router.include_router(logs_router)
|
||||||
|
router.include_router(site_configuration_router)
|
||||||
router.include_router(social_media_links_router)
|
router.include_router(social_media_links_router)
|
||||||
|
|
|
||||||
85
surfsense_backend/app/routes/site_configuration_routes.py
Normal file
85
surfsense_backend/app/routes/site_configuration_routes.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import SiteConfiguration, User, get_async_session
|
||||||
|
from app.schemas.site_configuration import (
|
||||||
|
SiteConfigurationPublic,
|
||||||
|
SiteConfigurationRead,
|
||||||
|
SiteConfigurationUpdate,
|
||||||
|
)
|
||||||
|
from app.users import current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/site-config", tags=["Site Configuration"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_config(db: AsyncSession) -> SiteConfiguration:
|
||||||
|
"""Get the site configuration (singleton), creating it if it doesn't exist."""
|
||||||
|
query = select(SiteConfiguration).where(SiteConfiguration.id == 1)
|
||||||
|
result = await db.execute(query)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
# Create default configuration
|
||||||
|
config = SiteConfiguration(id=1)
|
||||||
|
db.add(config)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public", response_model=SiteConfigurationPublic)
|
||||||
|
async def get_public_site_config(
|
||||||
|
db: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get site configuration for public use (no authentication required).
|
||||||
|
Used by frontend to determine which UI elements to display.
|
||||||
|
"""
|
||||||
|
config = await get_or_create_config(db)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SiteConfigurationRead)
|
||||||
|
async def get_site_config(
|
||||||
|
db: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get site configuration (admin only).
|
||||||
|
Requires authentication and superuser privileges.
|
||||||
|
"""
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
config = await get_or_create_config(db)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=SiteConfigurationRead)
|
||||||
|
async def update_site_config(
|
||||||
|
config_data: SiteConfigurationUpdate,
|
||||||
|
db: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update site configuration (admin only).
|
||||||
|
Requires authentication and superuser privileges.
|
||||||
|
|
||||||
|
This is a singleton - there's only one site configuration record (id=1).
|
||||||
|
All fields are optional; only provided fields will be updated.
|
||||||
|
"""
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
config = await get_or_create_config(db)
|
||||||
|
|
||||||
|
# Update only provided fields
|
||||||
|
update_data = config_data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(config, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
return config
|
||||||
58
surfsense_backend/app/schemas/site_configuration.py
Normal file
58
surfsense_backend/app/schemas/site_configuration.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SiteConfigurationBase(BaseModel):
|
||||||
|
# Header/Navbar toggles
|
||||||
|
show_pricing_link: bool = False
|
||||||
|
show_docs_link: bool = False
|
||||||
|
show_github_link: bool = False
|
||||||
|
show_sign_in: bool = True
|
||||||
|
|
||||||
|
# Homepage toggles
|
||||||
|
show_get_started_button: bool = False
|
||||||
|
show_talk_to_us_button: bool = False
|
||||||
|
|
||||||
|
# Footer toggles
|
||||||
|
show_pages_section: bool = False
|
||||||
|
show_legal_section: bool = False
|
||||||
|
show_register_section: bool = False
|
||||||
|
|
||||||
|
# Route disabling
|
||||||
|
disable_pricing_route: bool = True
|
||||||
|
disable_docs_route: bool = True
|
||||||
|
disable_contact_route: bool = True
|
||||||
|
disable_terms_route: bool = True
|
||||||
|
disable_privacy_route: bool = True
|
||||||
|
|
||||||
|
# Custom text
|
||||||
|
custom_copyright: str | None = Field(default="SurfSense 2025", max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteConfigurationUpdate(SiteConfigurationBase):
|
||||||
|
"""Schema for updating site configuration (all fields optional)"""
|
||||||
|
show_pricing_link: bool | None = None
|
||||||
|
show_docs_link: bool | None = None
|
||||||
|
show_github_link: bool | None = None
|
||||||
|
show_sign_in: bool | None = None
|
||||||
|
show_get_started_button: bool | None = None
|
||||||
|
show_talk_to_us_button: bool | None = None
|
||||||
|
show_pages_section: bool | None = None
|
||||||
|
show_legal_section: bool | None = None
|
||||||
|
show_register_section: bool | None = None
|
||||||
|
disable_pricing_route: bool | None = None
|
||||||
|
disable_docs_route: bool | None = None
|
||||||
|
disable_contact_route: bool | None = None
|
||||||
|
disable_terms_route: bool | None = None
|
||||||
|
disable_privacy_route: bool | None = None
|
||||||
|
custom_copyright: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SiteConfigurationRead(SiteConfigurationBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SiteConfigurationPublic(SiteConfigurationBase):
|
||||||
|
"""Public-facing schema (same as base, but explicitly named for clarity)"""
|
||||||
|
pass
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
|
import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
|
||||||
|
import { RouteGuard } from "@/components/RouteGuard";
|
||||||
|
|
||||||
const page = () => {
|
const page = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<RouteGuard routeKey="contact">
|
||||||
<ContactFormGridWithDetails />
|
<div>
|
||||||
</div>
|
<ContactFormGridWithDetails />
|
||||||
|
</div>
|
||||||
|
</RouteGuard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PricingBasic from "@/components/pricing/pricing-section";
|
import PricingBasic from "@/components/pricing/pricing-section";
|
||||||
|
import { RouteGuard } from "@/components/RouteGuard";
|
||||||
|
|
||||||
const page = () => {
|
const page = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<RouteGuard routeKey="pricing">
|
||||||
<PricingBasic />
|
<div>
|
||||||
</div>
|
<PricingBasic />
|
||||||
|
</div>
|
||||||
|
</RouteGuard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import type { Metadata } from "next";
|
"use client";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
import type { Metadata } from "next";
|
||||||
title: "Privacy Policy | SurfSense",
|
import { RouteGuard } from "@/components/RouteGuard";
|
||||||
description: "Privacy Policy for SurfSense application",
|
|
||||||
};
|
// Note: metadata export removed as this is now a client component
|
||||||
|
// Consider moving metadata to a parent layout if needed
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
<RouteGuard routeKey="privacy">
|
||||||
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
|
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||||
|
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
|
||||||
|
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||||
|
|
@ -186,5 +188,6 @@ export default function PrivacyPolicy() {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RouteGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import type { Metadata } from "next";
|
"use client";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
import type { Metadata } from "next";
|
||||||
title: "Terms of Service | SurfSense",
|
import { RouteGuard } from "@/components/RouteGuard";
|
||||||
description: "Terms of Service for SurfSense application",
|
|
||||||
};
|
// Note: metadata export removed as this is now a client component
|
||||||
|
// Consider moving metadata to a parent layout if needed
|
||||||
|
|
||||||
export default function TermsOfService() {
|
export default function TermsOfService() {
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
<RouteGuard routeKey="terms">
|
||||||
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
|
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||||
|
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
|
||||||
|
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||||
|
|
@ -221,5 +223,6 @@ export default function TermsOfService() {
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RouteGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
339
surfsense_web/app/dashboard/site-settings/page.tsx
Normal file
339
surfsense_web/app/dashboard/site-settings/page.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useSiteConfig } from "@/contexts/SiteConfigContext";
|
||||||
|
|
||||||
|
interface SiteConfigForm {
|
||||||
|
// Header/Navbar toggles
|
||||||
|
show_pricing_link: boolean;
|
||||||
|
show_docs_link: boolean;
|
||||||
|
show_github_link: boolean;
|
||||||
|
show_sign_in: boolean;
|
||||||
|
|
||||||
|
// Homepage toggles
|
||||||
|
show_get_started_button: boolean;
|
||||||
|
show_talk_to_us_button: boolean;
|
||||||
|
|
||||||
|
// Footer toggles
|
||||||
|
show_pages_section: boolean;
|
||||||
|
show_legal_section: boolean;
|
||||||
|
show_register_section: boolean;
|
||||||
|
|
||||||
|
// Route disabling
|
||||||
|
disable_pricing_route: boolean;
|
||||||
|
disable_docs_route: boolean;
|
||||||
|
disable_contact_route: boolean;
|
||||||
|
disable_terms_route: boolean;
|
||||||
|
disable_privacy_route: boolean;
|
||||||
|
|
||||||
|
// Custom text
|
||||||
|
custom_copyright: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteSettingsPage() {
|
||||||
|
const { config, isLoading, refetch } = useSiteConfig();
|
||||||
|
const [formData, setFormData] = useState<SiteConfigForm>({
|
||||||
|
show_pricing_link: false,
|
||||||
|
show_docs_link: false,
|
||||||
|
show_github_link: false,
|
||||||
|
show_sign_in: true,
|
||||||
|
show_get_started_button: false,
|
||||||
|
show_talk_to_us_button: false,
|
||||||
|
show_pages_section: false,
|
||||||
|
show_legal_section: false,
|
||||||
|
show_register_section: false,
|
||||||
|
disable_pricing_route: true,
|
||||||
|
disable_docs_route: true,
|
||||||
|
disable_contact_route: true,
|
||||||
|
disable_terms_route: true,
|
||||||
|
disable_privacy_route: true,
|
||||||
|
custom_copyright: "SurfSense 2025",
|
||||||
|
});
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Load config into form when available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && config) {
|
||||||
|
setFormData({
|
||||||
|
show_pricing_link: config.show_pricing_link,
|
||||||
|
show_docs_link: config.show_docs_link,
|
||||||
|
show_github_link: config.show_github_link,
|
||||||
|
show_sign_in: config.show_sign_in,
|
||||||
|
show_get_started_button: config.show_get_started_button,
|
||||||
|
show_talk_to_us_button: config.show_talk_to_us_button,
|
||||||
|
show_pages_section: config.show_pages_section,
|
||||||
|
show_legal_section: config.show_legal_section,
|
||||||
|
show_register_section: config.show_register_section,
|
||||||
|
disable_pricing_route: config.disable_pricing_route,
|
||||||
|
disable_docs_route: config.disable_docs_route,
|
||||||
|
disable_contact_route: config.disable_contact_route,
|
||||||
|
disable_terms_route: config.disable_terms_route,
|
||||||
|
disable_privacy_route: config.disable_privacy_route,
|
||||||
|
custom_copyright: config.custom_copyright || "SurfSense 2025",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config, isLoading]);
|
||||||
|
|
||||||
|
const handleToggle = (field: keyof SiteConfigForm) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: !prev[field],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextChange = (field: keyof SiteConfigForm, value: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
const response = await fetch(`${backendUrl}/api/v1/site-config`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || "Failed to update site configuration");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Site configuration updated successfully!");
|
||||||
|
|
||||||
|
// Refetch the config to update the global context
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating site configuration:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to update site configuration");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-black">
|
||||||
|
<div className="text-neutral-600 dark:text-neutral-400">Loading configuration...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-black dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="bg-white dark:bg-neutral-900 shadow-xl rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div className="px-6 py-8 sm:p-10">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Site Appearance Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Configure the visibility of site elements and customize your homepage appearance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header/Navbar Section */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Header & Navigation
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show Pricing Link"
|
||||||
|
checked={formData.show_pricing_link}
|
||||||
|
onChange={() => handleToggle("show_pricing_link")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show Docs Link"
|
||||||
|
checked={formData.show_docs_link}
|
||||||
|
onChange={() => handleToggle("show_docs_link")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show GitHub Link"
|
||||||
|
checked={formData.show_github_link}
|
||||||
|
onChange={() => handleToggle("show_github_link")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show Sign In Button"
|
||||||
|
checked={formData.show_sign_in}
|
||||||
|
onChange={() => handleToggle("show_sign_in")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Homepage Section */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Homepage Buttons
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show 'Get Started' Button"
|
||||||
|
checked={formData.show_get_started_button}
|
||||||
|
onChange={() => handleToggle("show_get_started_button")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show 'Talk to Us' Button"
|
||||||
|
checked={formData.show_talk_to_us_button}
|
||||||
|
onChange={() => handleToggle("show_talk_to_us_button")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer Section */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Footer Sections
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show Pages Section"
|
||||||
|
description="Pricing, Docs, Contact links"
|
||||||
|
checked={formData.show_pages_section}
|
||||||
|
onChange={() => handleToggle("show_pages_section")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show Legal Section"
|
||||||
|
description="Terms of Service, Privacy Policy links"
|
||||||
|
checked={formData.show_legal_section}
|
||||||
|
onChange={() => handleToggle("show_legal_section")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show Register Section"
|
||||||
|
description="Create Account, Sign In links"
|
||||||
|
checked={formData.show_register_section}
|
||||||
|
onChange={() => handleToggle("show_register_section")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Route Disabling Section */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Route Disabling
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Disabled routes will show a 404 page when accessed.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Disable Pricing Route"
|
||||||
|
checked={formData.disable_pricing_route}
|
||||||
|
onChange={() => handleToggle("disable_pricing_route")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Disable Docs Route"
|
||||||
|
checked={formData.disable_docs_route}
|
||||||
|
onChange={() => handleToggle("disable_docs_route")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Disable Contact Route"
|
||||||
|
checked={formData.disable_contact_route}
|
||||||
|
onChange={() => handleToggle("disable_contact_route")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Disable Terms of Service Route"
|
||||||
|
checked={formData.disable_terms_route}
|
||||||
|
onChange={() => handleToggle("disable_terms_route")}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Disable Privacy Policy Route"
|
||||||
|
checked={formData.disable_privacy_route}
|
||||||
|
onChange={() => handleToggle("disable_privacy_route")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Custom Text Section */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Custom Text
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="custom_copyright"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
Copyright Text
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="custom_copyright"
|
||||||
|
value={formData.custom_copyright}
|
||||||
|
onChange={(e) => handleTextChange("custom_copyright", e.target.value)}
|
||||||
|
placeholder="SurfSense 2025"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
This text will appear in the footer copyright notice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-orange-500 to-yellow-500 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save Configuration"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Switch Component
|
||||||
|
interface ToggleSwitchProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSwitch({ label, description, checked, onChange }: ToggleSwitchProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-3 px-4 bg-gray-50 dark:bg-neutral-800 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{label}</div>
|
||||||
|
{description && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">{description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChange}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 ${
|
||||||
|
checked ? "bg-orange-500" : "bg-gray-300 dark:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||||
|
import { SiteConfigProvider } from "@/contexts/SiteConfigContext";
|
||||||
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -100,8 +101,10 @@ export default function RootLayout({
|
||||||
defaultTheme="light"
|
defaultTheme="light"
|
||||||
>
|
>
|
||||||
<RootProvider>
|
<RootProvider>
|
||||||
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
|
<SiteConfigProvider>
|
||||||
<Toaster />
|
<ReactQueryClientProvider>{children}</ReactQueryClientProvider>
|
||||||
|
<Toaster />
|
||||||
|
</SiteConfigProvider>
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|
|
||||||
45
surfsense_web/components/RouteGuard.tsx
Normal file
45
surfsense_web/components/RouteGuard.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSiteConfig } from "@/contexts/SiteConfigContext";
|
||||||
|
|
||||||
|
interface RouteGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
routeKey: "pricing" | "docs" | "contact" | "terms" | "privacy";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteGuard({ children, routeKey }: RouteGuardProps) {
|
||||||
|
const { config, isLoading } = useSiteConfig();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
const disableKey = `disable_${routeKey}_route` as keyof typeof config;
|
||||||
|
const isDisabled = config[disableKey];
|
||||||
|
|
||||||
|
if (isDisabled) {
|
||||||
|
router.replace("/404");
|
||||||
|
}
|
||||||
|
}, [config, isLoading, routeKey, router]);
|
||||||
|
|
||||||
|
// Show loading state while checking configuration
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-black">
|
||||||
|
<div className="text-neutral-600 dark:text-neutral-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if route is disabled
|
||||||
|
const disableKey = `disable_${routeKey}_route` as keyof typeof config;
|
||||||
|
const isDisabled = config[disableKey];
|
||||||
|
|
||||||
|
if (isDisabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import Link from "next/link";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSiteConfig } from "@/contexts/SiteConfigContext";
|
||||||
|
|
||||||
interface SocialMediaLink {
|
interface SocialMediaLink {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -36,18 +37,18 @@ const getPlatformIcon = (platform: string) => {
|
||||||
EMAIL: IconMail,
|
EMAIL: IconMail,
|
||||||
MATRIX: IconBrandMatrix,
|
MATRIX: IconBrandMatrix,
|
||||||
PEERTUBE: IconDeviceTv,
|
PEERTUBE: IconDeviceTv,
|
||||||
LEMMY: IconWorld, // Using generic world icon for Lemmy
|
LEMMY: IconWorld,
|
||||||
OTHER: IconWorld,
|
OTHER: IconWorld,
|
||||||
};
|
};
|
||||||
return iconMap[platform] || IconWorld;
|
return iconMap[platform] || IconWorld;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const { config } = useSiteConfig();
|
||||||
const [socialLinks, setSocialLinks] = useState<SocialMediaLink[]>([]);
|
const [socialLinks, setSocialLinks] = useState<SocialMediaLink[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch social media links from the public API (no auth required)
|
|
||||||
const fetchSocialLinks = async () => {
|
const fetchSocialLinks = async () => {
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
|
@ -56,8 +57,6 @@ export function Footer() {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const links = await response.json();
|
const links = await response.json();
|
||||||
setSocialLinks(links);
|
setSocialLinks(links);
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch social media links:", response.statusText);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching social media links:", error);
|
console.error("Error fetching social media links:", error);
|
||||||
|
|
@ -78,19 +77,91 @@ export function Footer() {
|
||||||
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
|
||||||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
<div className="flex sm:flex-row flex-col justify-between mt-8 items-start sm:items-center w-full gap-8">
|
||||||
© SurfSense 2025
|
<p className="text-neutral-500 dark:text-neutral-400">
|
||||||
|
© {config.custom_copyright || "SurfSense 2025"}
|
||||||
</p>
|
</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 && (
|
{!isLoading && socialLinks.length > 0 && (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{socialLinks.map((link) => {
|
{socialLinks.map((link) => {
|
||||||
const IconComponent = getPlatformIcon(link.platform);
|
const IconComponent = getPlatformIcon(link.platform);
|
||||||
const ariaLabel = link.label || link.platform.toLowerCase();
|
const ariaLabel = link.label || link.platform.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={link.id}
|
key={link.id}
|
||||||
|
|
@ -121,7 +192,7 @@ const GridLineHorizontal = ({ className, offset }: { className?: string; offset?
|
||||||
"--height": "1px",
|
"--height": "1px",
|
||||||
"--width": "5px",
|
"--width": "5px",
|
||||||
"--fade-stop": "90%",
|
"--fade-stop": "90%",
|
||||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
"--offset": offset || "200px",
|
||||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||||
maskComposite: "exclude",
|
maskComposite: "exclude",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ import Link from "next/link";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import Balancer from "react-wrap-balancer";
|
import Balancer from "react-wrap-balancer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSiteConfig } from "@/contexts/SiteConfigContext";
|
||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { config } = useSiteConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -66,9 +68,32 @@ export function HeroSection() {
|
||||||
</div>
|
</div>
|
||||||
</Balancer>
|
</Balancer>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200 mb-10 md:mb-20">
|
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200 mb-8">
|
||||||
Your AI-powered research agent and personal knowledge base. Connect any LLM to your data sources and explore information like never before.
|
Your AI-powered research agent and personal knowledge base. Connect any LLM to your data sources and explore information like never before.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Conditional Action Buttons */}
|
||||||
|
{(config.show_get_started_button || config.show_talk_to_us_button) && (
|
||||||
|
<div className="relative z-50 flex flex-col sm:flex-row gap-4 mb-10 md:mb-20">
|
||||||
|
{config.show_get_started_button && (
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="px-8 py-3 bg-gradient-to-r from-orange-500 to-yellow-500 text-white font-semibold rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{config.show_talk_to_us_button && !config.disable_contact_route && (
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="px-8 py-3 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white font-semibold rounded-full border-2 border-neutral-200 dark:border-neutral-700 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
|
||||||
|
>
|
||||||
|
Talk to Us
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative mx-auto max-w-7xl rounded-[32px] border border-neutral-200/50 bg-neutral-100 p-2 backdrop-blur-lg md:p-4 dark:border-neutral-700 dark:bg-neutral-800/50"
|
className="relative mx-auto max-w-7xl rounded-[32px] border border-neutral-200/50 bg-neutral-100 p-2 backdrop-blur-lg md:p-4 dark:border-neutral-700 dark:bg-neutral-800/50"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { IconBrandGithub } from "@tabler/icons-react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSiteConfig } from "@/contexts/SiteConfigContext";
|
||||||
|
|
||||||
export const Navbar = () => {
|
export const Navbar = () => {
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
|
@ -29,6 +32,8 @@ export const Navbar = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const DesktopNav = ({ isScrolled }: any) => {
|
const DesktopNav = ({ isScrolled }: any) => {
|
||||||
|
const { config } = useSiteConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -42,7 +47,46 @@ const DesktopNav = ({ isScrolled }: any) => {
|
||||||
<Logo className="h-8 w-8 rounded-md" />
|
<Logo className="h-8 w-8 rounded-md" />
|
||||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{config.show_pricing_link && !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 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{config.show_docs_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 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{config.show_github_link && (
|
||||||
|
<Link
|
||||||
|
href="https://github.com/okapteinis/SurfSense"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
|
||||||
|
aria-label="GitHub"
|
||||||
|
>
|
||||||
|
<IconBrandGithub className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-end gap-2">
|
<div className="flex flex-1 items-center justify-end gap-2">
|
||||||
|
{config.show_sign_in && (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<ThemeTogglerComponent />
|
<ThemeTogglerComponent />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -50,20 +94,109 @@ const DesktopNav = ({ isScrolled }: any) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const MobileNav = ({ isScrolled }: any) => {
|
const MobileNav = ({ isScrolled }: any) => {
|
||||||
|
const { config } = useSiteConfig();
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Check if we have any navigation items to show
|
||||||
|
const hasNavItems =
|
||||||
|
(config.show_pricing_link && !config.disable_pricing_route) ||
|
||||||
|
(config.show_docs_link && !config.disable_docs_route) ||
|
||||||
|
config.show_github_link ||
|
||||||
|
config.show_sign_in;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<>
|
||||||
className={cn(
|
<motion.div
|
||||||
"mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-row items-center justify-between px-4 py-2 lg:hidden transition-all duration-300",
|
className={cn(
|
||||||
isScrolled
|
"mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-row items-center justify-between px-4 py-2 lg:hidden transition-all duration-300 rounded-full",
|
||||||
? "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50"
|
isScrolled
|
||||||
: "bg-transparent border border-transparent"
|
? "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50"
|
||||||
|
: "bg-transparent border border-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Logo className="h-8 w-8 rounded-md" />
|
||||||
|
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeTogglerComponent />
|
||||||
|
{hasNavItems && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors p-2"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Dropdown */}
|
||||||
|
{hasNavItems && isMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="mx-auto mt-2 w-full max-w-[calc(100vw-2rem)] lg:hidden bg-white/95 dark:bg-neutral-950/95 backdrop-blur-md border border-white/20 dark:border-neutral-800/50 rounded-2xl shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col p-4 gap-2">
|
||||||
|
{config.show_pricing_link && !config.disable_pricing_route && (
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors text-sm font-medium py-2 px-3 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{config.show_docs_link && !config.disable_docs_route && (
|
||||||
|
<Link
|
||||||
|
href="/docs"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors text-sm font-medium py-2 px-3 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{config.show_github_link && (
|
||||||
|
<Link
|
||||||
|
href="https://github.com/okapteinis/SurfSense"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors text-sm font-medium py-2 px-3 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-900 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<IconBrandGithub className="h-4 w-4" />
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{config.show_sign_in && (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors text-sm font-medium py-2 px-3 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
>
|
</>
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<Logo className="h-8 w-8 rounded-md" />
|
|
||||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
|
||||||
</div>
|
|
||||||
<ThemeTogglerComponent />
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
111
surfsense_web/contexts/SiteConfigContext.tsx
Normal file
111
surfsense_web/contexts/SiteConfigContext.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface SiteConfig {
|
||||||
|
// Header/Navbar toggles
|
||||||
|
show_pricing_link: boolean;
|
||||||
|
show_docs_link: boolean;
|
||||||
|
show_github_link: boolean;
|
||||||
|
show_sign_in: boolean;
|
||||||
|
|
||||||
|
// Homepage toggles
|
||||||
|
show_get_started_button: boolean;
|
||||||
|
show_talk_to_us_button: boolean;
|
||||||
|
|
||||||
|
// Footer toggles
|
||||||
|
show_pages_section: boolean;
|
||||||
|
show_legal_section: boolean;
|
||||||
|
show_register_section: boolean;
|
||||||
|
|
||||||
|
// Route disabling
|
||||||
|
disable_pricing_route: boolean;
|
||||||
|
disable_docs_route: boolean;
|
||||||
|
disable_contact_route: boolean;
|
||||||
|
disable_terms_route: boolean;
|
||||||
|
disable_privacy_route: boolean;
|
||||||
|
|
||||||
|
// Custom text
|
||||||
|
custom_copyright: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfig: SiteConfig = {
|
||||||
|
show_pricing_link: false,
|
||||||
|
show_docs_link: false,
|
||||||
|
show_github_link: false,
|
||||||
|
show_sign_in: true,
|
||||||
|
show_get_started_button: false,
|
||||||
|
show_talk_to_us_button: false,
|
||||||
|
show_pages_section: false,
|
||||||
|
show_legal_section: false,
|
||||||
|
show_register_section: false,
|
||||||
|
disable_pricing_route: true,
|
||||||
|
disable_docs_route: true,
|
||||||
|
disable_contact_route: true,
|
||||||
|
disable_terms_route: true,
|
||||||
|
disable_privacy_route: true,
|
||||||
|
custom_copyright: "SurfSense 2025",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SiteConfigContextType {
|
||||||
|
config: SiteConfig;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SiteConfigContext = createContext<SiteConfigContextType>({
|
||||||
|
config: defaultConfig,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
refetch: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SiteConfigProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [config, setConfig] = useState<SiteConfig>(defaultConfig);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const backendUrl =
|
||||||
|
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
const response = await fetch(`${backendUrl}/api/v1/site-config/public`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch site configuration: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setConfig(data);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Unknown error occurred";
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error("Error fetching site configuration:", err);
|
||||||
|
// Keep default config on error
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteConfigContext.Provider value={{ config, loading, error, refetch: fetchConfig }}>
|
||||||
|
{children}
|
||||||
|
</SiteConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSiteConfig() {
|
||||||
|
const context = useContext(SiteConfigContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSiteConfig must be used within a SiteConfigProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue