mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
Implement dynamic social media link management system
This commit implements a comprehensive system for managing social media
links dynamically without hardcoding any URLs in the frontend code.
Backend Changes:
- Add SocialMediaPlatform enum with support for 12 platforms (Mastodon,
Pixelfed, Bookwyrm, Lemmy, PeerTube, GitHub, GitLab, Matrix, LinkedIn,
Website, Email, Other)
- Create SocialMediaLink database model with fields: platform, url, label,
display_order, is_active, created_at
- Add Alembic migration (37_add_social_media_links_table.py)
- Create comprehensive API endpoints:
* GET /api/v1/social-media-links/public (no auth, returns active links)
* GET /api/v1/social-media-links (admin only, returns all links)
* POST /api/v1/social-media-links (admin only, create link)
* PATCH /api/v1/social-media-links/{id} (admin only, update link)
* DELETE /api/v1/social-media-links/{id} (admin only, delete link)
- Add Pydantic schemas for validation and serialization
- Register routes in main router
Frontend Changes:
- Update footer component to fetch links from public API endpoint
- Implement icon mapping for all supported platforms
- Add proper loading states and error handling
- Remove all hardcoded social media URLs
- Icons only display when URLs are configured via admin panel
- Proper accessibility (aria-labels, target="_blank", rel="noopener noreferrer")
Authentication Cleanup:
- Remove GoogleLoginButton.tsx component (no longer used)
- Delete Google OAuth documentation images
- Login and register pages already use email/password only
Documentation:
- Add comprehensive SOCIAL_MEDIA_LINKS_ADMIN.md with:
* API endpoint documentation
* cURL examples for all operations
* Platform support matrix
* Icon mapping reference
* Security notes
* Troubleshooting guide
Features:
✅ No hardcoded social media URLs
✅ Admin can add/edit/remove links via API
✅ Links automatically show/hide based on is_active flag
✅ Customizable display order
✅ Platform-specific icons automatically selected
✅ Public endpoint for frontend (no auth required)
✅ Admin endpoints protected (superuser only)
✅ Proper validation and error handling
✅ Email/password authentication only (OAuth removed)
Migration Required:
Run `alembic upgrade head` to create the social_media_links table.
Co-authored-by: Ojārs Kapteiņš <ojars@kapteinis.lv>
Co-authored-by: Claude AI Assistant <odede@anthropic.com>
This commit is contained in:
parent
c50f2c7e3e
commit
a1699dbe8f
12 changed files with 595 additions and 128 deletions
236
SOCIAL_MEDIA_LINKS_ADMIN.md
Normal file
236
SOCIAL_MEDIA_LINKS_ADMIN.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# Social Media Links Management
|
||||
|
||||
This document explains how to manage dynamic social media links for the SurfSense homepage footer.
|
||||
|
||||
## Overview
|
||||
|
||||
Social media links are now managed dynamically through the backend API. No hardcoded links exist in the frontend code. Administrators can add, edit, or remove links, and they will automatically appear or disappear from the homepage footer.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All admin endpoints require authentication and **superuser** privileges.
|
||||
|
||||
### Base URL
|
||||
```
|
||||
{BACKEND_URL}/api/v1/social-media-links
|
||||
```
|
||||
|
||||
### 1. Get All Links (Admin)
|
||||
```bash
|
||||
GET /api/v1/social-media-links
|
||||
Authorization: Bearer {your_jwt_token}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"platform": "MASTODON",
|
||||
"url": "https://mastodon.social/@username",
|
||||
"label": "Follow us on Mastodon",
|
||||
"display_order": 0,
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-17T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Get Public Links (No Auth Required)
|
||||
```bash
|
||||
GET /api/v1/social-media-links/public
|
||||
```
|
||||
|
||||
This endpoint returns only active links, ordered by `display_order`. Used by the homepage footer.
|
||||
|
||||
### 3. Create a Link (Admin)
|
||||
```bash
|
||||
POST /api/v1/social-media-links
|
||||
Authorization: Bearer {your_jwt_token}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"platform": "MASTODON",
|
||||
"url": "https://mastodon.social/@username",
|
||||
"label": "Mastodon",
|
||||
"display_order": 0,
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Platforms:**
|
||||
- `MASTODON`
|
||||
- `PIXELFED`
|
||||
- `BOOKWYRM`
|
||||
- `LEMMY`
|
||||
- `PEERTUBE`
|
||||
- `GITHUB`
|
||||
- `GITLAB`
|
||||
- `MATRIX`
|
||||
- `LINKEDIN`
|
||||
- `WEBSITE`
|
||||
- `EMAIL`
|
||||
- `OTHER`
|
||||
|
||||
### 4. Update a Link (Admin)
|
||||
```bash
|
||||
PATCH /api/v1/social-media-links/{link_id}
|
||||
Authorization: Bearer {your_jwt_token}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "https://new-url.example.com",
|
||||
"is_active": false
|
||||
}
|
||||
```
|
||||
|
||||
You can update any field(s). Omit fields you don't want to change.
|
||||
|
||||
### 5. Delete a Link (Admin)
|
||||
```bash
|
||||
DELETE /api/v1/social-media-links/{link_id}
|
||||
Authorization: Bearer {your_jwt_token}
|
||||
```
|
||||
|
||||
## Example: Using cURL
|
||||
|
||||
### Add a Mastodon Link
|
||||
```bash
|
||||
# First, login to get JWT token
|
||||
TOKEN=$(curl -X POST "http://localhost:8000/auth/jwt/login" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=admin@example.com&password=yourpassword" \
|
||||
| jq -r '.access_token')
|
||||
|
||||
# Create the link
|
||||
curl -X POST "http://localhost:8000/api/v1/social-media-links" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"platform": "MASTODON",
|
||||
"url": "https://mastodon.social/@surfsense",
|
||||
"label": "Mastodon",
|
||||
"display_order": 1,
|
||||
"is_active": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Add Multiple Links
|
||||
```bash
|
||||
# Pixelfed
|
||||
curl -X POST "http://localhost:8000/api/v1/social-media-links" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"platform": "PIXELFED",
|
||||
"url": "https://pixelfed.social/username",
|
||||
"label": "Pixelfed",
|
||||
"display_order": 2,
|
||||
"is_active": true
|
||||
}'
|
||||
|
||||
# Bookwyrm
|
||||
curl -X POST "http://localhost:8000/api/v1/social-media-links" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"platform": "BOOKWYRM",
|
||||
"url": "https://bookwyrm.social/user/username",
|
||||
"label": "Bookwyrm",
|
||||
"display_order": 3,
|
||||
"is_active": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Update a Link
|
||||
```bash
|
||||
# Hide a link without deleting it
|
||||
curl -X PATCH "http://localhost:8000/api/v1/social-media-links/1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"is_active": false}'
|
||||
```
|
||||
|
||||
### List All Links
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/social-media-links" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Delete a Link
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:8000/api/v1/social-media-links/1" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Icon Mapping
|
||||
|
||||
The frontend automatically maps platform names to appropriate icons:
|
||||
|
||||
| Platform | Icon |
|
||||
|----------|------|
|
||||
| MASTODON | Mastodon logo |
|
||||
| PIXELFED | Photo icon |
|
||||
| BOOKWYRM | Book icon |
|
||||
| GITHUB | GitHub logo |
|
||||
| GITLAB | GitLab logo |
|
||||
| LINKEDIN | LinkedIn logo |
|
||||
| WEBSITE | World/globe icon |
|
||||
| EMAIL | Mail icon |
|
||||
| MATRIX | Matrix logo |
|
||||
| PEERTUBE | TV/video icon |
|
||||
| LEMMY | World icon |
|
||||
| OTHER | World icon |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Backend:** Social media links are stored in the `social_media_links` database table
|
||||
2. **Public API:** The `/api/v1/social-media-links/public` endpoint returns only active links
|
||||
3. **Frontend:** The footer component (`components/homepage/footer.tsx`) fetches links on page load
|
||||
4. **Display:** Only links with `is_active=true` are shown, ordered by `display_order`
|
||||
5. **Icons:** Platform-specific icons are automatically selected based on the `platform` field
|
||||
|
||||
## Database Migration
|
||||
|
||||
To apply the database schema:
|
||||
|
||||
```bash
|
||||
cd surfsense_backend
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
This will create the `social_media_links` table and the `socialmediaplatform` enum type.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Admin endpoints require superuser privileges** - regular users cannot manage links
|
||||
- **Public endpoint is unauthenticated** - anyone can view active links (as intended for homepage display)
|
||||
- **No hardcoded URLs** - all social media links come from the database
|
||||
- **Validation** - URLs are validated (max 500 chars), labels max 100 chars
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Future improvements could include:
|
||||
|
||||
1. **Web UI:** A graphical admin panel in the dashboard for managing links
|
||||
2. **Drag-and-drop reordering:** UI to easily reorder links by changing `display_order`
|
||||
3. **Link analytics:** Track clicks on social media links
|
||||
4. **Per-user links:** Allow different users to have different social links (currently global)
|
||||
5. **Link preview:** Show how the footer will look before saving changes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Links not appearing on homepage:**
|
||||
1. Check that `is_active=true` for the link
|
||||
2. Verify the backend API is reachable from frontend
|
||||
3. Check browser console for fetch errors
|
||||
4. Confirm `NEXT_PUBLIC_FASTAPI_BACKEND_URL` environment variable is set correctly
|
||||
|
||||
**Cannot create links:**
|
||||
1. Ensure you're logged in as a superuser
|
||||
2. Check JWT token is valid and not expired
|
||||
3. Verify request payload matches the schema
|
||||
|
||||
**Icons not displaying:**
|
||||
1. Check that the platform name exactly matches one of the supported platforms (case-sensitive)
|
||||
2. Unsupported platforms will default to the world/globe icon
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""add social_media_links table
|
||||
|
||||
Revision ID: 37_add_social_media_links_table
|
||||
Revises: 36_remove_fk_constraints_for_global_llm_configs
|
||||
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 = '37_add_social_media_links_table'
|
||||
down_revision: Union[str, None] = '36_remove_fk_constraints_for_global_llm_configs'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create enum type
|
||||
social_media_platform_enum = sa.Enum(
|
||||
'MASTODON', 'PIXELFED', 'BOOKWYRM', 'LEMMY', 'PEERTUBE',
|
||||
'GITHUB', 'GITLAB', 'MATRIX', 'LINKEDIN', 'WEBSITE', 'EMAIL', 'OTHER',
|
||||
name='socialmediaplatform'
|
||||
)
|
||||
social_media_platform_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Create social_media_links table
|
||||
op.create_table(
|
||||
'social_media_links',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('platform', social_media_platform_enum, nullable=False),
|
||||
sa.Column('url', sa.String(length=500), nullable=False),
|
||||
sa.Column('label', sa.String(length=100), nullable=True),
|
||||
sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_social_media_links_id', 'social_media_links', ['id'])
|
||||
op.create_index('ix_social_media_links_platform', 'social_media_links', ['platform'])
|
||||
op.create_index('ix_social_media_links_created_at', 'social_media_links', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes
|
||||
op.drop_index('ix_social_media_links_created_at', table_name='social_media_links')
|
||||
op.drop_index('ix_social_media_links_platform', table_name='social_media_links')
|
||||
op.drop_index('ix_social_media_links_id', table_name='social_media_links')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('social_media_links')
|
||||
|
||||
# Drop enum type
|
||||
sa.Enum(name='socialmediaplatform').drop(op.get_bind(), checkfirst=True)
|
||||
|
|
@ -124,6 +124,21 @@ class LogStatus(str, Enum):
|
|||
FAILED = "FAILED"
|
||||
|
||||
|
||||
class SocialMediaPlatform(str, Enum):
|
||||
MASTODON = "MASTODON"
|
||||
PIXELFED = "PIXELFED"
|
||||
BOOKWYRM = "BOOKWYRM"
|
||||
LEMMY = "LEMMY"
|
||||
PEERTUBE = "PEERTUBE"
|
||||
GITHUB = "GITHUB"
|
||||
GITLAB = "GITLAB"
|
||||
MATRIX = "MATRIX"
|
||||
LINKEDIN = "LINKEDIN"
|
||||
WEBSITE = "WEBSITE"
|
||||
EMAIL = "EMAIL"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
|
@ -358,6 +373,16 @@ class Log(BaseModel, TimestampMixin):
|
|||
search_space = relationship("SearchSpace", back_populates="logs")
|
||||
|
||||
|
||||
class SocialMediaLink(BaseModel, TimestampMixin):
|
||||
__tablename__ = "social_media_links"
|
||||
|
||||
platform = Column(SQLAlchemyEnum(SocialMediaPlatform), nullable=False, index=True)
|
||||
url = Column(String(500), nullable=False)
|
||||
label = Column(String(100), nullable=True) # Optional custom label
|
||||
display_order = Column(Integer, nullable=False, default=0) # For ordering links
|
||||
is_active = Column(Boolean, nullable=False, default=True) # Toggle visibility
|
||||
|
||||
|
||||
if config.AUTH_TYPE == "GOOGLE":
|
||||
|
||||
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 .search_source_connectors_routes import router as search_source_connectors_router
|
||||
from .search_spaces_routes import router as search_spaces_router
|
||||
from .social_media_links_routes import router as social_media_links_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -31,3 +32,4 @@ router.include_router(airtable_add_connector_router)
|
|||
router.include_router(luma_add_connector_router)
|
||||
router.include_router(llm_config_router)
|
||||
router.include_router(logs_router)
|
||||
router.include_router(social_media_links_router)
|
||||
|
|
|
|||
151
surfsense_backend/app/routes/social_media_links_routes.py
Normal file
151
surfsense_backend/app/routes/social_media_links_routes.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import SocialMediaLink, User, get_async_session
|
||||
from app.schemas.social_media_links import (
|
||||
SocialMediaLinkCreate,
|
||||
SocialMediaLinkPublic,
|
||||
SocialMediaLinkRead,
|
||||
SocialMediaLinkUpdate,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/social-media-links", tags=["Social Media Links"])
|
||||
|
||||
|
||||
@router.get("/public", response_model=list[SocialMediaLinkPublic])
|
||||
async def get_public_social_media_links(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Get all active social media links for public display (no authentication required).
|
||||
Returns links ordered by display_order.
|
||||
"""
|
||||
query = (
|
||||
select(SocialMediaLink)
|
||||
.where(SocialMediaLink.is_active == True)
|
||||
.order_by(SocialMediaLink.display_order, SocialMediaLink.id)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
links = result.scalars().all()
|
||||
return links
|
||||
|
||||
|
||||
@router.get("", response_model=list[SocialMediaLinkRead])
|
||||
async def get_all_social_media_links(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get all social media links (admin only).
|
||||
Requires authentication and superuser privileges.
|
||||
"""
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
query = select(SocialMediaLink).order_by(
|
||||
SocialMediaLink.display_order, SocialMediaLink.id
|
||||
)
|
||||
result = await db.execute(query)
|
||||
links = result.scalars().all()
|
||||
return links
|
||||
|
||||
|
||||
@router.post("", response_model=SocialMediaLinkRead, status_code=201)
|
||||
async def create_social_media_link(
|
||||
link_data: SocialMediaLinkCreate,
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Create a new social media link (admin only).
|
||||
Requires authentication and superuser privileges.
|
||||
"""
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
new_link = SocialMediaLink(**link_data.model_dump())
|
||||
db.add(new_link)
|
||||
await db.commit()
|
||||
await db.refresh(new_link)
|
||||
return new_link
|
||||
|
||||
|
||||
@router.get("/{link_id}", response_model=SocialMediaLinkRead)
|
||||
async def get_social_media_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific social media link by ID (admin only).
|
||||
Requires authentication and superuser privileges.
|
||||
"""
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
query = select(SocialMediaLink).where(SocialMediaLink.id == link_id)
|
||||
result = await db.execute(query)
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Social media link not found")
|
||||
|
||||
return link
|
||||
|
||||
|
||||
@router.patch("/{link_id}", response_model=SocialMediaLinkRead)
|
||||
async def update_social_media_link(
|
||||
link_id: int,
|
||||
link_data: SocialMediaLinkUpdate,
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a social media link (admin only).
|
||||
Requires authentication and superuser privileges.
|
||||
"""
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
query = select(SocialMediaLink).where(SocialMediaLink.id == link_id)
|
||||
result = await db.execute(query)
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Social media link not found")
|
||||
|
||||
# Update only provided fields
|
||||
update_data = link_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(link, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
@router.delete("/{link_id}", status_code=204)
|
||||
async def delete_social_media_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a social media link (admin only).
|
||||
Requires authentication and superuser privileges.
|
||||
"""
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
query = select(SocialMediaLink).where(SocialMediaLink.id == link_id)
|
||||
result = await db.execute(query)
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Social media link not found")
|
||||
|
||||
await db.delete(link)
|
||||
await db.commit()
|
||||
return None
|
||||
42
surfsense_backend/app/schemas/social_media_links.py
Normal file
42
surfsense_backend/app/schemas/social_media_links.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
from app.db import SocialMediaPlatform
|
||||
|
||||
|
||||
class SocialMediaLinkBase(BaseModel):
|
||||
platform: SocialMediaPlatform
|
||||
url: str = Field(..., max_length=500)
|
||||
label: str | None = Field(None, max_length=100)
|
||||
display_order: int = Field(default=0, ge=0)
|
||||
is_active: bool = Field(default=True)
|
||||
|
||||
|
||||
class SocialMediaLinkCreate(SocialMediaLinkBase):
|
||||
pass
|
||||
|
||||
|
||||
class SocialMediaLinkUpdate(BaseModel):
|
||||
platform: SocialMediaPlatform | None = None
|
||||
url: str | None = Field(None, max_length=500)
|
||||
label: str | None = Field(None, max_length=100)
|
||||
display_order: int | None = Field(None, ge=0)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class SocialMediaLinkRead(SocialMediaLinkBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SocialMediaLinkPublic(BaseModel):
|
||||
"""Public-facing schema with only necessary fields for display"""
|
||||
id: int
|
||||
platform: SocialMediaPlatform
|
||||
url: str
|
||||
label: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
"use client";
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
|
||||
export function GoogleLoginButton() {
|
||||
const t = useTranslations("auth");
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth authorization URL
|
||||
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get authorization URL");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
console.error("No authorization URL received");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error during Google login:", error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-full my-8" />
|
||||
{/* <h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Login
|
||||
</h1> */}
|
||||
{/*
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-4 w-full overflow-hidden rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-900 shadow-sm dark:border-yellow-900/30 dark:bg-yellow-900/20 dark:text-yellow-200"
|
||||
>
|
||||
<motion.div
|
||||
className="flex items-center gap-2 p-4"
|
||||
initial={{ x: -5 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<title>Google Logo</title>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<div className="ml-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("cloud_dev_notice")}{" "}
|
||||
<a
|
||||
href="/docs"
|
||||
className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
{t("docs")}
|
||||
</a>{" "}
|
||||
{t("cloud_dev_self_hosted")}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div> */}
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
||||
</div>
|
||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
<span className="text-base font-medium">{t("continue_with_google")}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,12 +3,72 @@ 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";
|
||||
|
||||
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, // Using generic world icon for Lemmy
|
||||
OTHER: IconWorld,
|
||||
};
|
||||
return iconMap[platform] || IconWorld;
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
const [socialLinks, setSocialLinks] = useState<SocialMediaLink[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch social media links from the public API (no auth required)
|
||||
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);
|
||||
} else {
|
||||
console.error("Failed to fetch social media links:", response.statusText);
|
||||
}
|
||||
} 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">
|
||||
|
|
@ -25,32 +85,26 @@ export function Footer() {
|
|||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
||||
© SurfSense 2025
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="https://kapteinis.lv/@ojars"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Mastodon"
|
||||
>
|
||||
<IconBrandMastodon className="h-6 w-6 text-neutral-500 dark:text-neutral-300 hover:text-neutral-700 dark:hover:text-neutral-100 transition-colors" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://pixel.kapteinis.lv/ojars"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Pixelfed"
|
||||
>
|
||||
<IconPhoto className="h-6 w-6 text-neutral-500 dark:text-neutral-300 hover:text-neutral-700 dark:hover:text-neutral-100 transition-colors" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://book.kapteinis.lv/user/ojars"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Bookwyrm"
|
||||
>
|
||||
<IconBook 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>
|
||||
{!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>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
Loading…
Add table
Add a link
Reference in a new issue