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:
Claude 2025-11-17 20:44:17 +00:00
parent c50f2c7e3e
commit a1699dbe8f
No known key found for this signature in database
12 changed files with 595 additions and 128 deletions

236
SOCIAL_MEDIA_LINKS_ADMIN.md Normal file
View 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

View file

@ -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)

View file

@ -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):

View file

@ -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)

View 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

View 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}

View file

@ -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>
);
}

View file

@ -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">
&copy; 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