Initial commit: Leopost Full — merge di Leopost, Post Generator e Autopilot OS

- Backend FastAPI con multi-LLM (Claude/OpenAI/Gemini)
- Publishing su Facebook, Instagram, YouTube, TikTok
- Calendario editoriale con awareness levels (PAS, AIDA, BAB...)
- Design system Editorial Fresh (Fraunces + DM Sans)
- Scheduler automatico, gestione commenti AI, affiliate links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-31 17:23:16 +02:00
commit 519a580679
58 changed files with 8348 additions and 0 deletions

15
.gitattributes vendored Normal file
View File

@@ -0,0 +1,15 @@
* text=auto
*.sh text eol=lf
*.bash text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.md text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Dependencies
node_modules/
__pycache__/
*.pyc
venv/
.venv/
# Environment
.env
.env.local
.env.*.local
# Build
.next/
dist/
build/
out/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Data (local dev)
data/
*.db
*.sqlite

31
.vps-lab-config.json Normal file
View File

@@ -0,0 +1,31 @@
{
"type": "vps-lab",
"project_name": "Leopost Full",
"slug": "leopost-full",
"created_at": "2026-03-31T15:08:38Z",
"gitea": {
"repo_url": "https://git.mlhub.it/Michele/leopost-full",
"clone_url": "https://git.mlhub.it/Michele/leopost-full.git"
},
"vps": {
"deployed": false,
"url": "https://lab.mlhub.it/leopost-full/",
"container": "lab-leopost-full-app",
"path": "/opt/lab-leopost-full/"
},
"supabase": {
"enabled": false,
"project_ref": null
},
"stack": {
"frontend": "React (Vite) + Tailwind CSS",
"backend": "FastAPI (Python)",
"database": "SQLite",
"ai": "Multi-LLM (Claude / OpenAI / Gemini)"
},
"merge_sources": [
"leopost (design system)",
"postgenerator (editorial calendar + awareness levels)",
"luigi-social (core business logic)"
]
}

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# Stage 1: Build frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: Python backend + frontend built
FROM python:3.12-slim
WORKDIR /app
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./
COPY --from=frontend-builder /app/frontend/dist ./static
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--root-path", "/leopost-full"]

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Leopost Full
Social Content OS — merge di Leopost, Post Generator e Autopilot OS.
Lab: https://lab.mlhub.it/leopost-full/
## Stack
- **Frontend:** React (Vite) + Tailwind CSS — design system "Editorial Fresh" (Fraunces + DM Sans)
- **Backend:** FastAPI (Python) + SQLite
- **AI:** Multi-LLM (Claude / OpenAI / Gemini)
- **Publishing:** Facebook, Instagram, YouTube, TikTok
## Funzionalità
- Gestione Characters (profili creatori)
- Generazione contenuti AI multi-piattaforma
- Calendario editoriale con livelli di consapevolezza (PAS, AIDA, BAB...)
- Export CSV per Canva Bulk Create
- Scheduling automatico + pubblicazione social
- Gestione commenti con AI reply
- Affiliate links con auto-injection
- Multi-LLM configurabile via Settings
## Repository
- Gitea: https://git.mlhub.it/Michele/leopost-full

0
backend/app/__init__.py Normal file
View File

60
backend/app/auth.py Normal file
View File

@@ -0,0 +1,60 @@
from datetime import datetime, timedelta
import bcrypt
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import User
from .schemas import LoginRequest, Token
router = APIRouter(prefix="/api/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> User:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = db.query(User).filter(User.username == username).first()
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user
@router.post("/login", response_model=Token)
def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == request.username).first()
if not user or not verify_password(request.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.username})
return Token(access_token=token)
@router.get("/me")
def me(user: User = Depends(get_current_user)):
return {"username": user.username}

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///./data/leopost.db"
secret_key: str = "change-me-to-a-random-secret-key"
admin_username: str = "admin"
admin_password: str = "changeme"
access_token_expire_minutes: int = 1440 # 24h
class Config:
env_file = ".env"
settings = Settings()

20
backend/app/database.py Normal file
View File

@@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings
connect_args = {}
if settings.database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

156
backend/app/main.py Normal file
View File

@@ -0,0 +1,156 @@
"""Leopost Full — FastAPI application.
IMPORTANT: root_path is intentionally NOT set in the FastAPI() constructor.
It is passed only via Uvicorn's --root-path flag at runtime.
"""
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from threading import Thread
from time import sleep
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .auth import hash_password
from .auth import router as auth_router
from .config import settings
from .database import Base, SessionLocal, engine
from .models import User
from .routers.affiliates import router as affiliates_router
from .routers.characters import router as characters_router
from .routers.comments import router as comments_router
from .routers.content import router as content_router
from .routers.editorial import router as editorial_router
from .routers.plans import router as plans_router
from .routers.settings import router as settings_router
from .routers.social import router as social_router
logger = logging.getLogger("leopost")
# ---------------------------------------------------------------------------
# SPA static files with catch-all fallback
# ---------------------------------------------------------------------------
class SPAStaticFiles(StaticFiles):
"""Serve a React SPA: return index.html for any 404 so the client-side
router handles unknown paths instead of returning a 404 from the server."""
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except Exception:
return await super().get_response("index.html", scope)
# ---------------------------------------------------------------------------
# Background scheduler
# ---------------------------------------------------------------------------
_scheduler_running = False
def _scheduler_loop():
"""Simple scheduler that checks for posts to publish every 60 seconds."""
from .scheduler import check_and_publish
global _scheduler_running
while _scheduler_running:
try:
check_and_publish()
except Exception as e:
logger.error(f"Scheduler error: {e}")
sleep(60)
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global _scheduler_running
# Ensure data directory exists
data_dir = Path("./data")
data_dir.mkdir(parents=True, exist_ok=True)
# Create tables
Base.metadata.create_all(bind=engine)
# Create admin user if not exists
db = SessionLocal()
try:
existing = db.query(User).filter(User.username == settings.admin_username).first()
if not existing:
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
)
db.add(admin)
db.commit()
finally:
db.close()
# Start background scheduler
_scheduler_running = True
scheduler_thread = Thread(target=_scheduler_loop, daemon=True)
scheduler_thread.start()
logger.info("Background scheduler started")
yield
# Shutdown scheduler
_scheduler_running = False
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
# CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead.
app = FastAPI(
title="Leopost Full",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# API routes (must be registered BEFORE the SPA catch-all mount)
# ---------------------------------------------------------------------------
app.include_router(auth_router)
app.include_router(characters_router)
app.include_router(content_router)
app.include_router(affiliates_router)
app.include_router(plans_router)
app.include_router(social_router)
app.include_router(comments_router)
app.include_router(settings_router)
app.include_router(editorial_router)
@app.get("/api/health")
def health():
return {"status": "ok", "version": "0.1.0"}
# ---------------------------------------------------------------------------
# SPA catch-all mount (MUST be last)
# ---------------------------------------------------------------------------
_STATIC_DIR = Path(__file__).parent.parent.parent / "static"
if _STATIC_DIR.exists():
app.mount("/", SPAStaticFiles(directory=str(_STATIC_DIR), html=True), name="spa")

156
backend/app/models.py Normal file
View File

@@ -0,0 +1,156 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String, Text
from .database import Base
# === Phase 1: Core ===
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
class Character(Base):
__tablename__ = "characters"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
niche = Column(String(200), nullable=False)
topics = Column(JSON, default=list)
tone = Column(Text)
visual_style = Column(JSON, default=dict)
social_accounts = Column(JSON, default=dict)
affiliate_links = Column(JSON, default=list)
avatar_url = Column(String(500))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# === Phase 2: Content Generation ===
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True, index=True)
character_id = Column(Integer, ForeignKey("characters.id"), nullable=False)
content_type = Column(String(20), default="text") # text, image, video, carousel
text_content = Column(Text)
hashtags = Column(JSON, default=list)
image_url = Column(String(500))
video_url = Column(String(500))
media_urls = Column(JSON, default=list)
affiliate_links_used = Column(JSON, default=list)
llm_provider = Column(String(50))
llm_model = Column(String(100))
platform_hint = Column(String(20)) # which platform this was generated for
status = Column(String(20), default="draft") # draft, approved, scheduled, published, failed
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# === Phase 4: Affiliate Links ===
class AffiliateLink(Base):
__tablename__ = "affiliate_links"
id = Column(Integer, primary_key=True, index=True)
character_id = Column(Integer, ForeignKey("characters.id"), nullable=True) # null = global
network = Column(String(100), nullable=False) # amazon, clickbank, etc.
name = Column(String(200), nullable=False)
url = Column(String(1000), nullable=False)
tag = Column(String(100))
topics = Column(JSON, default=list) # relevant topics for auto-insertion
is_active = Column(Boolean, default=True)
click_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
# === Phase 5: Scheduling ===
class EditorialPlan(Base):
__tablename__ = "editorial_plans"
id = Column(Integer, primary_key=True, index=True)
character_id = Column(Integer, ForeignKey("characters.id"), nullable=False)
name = Column(String(200), nullable=False)
frequency = Column(String(20), default="daily") # daily, twice_daily, weekly, custom
posts_per_day = Column(Integer, default=1)
platforms = Column(JSON, default=list) # ["facebook", "instagram", "youtube"]
content_types = Column(JSON, default=list) # ["text", "image", "video"]
posting_times = Column(JSON, default=list) # ["09:00", "18:00"]
start_date = Column(DateTime)
end_date = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ScheduledPost(Base):
__tablename__ = "scheduled_posts"
id = Column(Integer, primary_key=True, index=True)
plan_id = Column(Integer, ForeignKey("editorial_plans.id"), nullable=True)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
platform = Column(String(20), nullable=False)
scheduled_at = Column(DateTime, nullable=False)
published_at = Column(DateTime, nullable=True)
status = Column(String(20), default="pending") # pending, publishing, published, failed
error_message = Column(Text, nullable=True)
external_post_id = Column(String(200), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# === Phase 6-10: Social Accounts ===
class SocialAccount(Base):
__tablename__ = "social_accounts"
id = Column(Integer, primary_key=True, index=True)
character_id = Column(Integer, ForeignKey("characters.id"), nullable=False)
platform = Column(String(20), nullable=False) # facebook, instagram, youtube, tiktok
account_name = Column(String(200))
account_id = Column(String(200))
access_token = Column(Text)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime, nullable=True)
page_id = Column(String(200), nullable=True) # Facebook page ID
extra_data = Column(JSON, default=dict) # platform-specific data
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
# === Phase 11: Comment Management ===
class Comment(Base):
__tablename__ = "comments"
id = Column(Integer, primary_key=True, index=True)
scheduled_post_id = Column(Integer, ForeignKey("scheduled_posts.id"), nullable=True)
platform = Column(String(20), nullable=False)
external_comment_id = Column(String(200))
author_name = Column(String(200))
author_id = Column(String(200))
content = Column(Text)
ai_suggested_reply = Column(Text, nullable=True)
approved_reply = Column(Text, nullable=True)
reply_status = Column(String(20), default="pending") # pending, approved, replied, ignored
replied_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# === System Settings ===
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False)
value = Column(JSON)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

View File

@@ -0,0 +1,75 @@
"""Affiliate links CRUD router.
Manages affiliate links that can be injected into generated content.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import AffiliateLink
from ..schemas import AffiliateLinkCreate, AffiliateLinkResponse, AffiliateLinkUpdate
router = APIRouter(
prefix="/api/affiliates",
tags=["affiliates"],
dependencies=[Depends(get_current_user)],
)
@router.get("/", response_model=list[AffiliateLinkResponse])
def list_affiliate_links(
character_id: int | None = Query(None),
db: Session = Depends(get_db),
):
"""List all affiliate links, optionally filtered by character."""
query = db.query(AffiliateLink)
if character_id is not None:
query = query.filter(AffiliateLink.character_id == character_id)
return query.order_by(AffiliateLink.created_at.desc()).all()
@router.get("/{link_id}", response_model=AffiliateLinkResponse)
def get_affiliate_link(link_id: int, db: Session = Depends(get_db)):
"""Get a single affiliate link by ID."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found")
return link
@router.post("/", response_model=AffiliateLinkResponse, status_code=201)
def create_affiliate_link(data: AffiliateLinkCreate, db: Session = Depends(get_db)):
"""Create a new affiliate link."""
link = AffiliateLink(**data.model_dump())
db.add(link)
db.commit()
db.refresh(link)
return link
@router.put("/{link_id}", response_model=AffiliateLinkResponse)
def update_affiliate_link(
link_id: int, data: AffiliateLinkUpdate, db: Session = Depends(get_db)
):
"""Update an affiliate link."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(link, key, value)
db.commit()
db.refresh(link)
return link
@router.delete("/{link_id}", status_code=204)
def delete_affiliate_link(link_id: int, db: Session = Depends(get_db)):
"""Delete an affiliate link."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found")
db.delete(link)
db.commit()

View File

@@ -0,0 +1,62 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Character
from ..schemas import CharacterCreate, CharacterResponse, CharacterUpdate
router = APIRouter(
prefix="/api/characters",
tags=["characters"],
dependencies=[Depends(get_current_user)],
)
@router.get("/", response_model=list[CharacterResponse])
def list_characters(db: Session = Depends(get_db)):
return db.query(Character).order_by(Character.created_at.desc()).all()
@router.get("/{character_id}", response_model=CharacterResponse)
def get_character(character_id: int, db: Session = Depends(get_db)):
character = db.query(Character).filter(Character.id == character_id).first()
if not character:
raise HTTPException(status_code=404, detail="Character not found")
return character
@router.post("/", response_model=CharacterResponse, status_code=201)
def create_character(data: CharacterCreate, db: Session = Depends(get_db)):
character = Character(**data.model_dump())
db.add(character)
db.commit()
db.refresh(character)
return character
@router.put("/{character_id}", response_model=CharacterResponse)
def update_character(
character_id: int, data: CharacterUpdate, db: Session = Depends(get_db)
):
character = db.query(Character).filter(Character.id == character_id).first()
if not character:
raise HTTPException(status_code=404, detail="Character not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(character, key, value)
character.updated_at = datetime.utcnow()
db.commit()
db.refresh(character)
return character
@router.delete("/{character_id}", status_code=204)
def delete_character(character_id: int, db: Session = Depends(get_db)):
character = db.query(Character).filter(Character.id == character_id).first()
if not character:
raise HTTPException(status_code=404, detail="Character not found")
db.delete(character)
db.commit()

View File

@@ -0,0 +1,281 @@
"""Comment management router.
Handles fetching, reviewing, and replying to comments on published posts.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting
from ..schemas import CommentAction, CommentResponse
from ..services.llm import get_llm_provider
from ..services.social import get_publisher
router = APIRouter(
prefix="/api/comments",
tags=["comments"],
dependencies=[Depends(get_current_user)],
)
@router.get("/", response_model=list[CommentResponse])
def list_comments(
platform: str | None = Query(None),
reply_status: str | None = Query(None),
scheduled_post_id: int | None = Query(None),
db: Session = Depends(get_db),
):
"""List comments with optional filters."""
query = db.query(Comment)
if platform is not None:
query = query.filter(Comment.platform == platform)
if reply_status is not None:
query = query.filter(Comment.reply_status == reply_status)
if scheduled_post_id is not None:
query = query.filter(Comment.scheduled_post_id == scheduled_post_id)
return query.order_by(Comment.created_at.desc()).all()
@router.get("/pending", response_model=list[CommentResponse])
def list_pending_comments(db: Session = Depends(get_db)):
"""Get only pending comments (reply_status='pending')."""
return (
db.query(Comment)
.filter(Comment.reply_status == "pending")
.order_by(Comment.created_at.desc())
.all()
)
@router.get("/{comment_id}", response_model=CommentResponse)
def get_comment(comment_id: int, db: Session = Depends(get_db)):
"""Get a single comment by ID."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
return comment
@router.post("/{comment_id}/action", response_model=CommentResponse)
def action_on_comment(
comment_id: int, data: CommentAction, db: Session = Depends(get_db)
):
"""Take action on a comment: approve, edit, or ignore."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
if data.action == "approve":
comment.approved_reply = comment.ai_suggested_reply
comment.reply_status = "approved"
elif data.action == "edit":
if not data.reply_text:
raise HTTPException(status_code=400, detail="reply_text required for edit action")
comment.approved_reply = data.reply_text
comment.reply_status = "approved"
elif data.action == "ignore":
comment.reply_status = "ignored"
else:
raise HTTPException(status_code=400, detail=f"Unknown action '{data.action}'. Use: approve, edit, ignore")
db.commit()
db.refresh(comment)
return comment
@router.post("/{comment_id}/reply", response_model=CommentResponse)
def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
"""Send the approved reply via the social platform API."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
if not comment.approved_reply:
raise HTTPException(status_code=400, detail="No approved reply to send. Use /action first.")
if not comment.external_comment_id:
raise HTTPException(status_code=400, detail="No external comment ID available for reply")
# Find the social account for this platform via the scheduled post
if not comment.scheduled_post_id:
raise HTTPException(status_code=400, detail="Comment is not linked to a scheduled post")
scheduled = (
db.query(ScheduledPost)
.filter(ScheduledPost.id == comment.scheduled_post_id)
.first()
)
if not scheduled:
raise HTTPException(status_code=404, detail="Associated scheduled post not found")
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Associated post not found")
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == comment.platform,
SocialAccount.is_active == True,
)
.first()
)
if not account:
raise HTTPException(
status_code=400,
detail=f"No active {comment.platform} account found for this character",
)
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
try:
publisher = get_publisher(account.platform, account.access_token, **kwargs)
success = publisher.reply_to_comment(comment.external_comment_id, comment.approved_reply)
if not success:
raise RuntimeError("Platform returned failure for reply")
comment.reply_status = "replied"
comment.replied_at = datetime.utcnow()
db.commit()
db.refresh(comment)
return comment
except (RuntimeError, ValueError) as e:
raise HTTPException(status_code=502, detail=f"Failed to send reply: {e}")
@router.post("/fetch/{platform}")
def fetch_comments(platform: str, db: Session = Depends(get_db)):
"""Fetch new comments from a platform for all published posts.
Creates Comment records for any new comments not already in the database.
Uses LLM to generate AI-suggested replies for each new comment.
"""
# Get all published scheduled posts for this platform
published_posts = (
db.query(ScheduledPost)
.filter(
ScheduledPost.platform == platform,
ScheduledPost.status == "published",
ScheduledPost.external_post_id != None,
)
.all()
)
if not published_posts:
return {"new_comments": 0, "message": f"No published posts found for {platform}"}
# Get LLM settings for AI reply generation
llm_provider_name = None
llm_api_key = None
llm_model = None
for key in ("llm_provider", "llm_api_key", "llm_model"):
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if setting:
if key == "llm_provider":
llm_provider_name = setting.value
elif key == "llm_api_key":
llm_api_key = setting.value
elif key == "llm_model":
llm_model = setting.value
llm = None
if llm_provider_name and llm_api_key:
try:
llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model)
except ValueError:
pass # LLM not available, skip AI replies
new_comment_count = 0
for scheduled in published_posts:
# Get the post to find the character
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
continue
# Find the social account
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == platform,
SocialAccount.is_active == True,
)
.first()
)
if not account or not account.access_token:
continue
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
try:
publisher = get_publisher(account.platform, account.access_token, **kwargs)
comments = publisher.get_comments(scheduled.external_post_id)
except (RuntimeError, ValueError):
continue # Skip this post if API call fails
for ext_comment in comments:
ext_id = ext_comment.get("id", "")
if not ext_id:
continue
# Check if comment already exists
existing = (
db.query(Comment)
.filter(Comment.external_comment_id == ext_id)
.first()
)
if existing:
continue
# Generate AI suggested reply if LLM is available
ai_reply = None
if llm:
try:
system_prompt = (
f"You are managing social media comments for a content creator. "
f"Write a friendly, on-brand reply to this comment. "
f"Keep it concise (1-2 sentences). Be authentic and engaging."
)
prompt = (
f"Comment by {ext_comment.get('author', 'someone')}: "
f"\"{ext_comment.get('text', '')}\"\n\n"
f"Write a reply:"
)
ai_reply = llm.generate(prompt, system=system_prompt)
except RuntimeError:
pass # Skip AI reply if generation fails
# Create comment record
comment = Comment(
scheduled_post_id=scheduled.id,
platform=platform,
external_comment_id=ext_id,
author_name=ext_comment.get("author", "Unknown"),
author_id=ext_comment.get("id", ""),
content=ext_comment.get("text", ""),
ai_suggested_reply=ai_reply,
reply_status="pending",
)
db.add(comment)
new_comment_count += 1
db.commit()
return {"new_comments": new_comment_count, "message": f"Fetched {new_comment_count} new comments from {platform}"}

View File

@@ -0,0 +1,225 @@
"""Content generation router.
Handles post generation via LLM, image generation, and CRUD operations on posts.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import AffiliateLink, Character, Post, SystemSetting
from ..schemas import (
GenerateContentRequest,
GenerateImageRequest,
PostResponse,
PostUpdate,
)
from ..services.content import generate_hashtags, generate_post_text, inject_affiliate_links
from ..services.images import get_image_provider
from ..services.llm import get_llm_provider
router = APIRouter(
prefix="/api/content",
tags=["content"],
dependencies=[Depends(get_current_user)],
)
def _get_setting(db: Session, key: str) -> str | None:
"""Retrieve a system setting value by key."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if setting is None:
return None
return setting.value
@router.post("/generate", response_model=PostResponse)
def generate_content(request: GenerateContentRequest, db: Session = Depends(get_db)):
"""Generate content for a character using LLM."""
# Validate character exists
character = db.query(Character).filter(Character.id == request.character_id).first()
if not character:
raise HTTPException(status_code=404, detail="Character not found")
# Get LLM settings
provider_name = request.provider or _get_setting(db, "llm_provider")
api_key = _get_setting(db, "llm_api_key")
model = request.model or _get_setting(db, "llm_model")
if not provider_name:
raise HTTPException(status_code=400, detail="LLM provider not configured. Set 'llm_provider' in settings.")
if not api_key:
raise HTTPException(status_code=400, detail="LLM API key not configured. Set 'llm_api_key' in settings.")
# Build character dict for content service
char_dict = {
"name": character.name,
"niche": character.niche,
"topics": character.topics or [],
"tone": character.tone or "professional",
}
# Create LLM provider and generate text
llm = get_llm_provider(provider_name, api_key, model)
text = generate_post_text(
character=char_dict,
llm_provider=llm,
platform=request.platform,
topic_hint=request.topic_hint,
)
# Generate hashtags
hashtags = generate_hashtags(text, llm, request.platform)
# Handle affiliate links
affiliate_links_used: list[dict] = []
if request.include_affiliates:
links = (
db.query(AffiliateLink)
.filter(
AffiliateLink.is_active == True,
(AffiliateLink.character_id == character.id) | (AffiliateLink.character_id == None),
)
.all()
)
if links:
link_dicts = [
{
"url": link.url,
"label": link.name,
"keywords": link.topics or [],
}
for link in links
]
text, affiliate_links_used = inject_affiliate_links(
text, link_dicts, character.topics or []
)
# Create post record
post = Post(
character_id=character.id,
content_type=request.content_type,
text_content=text,
hashtags=hashtags,
affiliate_links_used=affiliate_links_used,
llm_provider=provider_name,
llm_model=model,
platform_hint=request.platform,
status="draft",
)
db.add(post)
db.commit()
db.refresh(post)
return post
@router.post("/generate-image", response_model=PostResponse)
def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db)):
"""Generate an image for a character and attach to a post."""
# Validate character exists
character = db.query(Character).filter(Character.id == request.character_id).first()
if not character:
raise HTTPException(status_code=404, detail="Character not found")
# Get image settings
provider_name = request.provider or _get_setting(db, "image_provider")
api_key = _get_setting(db, "image_api_key")
if not provider_name:
raise HTTPException(status_code=400, detail="Image provider not configured. Set 'image_provider' in settings.")
if not api_key:
raise HTTPException(status_code=400, detail="Image API key not configured. Set 'image_api_key' in settings.")
# Build prompt from character if not provided
prompt = request.prompt
if not prompt:
style_hint = request.style_hint or ""
visual_style = character.visual_style or {}
style_desc = visual_style.get("description", "")
prompt = (
f"Create a social media image for {character.name}, "
f"a content creator in the {character.niche} niche. "
f"Style: {style_desc} {style_hint}".strip()
)
# Generate image
image_provider = get_image_provider(provider_name, api_key)
image_url = image_provider.generate(prompt, size=request.size)
# Create a new post with the image
post = Post(
character_id=character.id,
content_type="image",
image_url=image_url,
platform_hint="instagram",
status="draft",
)
db.add(post)
db.commit()
db.refresh(post)
return post
@router.get("/posts", response_model=list[PostResponse])
def list_posts(
character_id: int | None = Query(None),
status: str | None = Query(None),
db: Session = Depends(get_db),
):
"""List all posts with optional filters."""
query = db.query(Post)
if character_id is not None:
query = query.filter(Post.character_id == character_id)
if status is not None:
query = query.filter(Post.status == status)
return query.order_by(Post.created_at.desc()).all()
@router.get("/posts/{post_id}", response_model=PostResponse)
def get_post(post_id: int, db: Session = Depends(get_db)):
"""Get a single post by ID."""
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@router.put("/posts/{post_id}", response_model=PostResponse)
def update_post(post_id: int, data: PostUpdate, db: Session = Depends(get_db)):
"""Update a post."""
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(post, key, value)
post.updated_at = datetime.utcnow()
db.commit()
db.refresh(post)
return post
@router.delete("/posts/{post_id}", status_code=204)
def delete_post(post_id: int, db: Session = Depends(get_db)):
"""Delete a post."""
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
db.delete(post)
db.commit()
@router.post("/posts/{post_id}/approve", response_model=PostResponse)
def approve_post(post_id: int, db: Session = Depends(get_db)):
"""Approve a post (set status to 'approved')."""
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
post.status = "approved"
post.updated_at = datetime.utcnow()
db.commit()
db.refresh(post)
return post

View File

@@ -0,0 +1,112 @@
"""Editorial Calendar router.
Espone endpoint per il calendario editoriale con awareness levels e formati narrativi.
"""
import csv
import io
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..services.calendar_service import CalendarService, FORMATI_NARRATIVI, AWARENESS_LEVELS
router = APIRouter(
prefix="/api/editorial",
tags=["editorial"],
dependencies=[Depends(get_current_user)],
)
_calendar_service = CalendarService()
# === Schemas locali ===
class CalendarGenerateRequest(BaseModel):
topics: list[str]
format_narrativo: Optional[str] = None
awareness_level: Optional[int] = None
num_posts: int = 7
start_date: Optional[str] = None
character_id: Optional[int] = None
class ExportCsvRequest(BaseModel):
slots: list[dict]
# === Endpoints ===
@router.get("/formats")
def get_formats():
"""Restituisce i format narrativi disponibili con i relativi awareness levels."""
return {
"formats": _calendar_service.get_formats(),
"awareness_levels": [
{"value": k, "label": v}
for k, v in AWARENESS_LEVELS.items()
],
}
@router.post("/generate-calendar")
def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(get_db)):
"""Genera un calendario editoriale con awareness levels."""
if not request.topics:
return {"slots": [], "totale_post": 0}
slots = _calendar_service.generate_calendar(
topics=request.topics,
num_posts=request.num_posts,
format_narrativo=request.format_narrativo,
awareness_level=request.awareness_level,
start_date=request.start_date,
)
return {
"slots": slots,
"totale_post": len(slots),
}
@router.post("/export-csv")
def export_csv(request: ExportCsvRequest):
"""Esporta il calendario editoriale come CSV per Canva."""
output = io.StringIO()
fieldnames = [
"indice",
"data_pubblicazione",
"topic",
"formato_narrativo",
"awareness_level",
"awareness_label",
"note",
]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for slot in request.slots:
writer.writerow({
"indice": slot.get("indice", ""),
"data_pubblicazione": slot.get("data_pubblicazione", ""),
"topic": slot.get("topic", ""),
"formato_narrativo": slot.get("formato_narrativo", ""),
"awareness_level": slot.get("awareness_level", ""),
"awareness_label": slot.get("awareness_label", ""),
"note": slot.get("note", ""),
})
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": "attachment; filename=calendario_editoriale.csv"
},
)

View File

@@ -0,0 +1,150 @@
"""Editorial plans and scheduled posts router.
Manages editorial plans (posting schedules) and individual scheduled post entries.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import EditorialPlan, ScheduledPost
from ..schemas import (
EditorialPlanCreate,
EditorialPlanResponse,
EditorialPlanUpdate,
ScheduledPostCreate,
ScheduledPostResponse,
)
router = APIRouter(
prefix="/api/plans",
tags=["plans"],
dependencies=[Depends(get_current_user)],
)
# === Editorial Plans ===
@router.get("/", response_model=list[EditorialPlanResponse])
def list_plans(
character_id: int | None = Query(None),
db: Session = Depends(get_db),
):
"""List all editorial plans, optionally filtered by character."""
query = db.query(EditorialPlan)
if character_id is not None:
query = query.filter(EditorialPlan.character_id == character_id)
return query.order_by(EditorialPlan.created_at.desc()).all()
@router.get("/scheduled", response_model=list[ScheduledPostResponse])
def list_all_scheduled_posts(
platform: str | None = Query(None),
status: str | None = Query(None),
date_from: datetime | None = Query(None),
date_after: datetime | None = Query(None),
db: Session = Depends(get_db),
):
"""Get all scheduled posts across all plans with optional filters."""
query = db.query(ScheduledPost)
if platform is not None:
query = query.filter(ScheduledPost.platform == platform)
if status is not None:
query = query.filter(ScheduledPost.status == status)
if date_from is not None:
query = query.filter(ScheduledPost.scheduled_at >= date_from)
if date_after is not None:
query = query.filter(ScheduledPost.scheduled_at <= date_after)
return query.order_by(ScheduledPost.scheduled_at).all()
@router.get("/{plan_id}", response_model=EditorialPlanResponse)
def get_plan(plan_id: int, db: Session = Depends(get_db)):
"""Get a single editorial plan by ID."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
return plan
@router.post("/", response_model=EditorialPlanResponse, status_code=201)
def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)):
"""Create a new editorial plan."""
plan = EditorialPlan(**data.model_dump())
db.add(plan)
db.commit()
db.refresh(plan)
return plan
@router.put("/{plan_id}", response_model=EditorialPlanResponse)
def update_plan(
plan_id: int, data: EditorialPlanUpdate, db: Session = Depends(get_db)
):
"""Update an editorial plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(plan, key, value)
plan.updated_at = datetime.utcnow()
db.commit()
db.refresh(plan)
return plan
@router.delete("/{plan_id}", status_code=204)
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""Delete an editorial plan and its associated scheduled posts."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
# Delete associated scheduled posts first
db.query(ScheduledPost).filter(ScheduledPost.plan_id == plan_id).delete()
db.delete(plan)
db.commit()
@router.post("/{plan_id}/toggle", response_model=EditorialPlanResponse)
def toggle_plan(plan_id: int, db: Session = Depends(get_db)):
"""Toggle the is_active status of an editorial plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
plan.is_active = not plan.is_active
plan.updated_at = datetime.utcnow()
db.commit()
db.refresh(plan)
return plan
# === Scheduled Posts ===
@router.get("/{plan_id}/schedule", response_model=list[ScheduledPostResponse])
def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)):
"""Get all scheduled posts for a specific plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
return (
db.query(ScheduledPost)
.filter(ScheduledPost.plan_id == plan_id)
.order_by(ScheduledPost.scheduled_at)
.all()
)
@router.post("/schedule", response_model=ScheduledPostResponse, status_code=201)
def schedule_post(data: ScheduledPostCreate, db: Session = Depends(get_db)):
"""Manually schedule a post."""
scheduled = ScheduledPost(**data.model_dump())
db.add(scheduled)
db.commit()
db.refresh(scheduled)
return scheduled

View File

@@ -0,0 +1,122 @@
"""System settings router.
Manages key-value system settings including API provider configuration.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import SystemSetting
from ..schemas import SettingResponse, SettingUpdate
router = APIRouter(
prefix="/api/settings",
tags=["settings"],
dependencies=[Depends(get_current_user)],
)
@router.get("/", response_model=list[SettingResponse])
def list_settings(db: Session = Depends(get_db)):
"""Get all system settings."""
settings = db.query(SystemSetting).order_by(SystemSetting.key).all()
return settings
@router.get("/providers/status")
def get_providers_status(db: Session = Depends(get_db)):
"""Check which API providers are configured (have API keys set).
Returns a dict indicating configuration status for each provider category.
"""
# Helper to check if a setting exists and has a truthy value
def _has_setting(key: str) -> str | None:
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if setting and setting.value:
return setting.value if isinstance(setting.value, str) else str(setting.value)
return None
# LLM provider
llm_provider = _has_setting("llm_provider")
llm_key = _has_setting("llm_api_key")
# Image provider
image_provider = _has_setting("image_provider")
image_key = _has_setting("image_api_key")
# Voice provider (future)
voice_provider = _has_setting("voice_provider")
voice_key = _has_setting("voice_api_key")
# Social platforms - check for any active social accounts
from ..models import SocialAccount
social_platforms = {}
for platform in ("facebook", "instagram", "youtube", "tiktok"):
has_account = (
db.query(SocialAccount)
.filter(
SocialAccount.platform == platform,
SocialAccount.is_active == True,
SocialAccount.access_token != None,
)
.first()
)
social_platforms[platform] = has_account is not None
return {
"llm": {
"configured": bool(llm_provider and llm_key),
"provider": llm_provider,
},
"image": {
"configured": bool(image_provider and image_key),
"provider": image_provider,
},
"voice": {
"configured": bool(voice_provider and voice_key),
"provider": voice_provider,
},
"social": social_platforms,
}
@router.get("/{key}", response_model=SettingResponse)
def get_setting(key: str, db: Session = Depends(get_db)):
"""Get a single setting by key."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if not setting:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
return setting
@router.put("/{key}", response_model=SettingResponse)
def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db)):
"""Create or update a setting by key.
If the setting exists, update its value. If not, create it.
"""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if setting:
setting.value = data.value
setting.updated_at = datetime.utcnow()
else:
setting = SystemSetting(key=key, value=data.value)
db.add(setting)
db.commit()
db.refresh(setting)
return setting
@router.delete("/{key}", status_code=204)
def delete_setting(key: str, db: Session = Depends(get_db)):
"""Delete a setting by key."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if not setting:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
db.delete(setting)
db.commit()

View File

@@ -0,0 +1,203 @@
"""Social account management and publishing router.
Handles CRUD for social media accounts and manual publishing of scheduled posts.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Post, ScheduledPost, SocialAccount
from ..schemas import (
ScheduledPostResponse,
SocialAccountCreate,
SocialAccountResponse,
SocialAccountUpdate,
)
from ..services.social import get_publisher
router = APIRouter(
prefix="/api/social",
tags=["social"],
dependencies=[Depends(get_current_user)],
)
# === Social Accounts ===
@router.get("/accounts", response_model=list[SocialAccountResponse])
def list_social_accounts(
character_id: int | None = Query(None),
db: Session = Depends(get_db),
):
"""List all social accounts, optionally filtered by character."""
query = db.query(SocialAccount)
if character_id is not None:
query = query.filter(SocialAccount.character_id == character_id)
return query.order_by(SocialAccount.created_at.desc()).all()
@router.get("/accounts/{account_id}", response_model=SocialAccountResponse)
def get_social_account(account_id: int, db: Session = Depends(get_db)):
"""Get a single social account by ID."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
return account
@router.post("/accounts", response_model=SocialAccountResponse, status_code=201)
def create_social_account(data: SocialAccountCreate, db: Session = Depends(get_db)):
"""Create/connect a new social account."""
account = SocialAccount(**data.model_dump())
db.add(account)
db.commit()
db.refresh(account)
return account
@router.put("/accounts/{account_id}", response_model=SocialAccountResponse)
def update_social_account(
account_id: int, data: SocialAccountUpdate, db: Session = Depends(get_db)
):
"""Update a social account."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(account, key, value)
db.commit()
db.refresh(account)
return account
@router.delete("/accounts/{account_id}", status_code=204)
def delete_social_account(account_id: int, db: Session = Depends(get_db)):
"""Delete a social account."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
db.delete(account)
db.commit()
@router.post("/accounts/{account_id}/test")
def test_social_account(account_id: int, db: Session = Depends(get_db)):
"""Test connection to a social account by making a simple API call."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
if not account.access_token:
raise HTTPException(status_code=400, detail="No access token configured for this account")
try:
# Build kwargs based on platform
kwargs: dict = {}
if account.platform == "facebook":
if not account.page_id:
raise HTTPException(status_code=400, detail="Facebook account requires page_id")
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
ig_user_id = account.account_id or (account.extra_data or {}).get("ig_user_id")
if not ig_user_id:
raise HTTPException(status_code=400, detail="Instagram account requires ig_user_id")
kwargs["ig_user_id"] = ig_user_id
# Try to instantiate the publisher (validates credentials format)
get_publisher(account.platform, account.access_token, **kwargs)
return {"status": "ok", "message": f"Connection to {account.platform} account is configured correctly"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=502, detail=f"Connection test failed: {e}")
# === Publishing ===
@router.post("/publish/{scheduled_post_id}", response_model=ScheduledPostResponse)
def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)):
"""Manually trigger publishing of a scheduled post."""
scheduled = (
db.query(ScheduledPost)
.filter(ScheduledPost.id == scheduled_post_id)
.first()
)
if not scheduled:
raise HTTPException(status_code=404, detail="Scheduled post not found")
# Get the post content
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Associated post not found")
# Find the social account for this platform and character
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == scheduled.platform,
SocialAccount.is_active == True,
)
.first()
)
if not account:
raise HTTPException(
status_code=400,
detail=f"No active {scheduled.platform} account found for character {post.character_id}",
)
if not account.access_token:
raise HTTPException(status_code=400, detail="Social account has no access token configured")
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
try:
scheduled.status = "publishing"
db.commit()
publisher = get_publisher(account.platform, account.access_token, **kwargs)
# Determine publish method based on content type
text = post.text_content or ""
if post.hashtags:
text = f"{text}\n\n{' '.join(post.hashtags)}"
if post.video_url:
external_id = publisher.publish_video(text, post.video_url)
elif post.image_url:
external_id = publisher.publish_image(text, post.image_url)
else:
external_id = publisher.publish_text(text)
# Update scheduled post
scheduled.status = "published"
scheduled.published_at = datetime.utcnow()
scheduled.external_post_id = external_id
# Update post status
post.status = "published"
post.updated_at = datetime.utcnow()
db.commit()
db.refresh(scheduled)
return scheduled
except (RuntimeError, ValueError) as e:
scheduled.status = "failed"
scheduled.error_message = str(e)
db.commit()
db.refresh(scheduled)
raise HTTPException(status_code=502, detail=f"Publishing failed: {e}")

176
backend/app/scheduler.py Normal file
View File

@@ -0,0 +1,176 @@
"""
Background scheduler for Leopost Full.
Handles automated content generation and post publishing.
"""
import logging
from datetime import datetime
from .database import SessionLocal
from .models import EditorialPlan, Post, ScheduledPost, SocialAccount, SystemSetting
from .services.content import generate_post_text, generate_hashtags
from .services.llm import get_llm_provider
from .services.social import get_publisher
logger = logging.getLogger("leopost.scheduler")
def check_and_publish():
"""Check for posts that need publishing and publish them."""
db = SessionLocal()
try:
now = datetime.utcnow()
pending = (
db.query(ScheduledPost)
.filter(
ScheduledPost.status == "pending",
ScheduledPost.scheduled_at <= now,
)
.all()
)
for sp in pending:
try:
_publish_single(sp, db)
except Exception as e:
logger.error(f"Failed to publish post {sp.id}: {e}")
sp.status = "failed"
sp.error_message = str(e)
db.commit()
finally:
db.close()
def _publish_single(sp: ScheduledPost, db):
"""Publish a single scheduled post."""
post = db.query(Post).filter(Post.id == sp.post_id).first()
if not post:
sp.status = "failed"
sp.error_message = "Post not found"
db.commit()
return
# Find social account for this character + platform
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == sp.platform,
SocialAccount.is_active == True,
)
.first()
)
if not account:
sp.status = "failed"
sp.error_message = f"No active {sp.platform} account found"
db.commit()
return
sp.status = "publishing"
db.commit()
try:
kwargs = {}
if account.page_id:
kwargs["page_id"] = account.page_id
if hasattr(account, "extra_data") and account.extra_data:
kwargs.update(account.extra_data)
publisher = get_publisher(sp.platform, account.access_token, **kwargs)
if post.video_url:
ext_id = publisher.publish_video(post.text_content or "", post.video_url)
elif post.image_url:
ext_id = publisher.publish_image(post.text_content or "", post.image_url)
else:
text = post.text_content or ""
if post.hashtags:
text += "\n\n" + " ".join(post.hashtags)
ext_id = publisher.publish_text(text)
sp.status = "published"
sp.published_at = datetime.utcnow()
sp.external_post_id = ext_id
post.status = "published"
db.commit()
except Exception as e:
sp.status = "failed"
sp.error_message = str(e)
db.commit()
raise
def generate_planned_content():
"""Generate content for active editorial plans."""
db = SessionLocal()
try:
plans = (
db.query(EditorialPlan)
.filter(EditorialPlan.is_active == True)
.all()
)
# Get LLM settings
llm_setting = _get_setting(db, "llm_provider", "claude")
api_key_setting = _get_setting(db, "llm_api_key", "")
model_setting = _get_setting(db, "llm_model", None)
if not api_key_setting:
logger.warning("No LLM API key configured, skipping content generation")
return
for plan in plans:
try:
_generate_for_plan(plan, db, llm_setting, api_key_setting, model_setting)
except Exception as e:
logger.error(f"Failed to generate for plan {plan.id}: {e}")
finally:
db.close()
def _generate_for_plan(plan, db, provider_name, api_key, model):
"""Generate content for a single plan."""
from .models import Character
character = db.query(Character).filter(Character.id == plan.character_id).first()
if not character:
return
provider = get_llm_provider(provider_name, api_key, model)
char_dict = {
"name": character.name,
"niche": character.niche,
"topics": character.topics or [],
"tone": character.tone or "",
}
for platform in plan.platforms or []:
for content_type in plan.content_types or ["text"]:
text = generate_post_text(char_dict, provider, platform)
hashtags = generate_hashtags(text, provider, platform)
post = Post(
character_id=character.id,
content_type=content_type,
text_content=text,
hashtags=hashtags,
llm_provider=provider_name,
llm_model=model,
platform_hint=platform,
status="draft",
)
db.add(post)
db.commit()
def _get_setting(db, key, default=None):
"""Get a system setting value."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if setting:
return setting.value
return default

320
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,320 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# === Auth ===
class LoginRequest(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# === Characters ===
class CharacterBase(BaseModel):
name: str
niche: str
topics: list[str] = []
tone: Optional[str] = None
visual_style: dict = {}
social_accounts: dict = {}
affiliate_links: list[dict] = []
avatar_url: Optional[str] = None
is_active: bool = True
class CharacterCreate(CharacterBase):
pass
class CharacterUpdate(BaseModel):
name: Optional[str] = None
niche: Optional[str] = None
topics: Optional[list[str]] = None
tone: Optional[str] = None
visual_style: Optional[dict] = None
social_accounts: Optional[dict] = None
affiliate_links: Optional[list[dict]] = None
avatar_url: Optional[str] = None
is_active: Optional[bool] = None
class CharacterResponse(CharacterBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# === Posts / Content ===
class PostCreate(BaseModel):
character_id: int
content_type: str = "text"
platform_hint: str = "instagram"
text_content: Optional[str] = None
hashtags: list[str] = []
image_url: Optional[str] = None
video_url: Optional[str] = None
media_urls: list[str] = []
affiliate_links_used: list[dict] = []
status: str = "draft"
class PostUpdate(BaseModel):
text_content: Optional[str] = None
hashtags: Optional[list[str]] = None
image_url: Optional[str] = None
video_url: Optional[str] = None
status: Optional[str] = None
affiliate_links_used: Optional[list[dict]] = None
class PostResponse(BaseModel):
id: int
character_id: int
content_type: str
text_content: Optional[str] = None
hashtags: list[str] = []
image_url: Optional[str] = None
video_url: Optional[str] = None
media_urls: list[str] = []
affiliate_links_used: list[dict] = []
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
platform_hint: Optional[str] = None
status: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class GenerateContentRequest(BaseModel):
character_id: int
platform: str = "instagram"
content_type: str = "text"
topic_hint: Optional[str] = None
include_affiliates: bool = True
provider: Optional[str] = None # override default LLM
model: Optional[str] = None
class GenerateImageRequest(BaseModel):
character_id: int
prompt: Optional[str] = None # auto-generated if not provided
style_hint: Optional[str] = None
size: str = "1024x1024"
provider: Optional[str] = None # dalle, replicate
# === Affiliate Links ===
class AffiliateLinkBase(BaseModel):
character_id: Optional[int] = None
network: str
name: str
url: str
tag: Optional[str] = None
topics: list[str] = []
is_active: bool = True
class AffiliateLinkCreate(AffiliateLinkBase):
pass
class AffiliateLinkUpdate(BaseModel):
network: Optional[str] = None
name: Optional[str] = None
url: Optional[str] = None
tag: Optional[str] = None
topics: Optional[list[str]] = None
is_active: Optional[bool] = None
class AffiliateLinkResponse(AffiliateLinkBase):
id: int
click_count: int = 0
created_at: datetime
class Config:
from_attributes = True
# === Editorial Plans ===
class EditorialPlanBase(BaseModel):
character_id: int
name: str
frequency: str = "daily"
posts_per_day: int = 1
platforms: list[str] = []
content_types: list[str] = ["text"]
posting_times: list[str] = ["09:00"]
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
is_active: bool = False
class EditorialPlanCreate(EditorialPlanBase):
pass
class EditorialPlanUpdate(BaseModel):
name: Optional[str] = None
frequency: Optional[str] = None
posts_per_day: Optional[int] = None
platforms: Optional[list[str]] = None
content_types: Optional[list[str]] = None
posting_times: Optional[list[str]] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
is_active: Optional[bool] = None
class EditorialPlanResponse(EditorialPlanBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# === Scheduled Posts ===
class ScheduledPostCreate(BaseModel):
plan_id: Optional[int] = None
post_id: int
platform: str
scheduled_at: datetime
class ScheduledPostResponse(BaseModel):
id: int
plan_id: Optional[int] = None
post_id: int
platform: str
scheduled_at: datetime
published_at: Optional[datetime] = None
status: str
error_message: Optional[str] = None
external_post_id: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# === Social Accounts ===
class SocialAccountCreate(BaseModel):
character_id: int
platform: str
account_name: Optional[str] = None
account_id: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
page_id: Optional[str] = None
extra_data: dict = {}
class SocialAccountUpdate(BaseModel):
account_name: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
page_id: Optional[str] = None
extra_data: Optional[dict] = None
is_active: Optional[bool] = None
class SocialAccountResponse(BaseModel):
id: int
character_id: int
platform: str
account_name: Optional[str] = None
account_id: Optional[str] = None
page_id: Optional[str] = None
is_active: bool
token_expires_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
# === Comments ===
class CommentResponse(BaseModel):
id: int
scheduled_post_id: Optional[int] = None
platform: str
external_comment_id: Optional[str] = None
author_name: Optional[str] = None
content: Optional[str] = None
ai_suggested_reply: Optional[str] = None
approved_reply: Optional[str] = None
reply_status: str
replied_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class CommentAction(BaseModel):
action: str # approve, edit, ignore
reply_text: Optional[str] = None # for edit action
# === System Settings ===
class SettingUpdate(BaseModel):
key: str
value: dict | str | list | int | bool | None
class SettingResponse(BaseModel):
key: str
value: dict | str | list | int | bool | None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# === Editorial Calendar (from PostGenerator) ===
class CalendarGenerateRequest(BaseModel):
topics: list[str]
format_narrativo: Optional[str] = None # PAS, AIDA, BAB, Storytelling, Listicle, Dato_Implicazione
awareness_level: Optional[int] = None # 1-5 (Schwartz levels)
num_posts: int = 7
start_date: Optional[str] = None # YYYY-MM-DD
character_id: Optional[int] = None
class CalendarSlotResponse(BaseModel):
indice: int
topic: str
formato_narrativo: str
awareness_level: int
awareness_label: str
data_pubblicazione: str
note: Optional[str] = None
class CalendarResponse(BaseModel):
slots: list[CalendarSlotResponse]
totale_post: int

View File

View File

@@ -0,0 +1,168 @@
"""CalendarService — genera il calendario editoriale con awareness levels (Schwartz).
Versione adattata per Leopost Full (standalone, senza dipendenze da postgenerator).
Genera un piano di pubblicazione con:
- Formati narrativi: PAS, AIDA, BAB, Storytelling, Listicle, Dato_Implicazione
- Awareness levels (Schwartz): 1-Unaware, 2-Problem Aware, 3-Solution Aware,
4-Product Aware, 5-Most Aware
- Date di pubblicazione suggerite
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Optional
# ---------------------------------------------------------------------------
# Costanti
# ---------------------------------------------------------------------------
FORMATI_NARRATIVI = [
"PAS",
"AIDA",
"BAB",
"Storytelling",
"Listicle",
"Dato_Implicazione",
]
AWARENESS_LEVELS = {
1: "Unaware",
2: "Problem Aware",
3: "Solution Aware",
4: "Product Aware",
5: "Most Aware",
}
# Mapping formato narrativo -> awareness levels consigliati
_FORMATO_TO_LEVELS: dict[str, list[int]] = {
"PAS": [2, 3],
"AIDA": [3, 4, 5],
"BAB": [2, 3],
"Storytelling": [1, 2],
"Listicle": [2, 3],
"Dato_Implicazione": [1, 2, 3],
}
# Distribuzione default per generazione automatica
_DEFAULT_DISTRIBUTION = [
("Storytelling", 1),
("Dato_Implicazione", 2),
("PAS", 2),
("Listicle", 3),
("AIDA", 4),
("BAB", 3),
("AIDA", 5),
]
# ---------------------------------------------------------------------------
# CalendarService
# ---------------------------------------------------------------------------
class CalendarService:
"""Genera il calendario editoriale con awareness levels e formati narrativi."""
def generate_calendar(
self,
topics: list[str],
num_posts: int = 7,
format_narrativo: Optional[str] = None,
awareness_level: Optional[int] = None,
start_date: Optional[str] = None,
) -> list[dict]:
"""Genera un calendario editoriale."""
if start_date:
try:
data_inizio = date.fromisoformat(start_date)
except ValueError:
data_inizio = date.today()
else:
data_inizio = date.today()
dates = self._generate_dates(data_inizio, num_posts)
if format_narrativo and awareness_level:
distribution = [(format_narrativo, awareness_level)] * num_posts
elif format_narrativo:
levels = _FORMATO_TO_LEVELS.get(format_narrativo, [2, 3, 4])
distribution = [
(format_narrativo, levels[i % len(levels)])
for i in range(num_posts)
]
elif awareness_level:
compatible_formats = [
fmt for fmt, levels in _FORMATO_TO_LEVELS.items()
if awareness_level in levels
]
if not compatible_formats:
compatible_formats = FORMATI_NARRATIVI
distribution = [
(compatible_formats[i % len(compatible_formats)], awareness_level)
for i in range(num_posts)
]
else:
distribution = [
_DEFAULT_DISTRIBUTION[i % len(_DEFAULT_DISTRIBUTION)]
for i in range(num_posts)
]
slots = []
for i in range(num_posts):
topic = topics[i % len(topics)] if topics else f"Topic {i + 1}"
fmt, level = distribution[i]
slots.append({
"indice": i,
"topic": topic,
"formato_narrativo": fmt,
"awareness_level": level,
"awareness_label": AWARENESS_LEVELS.get(level, f"Level {level}"),
"data_pubblicazione": dates[i].isoformat(),
"note": self._generate_note(fmt, level),
})
return slots
def get_formats(self) -> list[dict]:
"""Ritorna la lista dei formati narrativi disponibili."""
return [
{
"value": fmt,
"label": fmt.replace("_", " "),
"awareness_levels": _FORMATO_TO_LEVELS.get(fmt, [2, 3]),
}
for fmt in FORMATI_NARRATIVI
]
@staticmethod
def _generate_dates(start: date, count: int) -> list[date]:
"""Genera date di pubblicazione (lun, mer, ven)."""
publish_days = [0, 2, 4]
dates = []
current = start
while current.weekday() not in publish_days:
current += timedelta(days=1)
while len(dates) < count:
if current.weekday() in publish_days:
dates.append(current)
current += timedelta(days=1)
return dates
@staticmethod
def _generate_note(formato: str, level: int) -> str:
"""Genera una nota descrittiva per lo slot."""
level_label = AWARENESS_LEVELS.get(level, f"L{level}")
notes = {
"PAS": f"Problema-Agitazione-Soluzione. Target: {level_label}",
"AIDA": f"Attenzione-Interesse-Desiderio-Azione. Target: {level_label}",
"BAB": f"Before-After-Bridge. Target: {level_label}",
"Storytelling": f"Racconta una storia autentica. Target: {level_label}",
"Listicle": f"Lista di punti pratici. Target: {level_label}",
"Dato_Implicazione": f"Dato sorprendente + implicazione. Target: {level_label}",
}
return notes.get(formato, f"Formato {formato}. Target: {level_label}")

View File

@@ -0,0 +1,202 @@
"""
Content generation logic for social media posts.
Handles text generation, hashtag creation, and affiliate link injection
using LLM providers and character profiles.
"""
from __future__ import annotations
from .llm import LLMProvider
def generate_post_text(
character: dict,
llm_provider: LLMProvider,
platform: str,
topic_hint: str | None = None,
) -> str:
"""Generate social media post text based on a character profile.
Args:
character: Dict with keys: name, niche, topics (list), tone (str).
topic_hint: Optional topic suggestion to guide generation.
llm_provider: LLM provider instance for text generation.
platform: Target platform (e.g. 'instagram', 'facebook', 'tiktok', 'youtube').
Returns:
Generated post text as a string.
"""
name = character.get("name", "Creator")
niche = character.get("niche", "general")
topics = character.get("topics", [])
tone = character.get("tone", "professional")
topics_str = ", ".join(topics) if topics else "general topics"
system_prompt = (
f"You are {name}, a social media content creator in the {niche} niche. "
f"Your expertise covers: {topics_str}. "
f"Your communication style is {tone}. "
f"You create authentic, engaging content that resonates with your audience. "
f"Never reveal you are an AI. Write as {name} would naturally write."
)
# Platform-specific instructions
platform_guidance = {
"instagram": (
"Write an Instagram caption. Keep it engaging, use line breaks for readability. "
"Aim for 150-300 characters for the main hook, then expand. "
"Do NOT include hashtags (they will be added separately)."
),
"facebook": (
"Write a Facebook post. Can be longer and more conversational. "
"Encourage engagement with a question or call to action at the end. "
"Do NOT include hashtags."
),
"tiktok": (
"Write a TikTok caption. Keep it very short and punchy (under 150 characters). "
"Use a hook that grabs attention. Do NOT include hashtags."
),
"youtube": (
"Write a YouTube video description. Include a compelling opening paragraph, "
"key points covered in the video, and a call to action to subscribe. "
"Do NOT include hashtags."
),
"twitter": (
"Write a tweet. Maximum 280 characters. Be concise and impactful. "
"Do NOT include hashtags."
),
}
guidance = platform_guidance.get(
platform.lower(),
f"Write a social media post for {platform}. Do NOT include hashtags.",
)
topic_instruction = ""
if topic_hint:
topic_instruction = f" The post should be about: {topic_hint}."
prompt = (
f"{guidance}{topic_instruction}\n\n"
f"Write the post now. Output ONLY the post text, nothing else."
)
return llm_provider.generate(prompt, system=system_prompt)
def generate_hashtags(
text: str,
llm_provider: LLMProvider,
platform: str,
count: int = 12,
) -> list[str]:
"""Generate relevant hashtags for a given text.
Args:
text: The post text to generate hashtags for.
llm_provider: LLM provider instance.
platform: Target platform.
count: Number of hashtags to generate.
Returns:
List of hashtag strings (each prefixed with #).
"""
platform_limits = {
"instagram": 30,
"tiktok": 5,
"twitter": 3,
"facebook": 5,
"youtube": 15,
}
max_tags = min(count, platform_limits.get(platform.lower(), count))
system_prompt = (
"You are a social media hashtag strategist. You generate relevant, "
"effective hashtags that maximize reach and engagement."
)
prompt = (
f"Generate exactly {max_tags} hashtags for the following {platform} post.\n\n"
f"Post text:\n{text}\n\n"
f"Rules:\n"
f"- Mix popular (high reach) and niche (targeted) hashtags\n"
f"- Each hashtag must start with #\n"
f"- No spaces within hashtags, use CamelCase for multi-word\n"
f"- Output ONLY the hashtags, one per line, nothing else"
)
result = llm_provider.generate(prompt, system=system_prompt)
# Parse hashtags from the response
hashtags: list[str] = []
for line in result.strip().splitlines():
tag = line.strip()
if not tag:
continue
# Ensure it starts with #
if not tag.startswith("#"):
tag = f"#{tag}"
# Remove any trailing punctuation or spaces
tag = tag.split()[0] # Take only the first word if extra text
hashtags.append(tag)
return hashtags[:max_tags]
def inject_affiliate_links(
text: str,
affiliate_links: list[dict],
topics: list[str],
) -> tuple[str, list[dict]]:
"""Find relevant affiliate links and append them to the post text.
Matches affiliate links based on topic overlap. Links whose keywords
overlap with the provided topics are appended naturally at the end.
Args:
text: Original post text.
affiliate_links: List of dicts, each with keys:
- url (str): The affiliate URL
- label (str): Display text for the link
- keywords (list[str]): Topic keywords this link is relevant for
topics: Current post topics to match against.
Returns:
Tuple of (modified_text, links_used) where links_used is the list
of affiliate link dicts that were injected.
"""
if not affiliate_links or not topics:
return text, []
# Normalize topics to lowercase for matching
topics_lower = {t.lower() for t in topics}
# Score each link by keyword overlap
scored_links: list[tuple[int, dict]] = []
for link in affiliate_links:
keywords = link.get("keywords", [])
keywords_lower = {k.lower() for k in keywords}
overlap = len(topics_lower & keywords_lower)
if overlap > 0:
scored_links.append((overlap, link))
if not scored_links:
return text, []
# Sort by relevance (most overlap first), take top 2
scored_links.sort(key=lambda x: x[0], reverse=True)
top_links = [link for _, link in scored_links[:2]]
# Build the links section
links_section_parts: list[str] = []
for link in top_links:
label = link.get("label", "Check this out")
url = link.get("url", "")
links_section_parts.append(f"{label}: {url}")
links_text = "\n".join(links_section_parts)
modified_text = f"{text}\n\n{links_text}"
return modified_text, top_links

View File

@@ -0,0 +1,181 @@
"""
Image generation abstraction layer.
Supports DALL-E (OpenAI) and Replicate (Stability AI SDXL) for image generation.
"""
import time
from abc import ABC, abstractmethod
import httpx
TIMEOUT = 120.0
POLL_INTERVAL = 2.0
MAX_POLL_ATTEMPTS = 60
class ImageProvider(ABC):
"""Abstract base class for image generation providers."""
def __init__(self, api_key: str, model: str | None = None):
self.api_key = api_key
self.model = model
@abstractmethod
def generate(self, prompt: str, size: str = "1024x1024") -> str:
"""Generate an image from a text prompt.
Args:
prompt: Text description of the image to generate.
size: Image dimensions as 'WIDTHxHEIGHT' string.
Returns:
URL of the generated image.
"""
...
class DallEProvider(ImageProvider):
"""OpenAI DALL-E 3 image generation provider."""
API_URL = "https://api.openai.com/v1/images/generations"
def __init__(self, api_key: str, model: str | None = None):
super().__init__(api_key, model or "dall-e-3")
def generate(self, prompt: str, size: str = "1024x1024") -> str:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self.model,
"prompt": prompt,
"n": 1,
"size": size,
"response_format": "url",
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(self.API_URL, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
return data["data"][0]["url"]
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"DALL-E API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"DALL-E API request failed: {e}") from e
class ReplicateProvider(ImageProvider):
"""Replicate image generation provider using Stability AI SDXL."""
API_URL = "https://api.replicate.com/v1/predictions"
def __init__(self, api_key: str, model: str | None = None):
super().__init__(api_key, model or "stability-ai/sdxl:latest")
def generate(self, prompt: str, size: str = "1024x1024") -> str:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# Parse size into width and height
try:
width, height = (int(d) for d in size.split("x"))
except ValueError:
width, height = 1024, 1024
# Determine the version string from the model
# Replicate expects "owner/model:version" or uses the version hash directly
version = self.model
payload = {
"version": version,
"input": {
"prompt": prompt,
"width": width,
"height": height,
},
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
# Create prediction
response = client.post(self.API_URL, headers=headers, json=payload)
response.raise_for_status()
prediction = response.json()
prediction_url = prediction.get("urls", {}).get("get")
if not prediction_url:
prediction_id = prediction.get("id")
prediction_url = f"{self.API_URL}/{prediction_id}"
# Poll for completion
for _ in range(MAX_POLL_ATTEMPTS):
poll_response = client.get(prediction_url, headers=headers)
poll_response.raise_for_status()
result = poll_response.json()
status = result.get("status")
if status == "succeeded":
output = result.get("output")
if isinstance(output, list) and output:
return output[0]
if isinstance(output, str):
return output
raise RuntimeError(
f"Replicate returned unexpected output format: {output}"
)
if status == "failed":
error = result.get("error", "Unknown error")
raise RuntimeError(f"Replicate prediction failed: {error}")
if status == "canceled":
raise RuntimeError("Replicate prediction was canceled")
time.sleep(POLL_INTERVAL)
raise RuntimeError(
"Replicate prediction timed out after polling"
)
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"Replicate API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"Replicate API request failed: {e}") from e
def get_image_provider(
provider_name: str, api_key: str, model: str | None = None
) -> ImageProvider:
"""Factory function to get an image generation provider instance.
Args:
provider_name: One of 'dalle', 'replicate'.
api_key: API key for the provider.
model: Optional model override. Uses default if not specified.
Returns:
An ImageProvider instance.
Raises:
ValueError: If provider_name is not supported.
"""
providers = {
"dalle": DallEProvider,
"replicate": ReplicateProvider,
}
provider_cls = providers.get(provider_name.lower())
if provider_cls is None:
supported = ", ".join(providers.keys())
raise ValueError(
f"Unknown image provider '{provider_name}'. Supported: {supported}"
)
return provider_cls(api_key=api_key, model=model)

194
backend/app/services/llm.py Normal file
View File

@@ -0,0 +1,194 @@
"""
Multi-LLM abstraction layer.
Supports Claude (Anthropic), OpenAI, and Gemini via direct HTTP calls using httpx.
Each provider implements the same interface for text generation.
"""
from abc import ABC, abstractmethod
import httpx
# Default models per provider
DEFAULT_MODELS = {
"claude": "claude-sonnet-4-20250514",
"openai": "gpt-4o-mini",
"gemini": "gemini-2.0-flash",
}
TIMEOUT = 60.0
class LLMProvider(ABC):
"""Abstract base class for LLM providers."""
def __init__(self, api_key: str, model: str | None = None):
self.api_key = api_key
self.model = model
@abstractmethod
def generate(self, prompt: str, system: str = "") -> str:
"""Generate text from a prompt.
Args:
prompt: The user prompt / message.
system: Optional system prompt for context and behavior.
Returns:
Generated text string.
"""
...
class ClaudeProvider(LLMProvider):
"""Anthropic Claude provider via Messages API."""
API_URL = "https://api.anthropic.com/v1/messages"
def __init__(self, api_key: str, model: str | None = None):
super().__init__(api_key, model or DEFAULT_MODELS["claude"])
def generate(self, prompt: str, system: str = "") -> str:
headers = {
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
payload: dict = {
"model": self.model,
"max_tokens": 2048,
"messages": [{"role": "user", "content": prompt}],
}
if system:
payload["system"] = system
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(self.API_URL, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
# Claude returns content as a list of content blocks
content_blocks = data.get("content", [])
return "".join(
block.get("text", "") for block in content_blocks if block.get("type") == "text"
)
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"Claude API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"Claude API request failed: {e}") from e
class OpenAIProvider(LLMProvider):
"""OpenAI provider via Chat Completions API."""
API_URL = "https://api.openai.com/v1/chat/completions"
def __init__(self, api_key: str, model: str | None = None):
super().__init__(api_key, model or DEFAULT_MODELS["openai"])
def generate(self, prompt: str, system: str = "") -> str:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
messages: list[dict] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
payload = {
"model": self.model,
"messages": messages,
"max_tokens": 2048,
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(self.API_URL, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"OpenAI API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"OpenAI API request failed: {e}") from e
class GeminiProvider(LLMProvider):
"""Google Gemini provider via Generative Language API."""
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
def __init__(self, api_key: str, model: str | None = None):
super().__init__(api_key, model or DEFAULT_MODELS["gemini"])
def generate(self, prompt: str, system: str = "") -> str:
url = f"{self.API_BASE}/{self.model}:generateContent"
params = {"key": self.api_key}
headers = {"Content-Type": "application/json"}
# Build contents; Gemini uses a parts-based structure
parts: list[dict] = []
if system:
parts.append({"text": f"{system}\n\n{prompt}"})
else:
parts.append({"text": prompt})
payload = {
"contents": [{"parts": parts}],
"generationConfig": {
"maxOutputTokens": 2048,
},
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(url, params=params, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
candidates = data.get("candidates", [])
if not candidates:
return ""
content = candidates[0].get("content", {})
parts_out = content.get("parts", [])
return "".join(part.get("text", "") for part in parts_out)
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"Gemini API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"Gemini API request failed: {e}") from e
def get_llm_provider(
provider_name: str, api_key: str, model: str | None = None
) -> LLMProvider:
"""Factory function to get an LLM provider instance.
Args:
provider_name: One of 'claude', 'openai', 'gemini'.
api_key: API key for the provider.
model: Optional model override. Uses default if not specified.
Returns:
An LLMProvider instance.
Raises:
ValueError: If provider_name is not supported.
"""
providers = {
"claude": ClaudeProvider,
"openai": OpenAIProvider,
"gemini": GeminiProvider,
}
provider_cls = providers.get(provider_name.lower())
if provider_cls is None:
supported = ", ".join(providers.keys())
raise ValueError(
f"Unknown LLM provider '{provider_name}'. Supported: {supported}"
)
return provider_cls(api_key=api_key, model=model)

View File

@@ -0,0 +1,169 @@
"""PromptService — carica, lista e compila prompt .txt con variabili.
Gestisce i file .txt dei prompt LLM nella directory PROMPTS_PATH.
Usa la sintassi {{variabile}} per i placeholder (doppia graffa).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
# Pattern per trovare le variabili {{nome}} nei template
_VARIABLE_PATTERN = re.compile(r"\{\{(\w+)\}\}")
class PromptService:
"""Servizio per gestire i prompt .txt del sistema di generazione.
Fornisce metodi per:
- Elencare i prompt disponibili
- Caricare il contenuto di un prompt
- Compilare un prompt sostituendo le variabili {{...}}
- Salvare un prompt (per l'editor di Phase 2)
- Estrarre la lista di variabili richieste da un template
"""
def __init__(self, prompts_dir: Path) -> None:
"""Inizializza il servizio con la directory dei prompt.
Args:
prompts_dir: Path alla directory contenente i file .txt dei prompt.
Tipicamente PROMPTS_PATH da backend.config.
Raises:
FileNotFoundError: Se la directory non esiste.
"""
if not prompts_dir.exists():
raise FileNotFoundError(
f"Directory prompt non trovata: {prompts_dir}. "
"Verifica che PROMPTS_PATH sia configurato correttamente."
)
if not prompts_dir.is_dir():
raise NotADirectoryError(
f"Il percorso non è una directory: {prompts_dir}"
)
self._prompts_dir = prompts_dir
def list_prompts(self) -> list[str]:
"""Elenca tutti i prompt .txt disponibili nella directory.
Returns:
Lista di nomi file senza estensione, ordinata alfabeticamente.
Es: ['aida_promozione', 'bab_storytelling', 'system_prompt', ...]
"""
return sorted(
p.stem for p in self._prompts_dir.glob("*.txt") if p.is_file()
)
def load_prompt(self, name: str) -> str:
"""Carica il contenuto grezzo di un prompt .txt.
Args:
name: Nome del prompt senza estensione (es. "pas_valore")
Returns:
Contenuto testuale del file prompt
Raises:
FileNotFoundError: Se il file non esiste
"""
path = self._get_path(name)
if not path.exists():
available = self.list_prompts()
raise FileNotFoundError(
f"Prompt '{name}' non trovato in {self._prompts_dir}. "
f"Prompt disponibili: {available}"
)
return path.read_text(encoding="utf-8")
def compile_prompt(self, name: str, variables: dict[str, str]) -> str:
"""Carica un prompt e sostituisce tutte le variabili {{nome}} con i valori forniti.
Args:
name: Nome del prompt senza estensione
variables: Dizionario { nome_variabile: valore }
Returns:
Testo del prompt con tutte le variabili sostituite
Raises:
FileNotFoundError: Se il prompt non esiste
ValueError: Se una variabile nel template non ha corrispondenza nel dict
"""
template = self.load_prompt(name)
# Verifica che tutte le variabili del template siano nel dict
required = set(_VARIABLE_PATTERN.findall(template))
provided = set(variables.keys())
missing = required - provided
if missing:
raise ValueError(
f"Variabili mancanti per il prompt '{name}': {sorted(missing)}. "
f"Fornire: {sorted(required)}"
)
def replace_var(match: re.Match) -> str:
var_name = match.group(1)
return variables[var_name]
return _VARIABLE_PATTERN.sub(replace_var, template)
def save_prompt(self, name: str, content: str) -> None:
"""Salva il contenuto di un prompt nel file .txt.
Usato dall'editor di prompt in Phase 2.
Args:
name: Nome del prompt senza estensione
content: Contenuto testuale da salvare
Raises:
ValueError: Se il nome contiene caratteri non sicuri
"""
# Sicurezza: validazione nome file (solo lettere, cifre, underscore, trattino)
if not re.match(r"^[\w\-]+$", name):
raise ValueError(
f"Nome prompt non valido: '{name}'. "
"Usa solo lettere, cifre, underscore e trattino."
)
path = self._get_path(name)
path.write_text(content, encoding="utf-8")
def get_required_variables(self, name: str) -> list[str]:
"""Analizza il template e ritorna la lista delle variabili richieste.
Args:
name: Nome del prompt senza estensione
Returns:
Lista ordinata di nomi variabile (senza doppie graffe)
Es: ['brand_name', 'livello_schwartz', 'obiettivo_campagna', 'target_nicchia', 'topic']
Raises:
FileNotFoundError: Se il prompt non esiste
"""
template = self.load_prompt(name)
variables = sorted(set(_VARIABLE_PATTERN.findall(template)))
return variables
def prompt_exists(self, name: str) -> bool:
"""Verifica se un prompt esiste.
Args:
name: Nome del prompt senza estensione
Returns:
True se il file esiste
"""
return self._get_path(name).exists()
# ---------------------------------------------------------------------------
# Metodi privati
# ---------------------------------------------------------------------------
def _get_path(self, name: str) -> Path:
"""Costruisce il percorso completo per un file prompt."""
return self._prompts_dir / f"{name}.txt"

View File

@@ -0,0 +1,706 @@
"""
Social media publishing abstraction layer.
Supports Facebook, Instagram, YouTube, and TikTok via their respective APIs.
All HTTP calls use httpx (sync).
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
import httpx
TIMEOUT = 120.0
UPLOAD_TIMEOUT = 300.0
class SocialPublisher(ABC):
"""Abstract base class for social media publishers."""
@abstractmethod
def publish_text(self, text: str, **kwargs) -> str:
"""Publish a text-only post.
Returns:
External post ID from the platform.
"""
...
@abstractmethod
def publish_image(self, text: str, image_url: str, **kwargs) -> str:
"""Publish an image post with caption.
Returns:
External post ID from the platform.
"""
...
@abstractmethod
def publish_video(self, text: str, video_path: str, **kwargs) -> str:
"""Publish a video post with caption.
Returns:
External post ID from the platform.
"""
...
@abstractmethod
def get_comments(self, post_id: str) -> list[dict]:
"""Get comments on a post.
Returns:
List of comment dicts with at least 'id', 'text', 'author' keys.
"""
...
@abstractmethod
def reply_to_comment(self, comment_id: str, text: str) -> bool:
"""Reply to a specific comment.
Returns:
True if reply was successful, False otherwise.
"""
...
class FacebookPublisher(SocialPublisher):
"""Facebook Page publishing via Graph API.
Required API setup:
- Create a Facebook App at https://developers.facebook.com
- Request 'pages_manage_posts', 'pages_read_engagement' permissions
- Get a Page Access Token (long-lived recommended)
- The page_id is the Facebook Page ID (numeric)
"""
GRAPH_API_BASE = "https://graph.facebook.com/v18.0"
def __init__(self, access_token: str, page_id: str):
self.access_token = access_token
self.page_id = page_id
def _request(
self,
method: str,
endpoint: str,
params: dict | None = None,
data: dict | None = None,
files: dict | None = None,
timeout: float = TIMEOUT,
) -> dict:
"""Make a request to the Facebook Graph API."""
url = f"{self.GRAPH_API_BASE}{endpoint}"
if params is None:
params = {}
params["access_token"] = self.access_token
try:
with httpx.Client(timeout=timeout) as client:
if method == "GET":
response = client.get(url, params=params)
elif files:
response = client.post(url, params=params, data=data, files=files)
else:
response = client.post(url, params=params, json=data)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"Facebook API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"Facebook API request failed: {e}") from e
def publish_text(self, text: str, **kwargs) -> str:
data = {"message": text}
result = self._request("POST", f"/{self.page_id}/feed", data=data)
return result.get("id", "")
def publish_image(self, text: str, image_url: str, **kwargs) -> str:
data = {
"message": text,
"url": image_url,
}
result = self._request("POST", f"/{self.page_id}/photos", data=data)
return result.get("id", "")
def publish_video(self, text: str, video_path: str, **kwargs) -> str:
with open(video_path, "rb") as video_file:
files = {"source": ("video.mp4", video_file, "video/mp4")}
form_data = {"description": text}
result = self._request(
"POST",
f"/{self.page_id}/videos",
data=form_data,
files=files,
timeout=UPLOAD_TIMEOUT,
)
return result.get("id", "")
def get_comments(self, post_id: str) -> list[dict]:
result = self._request("GET", f"/{post_id}/comments")
comments = []
for item in result.get("data", []):
comments.append({
"id": item.get("id", ""),
"text": item.get("message", ""),
"author": item.get("from", {}).get("name", "Unknown"),
"created_at": item.get("created_time", ""),
})
return comments
def reply_to_comment(self, comment_id: str, text: str) -> bool:
try:
self._request("POST", f"/{comment_id}/comments", data={"message": text})
return True
except RuntimeError:
return False
class InstagramPublisher(SocialPublisher):
"""Instagram publishing via Instagram Graph API (Business/Creator accounts).
Required API setup:
- Facebook App with Instagram Graph API enabled
- Instagram Business or Creator account linked to a Facebook Page
- Permissions: 'instagram_basic', 'instagram_content_publish'
- ig_user_id is the Instagram Business Account ID (from Facebook Graph API)
- Note: Text-only posts are not supported by Instagram API
"""
GRAPH_API_BASE = "https://graph.facebook.com/v18.0"
def __init__(self, access_token: str, ig_user_id: str):
self.access_token = access_token
self.ig_user_id = ig_user_id
def _request(
self,
method: str,
endpoint: str,
params: dict | None = None,
data: dict | None = None,
timeout: float = TIMEOUT,
) -> dict:
"""Make a request to the Instagram Graph API."""
url = f"{self.GRAPH_API_BASE}{endpoint}"
if params is None:
params = {}
params["access_token"] = self.access_token
try:
with httpx.Client(timeout=timeout) as client:
if method == "GET":
response = client.get(url, params=params)
else:
response = client.post(url, params=params, json=data)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"Instagram API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"Instagram API request failed: {e}") from e
def publish_text(self, text: str, **kwargs) -> str:
"""Instagram does not support text-only posts.
Raises RuntimeError. Use publish_image or publish_video instead.
"""
raise RuntimeError(
"Instagram does not support text-only posts. "
"Use publish_image() or publish_video() instead."
)
def publish_image(self, text: str, image_url: str, **kwargs) -> str:
# Step 1: Create media container
container_data = {
"image_url": image_url,
"caption": text,
}
container = self._request(
"POST", f"/{self.ig_user_id}/media", data=container_data
)
container_id = container.get("id", "")
if not container_id:
raise RuntimeError("Failed to create Instagram media container")
# Step 2: Publish the container
publish_data = {"creation_id": container_id}
result = self._request(
"POST", f"/{self.ig_user_id}/media_publish", data=publish_data
)
return result.get("id", "")
def publish_video(self, text: str, video_path: str, **kwargs) -> str:
"""Publish a video as a Reel on Instagram.
Note: video_path should be a publicly accessible URL for Instagram API.
For local files, upload to a hosting service first and pass the URL.
"""
video_url = kwargs.get("video_url", video_path)
# Step 1: Create media container for Reel
container_data = {
"media_type": "REELS",
"video_url": video_url,
"caption": text,
}
container = self._request(
"POST", f"/{self.ig_user_id}/media", data=container_data
)
container_id = container.get("id", "")
if not container_id:
raise RuntimeError("Failed to create Instagram video container")
# Step 2: Wait for video processing (poll status)
for _ in range(60):
status = self._request(
"GET",
f"/{container_id}",
params={"fields": "status_code"},
)
status_code = status.get("status_code", "")
if status_code == "FINISHED":
break
if status_code == "ERROR":
raise RuntimeError("Instagram video processing failed")
time.sleep(5)
else:
raise RuntimeError("Instagram video processing timed out")
# Step 3: Publish
publish_data = {"creation_id": container_id}
result = self._request(
"POST", f"/{self.ig_user_id}/media_publish", data=publish_data
)
return result.get("id", "")
def get_comments(self, post_id: str) -> list[dict]:
result = self._request(
"GET",
f"/{post_id}/comments",
params={"fields": "id,text,username,timestamp"},
)
comments = []
for item in result.get("data", []):
comments.append({
"id": item.get("id", ""),
"text": item.get("text", ""),
"author": item.get("username", "Unknown"),
"created_at": item.get("timestamp", ""),
})
return comments
def reply_to_comment(self, comment_id: str, text: str) -> bool:
try:
self._request(
"POST",
f"/{comment_id}/replies",
data={"message": text},
)
return True
except RuntimeError:
return False
class YouTubePublisher(SocialPublisher):
"""YouTube publishing via YouTube Data API v3.
Required API setup:
- Google Cloud project with YouTube Data API v3 enabled
- OAuth 2.0 credentials (access_token from OAuth flow)
- Scopes: 'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube.force-ssl'
- Note: Uploads require OAuth, not just an API key
"""
API_BASE = "https://www.googleapis.com"
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
def __init__(self, access_token: str):
self.access_token = access_token
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.access_token}"}
def publish_text(self, text: str, **kwargs) -> str:
"""YouTube does not support text-only posts via Data API.
Consider using YouTube Community Posts API if available.
"""
raise RuntimeError(
"YouTube does not support text-only posts via the Data API. "
"Use publish_video() instead."
)
def publish_image(self, text: str, image_url: str, **kwargs) -> str:
"""YouTube does not support image-only posts via Data API."""
raise RuntimeError(
"YouTube does not support image posts via the Data API. "
"Use publish_video() instead."
)
def publish_video(self, text: str, video_path: str, **kwargs) -> str:
"""Upload a video to YouTube using resumable upload.
Args:
text: Video description.
video_path: Path to the video file.
**kwargs: Additional options:
- title (str): Video title (default: first 100 chars of text)
- tags (list[str]): Video tags
- privacy (str): 'public', 'unlisted', or 'private' (default: 'public')
- category_id (str): YouTube category ID (default: '22' for People & Blogs)
"""
title = kwargs.get("title", text[:100])
tags = kwargs.get("tags", [])
privacy = kwargs.get("privacy", "public")
category_id = kwargs.get("category_id", "22")
# Step 1: Initialize resumable upload
metadata = {
"snippet": {
"title": title,
"description": text,
"tags": tags,
"categoryId": category_id,
},
"status": {
"privacyStatus": privacy,
},
}
headers = self._headers()
headers["Content-Type"] = "application/json"
try:
with httpx.Client(timeout=UPLOAD_TIMEOUT) as client:
# Init resumable upload
init_response = client.post(
self.UPLOAD_URL,
params={
"uploadType": "resumable",
"part": "snippet,status",
},
headers=headers,
json=metadata,
)
init_response.raise_for_status()
upload_url = init_response.headers.get("location")
if not upload_url:
raise RuntimeError(
"YouTube API did not return a resumable upload URL"
)
# Step 2: Upload the video file
with open(video_path, "rb") as video_file:
video_data = video_file.read()
upload_headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "video/*",
"Content-Length": str(len(video_data)),
}
upload_response = client.put(
upload_url,
headers=upload_headers,
content=video_data,
)
upload_response.raise_for_status()
result = upload_response.json()
return result.get("id", "")
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"YouTube API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"YouTube API request failed: {e}") from e
def get_comments(self, post_id: str) -> list[dict]:
"""Get comment threads for a video.
Args:
post_id: YouTube video ID.
"""
url = f"{self.API_BASE}/youtube/v3/commentThreads"
params = {
"part": "snippet",
"videoId": post_id,
"maxResults": 100,
"order": "time",
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.get(url, headers=self._headers(), params=params)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"YouTube API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"YouTube API request failed: {e}") from e
comments = []
for item in data.get("items", []):
snippet = item.get("snippet", {}).get("topLevelComment", {}).get("snippet", {})
comments.append({
"id": item.get("snippet", {}).get("topLevelComment", {}).get("id", ""),
"text": snippet.get("textDisplay", ""),
"author": snippet.get("authorDisplayName", "Unknown"),
"created_at": snippet.get("publishedAt", ""),
})
return comments
def reply_to_comment(self, comment_id: str, text: str) -> bool:
"""Reply to a YouTube comment.
Args:
comment_id: The parent comment ID to reply to.
text: Reply text.
"""
url = f"{self.API_BASE}/youtube/v3/comments"
params = {"part": "snippet"}
payload = {
"snippet": {
"parentId": comment_id,
"textOriginal": text,
}
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(
url, headers=self._headers(), params=params, json=payload
)
response.raise_for_status()
return True
except (httpx.HTTPStatusError, httpx.RequestError):
return False
class TikTokPublisher(SocialPublisher):
"""TikTok publishing via Content Posting API.
Required API setup:
- Register app at https://developers.tiktok.com
- Apply for 'Content Posting API' access
- OAuth 2.0 flow to get access_token
- Scopes: 'video.publish', 'video.upload'
- Note: TikTok API access requires app review and approval
- Text-only and image posts are not supported via API
"""
API_BASE = "https://open.tiktokapis.com/v2"
def __init__(self, access_token: str):
self.access_token = access_token
def _headers(self) -> dict:
return {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
def publish_text(self, text: str, **kwargs) -> str:
"""TikTok does not support text-only posts via API."""
raise RuntimeError(
"TikTok does not support text-only posts via the Content Posting API. "
"Use publish_video() instead."
)
def publish_image(self, text: str, image_url: str, **kwargs) -> str:
"""TikTok image posting is limited. Use publish_video instead."""
raise RuntimeError(
"TikTok image posting is not widely supported via the API. "
"Use publish_video() instead."
)
def publish_video(self, text: str, video_path: str, **kwargs) -> str:
"""Publish a video to TikTok using the Content Posting API.
Args:
text: Video caption/description.
video_path: Path to the video file.
**kwargs: Additional options:
- privacy_level (str): 'PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS',
'FOLLOWER_OF_CREATOR', 'SELF_ONLY' (default: 'PUBLIC_TO_EVERYONE')
- disable_comment (bool): Disable comments (default: False)
- disable_duet (bool): Disable duet (default: False)
- disable_stitch (bool): Disable stitch (default: False)
"""
privacy_level = kwargs.get("privacy_level", "PUBLIC_TO_EVERYONE")
disable_comment = kwargs.get("disable_comment", False)
disable_duet = kwargs.get("disable_duet", False)
disable_stitch = kwargs.get("disable_stitch", False)
# Get file size for chunk upload
import os
file_size = os.path.getsize(video_path)
# Step 1: Initialize video upload
init_url = f"{self.API_BASE}/post/publish/video/init/"
init_payload = {
"post_info": {
"title": text[:150], # TikTok title limit
"privacy_level": privacy_level,
"disable_comment": disable_comment,
"disable_duet": disable_duet,
"disable_stitch": disable_stitch,
},
"source_info": {
"source": "FILE_UPLOAD",
"video_size": file_size,
"chunk_size": file_size, # Single chunk upload
"total_chunk_count": 1,
},
}
try:
with httpx.Client(timeout=UPLOAD_TIMEOUT) as client:
init_response = client.post(
init_url, headers=self._headers(), json=init_payload
)
init_response.raise_for_status()
init_data = init_response.json()
publish_id = init_data.get("data", {}).get("publish_id", "")
upload_url = init_data.get("data", {}).get("upload_url", "")
if not upload_url:
raise RuntimeError(
f"TikTok API did not return upload URL: {init_data}"
)
# Step 2: Upload the video file
with open(video_path, "rb") as video_file:
video_data = video_file.read()
upload_headers = {
"Content-Type": "video/mp4",
"Content-Range": f"bytes 0-{file_size - 1}/{file_size}",
}
upload_response = client.put(
upload_url,
headers=upload_headers,
content=video_data,
)
upload_response.raise_for_status()
return publish_id
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"TikTok API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"TikTok API request failed: {e}") from e
def get_comments(self, post_id: str) -> list[dict]:
"""Get comments on a TikTok video.
Note: Requires 'video.list' scope and comment read access.
"""
url = f"{self.API_BASE}/comment/list/"
payload = {
"video_id": post_id,
"max_count": 50,
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(url, headers=self._headers(), json=payload)
response.raise_for_status()
data = response.json()
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"TikTok API error {e.response.status_code}: {e.response.text}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"TikTok API request failed: {e}") from e
comments = []
for item in data.get("data", {}).get("comments", []):
comments.append({
"id": item.get("id", ""),
"text": item.get("text", ""),
"author": item.get("user", {}).get("display_name", "Unknown"),
"created_at": str(item.get("create_time", "")),
})
return comments
def reply_to_comment(self, comment_id: str, text: str) -> bool:
"""Reply to a TikTok comment.
Note: Comment reply functionality may be limited depending on API access level.
"""
url = f"{self.API_BASE}/comment/reply/"
payload = {
"comment_id": comment_id,
"text": text,
}
try:
with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(url, headers=self._headers(), json=payload)
response.raise_for_status()
return True
except (httpx.HTTPStatusError, httpx.RequestError):
return False
def get_publisher(
platform: str, access_token: str, **kwargs
) -> SocialPublisher:
"""Factory function to get a social media publisher instance.
Args:
platform: One of 'facebook', 'instagram', 'youtube', 'tiktok'.
access_token: OAuth access token for the platform.
**kwargs: Additional platform-specific arguments:
- facebook: page_id (str) - required
- instagram: ig_user_id (str) - required
Returns:
A SocialPublisher instance.
Raises:
ValueError: If platform is not supported or required kwargs are missing.
"""
platform_lower = platform.lower()
if platform_lower == "facebook":
page_id = kwargs.get("page_id")
if not page_id:
raise ValueError("FacebookPublisher requires 'page_id' parameter")
return FacebookPublisher(access_token=access_token, page_id=page_id)
elif platform_lower == "instagram":
ig_user_id = kwargs.get("ig_user_id")
if not ig_user_id:
raise ValueError("InstagramPublisher requires 'ig_user_id' parameter")
return InstagramPublisher(access_token=access_token, ig_user_id=ig_user_id)
elif platform_lower == "youtube":
return YouTubePublisher(access_token=access_token)
elif platform_lower == "tiktok":
return TikTokPublisher(access_token=access_token)
else:
supported = "facebook, instagram, youtube, tiktok"
raise ValueError(
f"Unknown platform '{platform}'. Supported: {supported}"
)

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
bcrypt==4.2.0
httpx==0.27.2
python-multipart==0.0.12

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
app:
build: .
container_name: lab-leopost-full-app
restart: unless-stopped
volumes:
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:///./data/leopost.db
networks:
- proxy_net
deploy:
resources:
limits:
memory: 1024M
cpus: '1.0'
networks:
proxy_net:
external: true

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leopost Full</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,600;0,700;1,400&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "leopost-full",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"vite": "^5.4.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

53
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,53 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './AuthContext'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import LoginPage from './components/LoginPage'
import Dashboard from './components/Dashboard'
import CharacterList from './components/CharacterList'
import CharacterForm from './components/CharacterForm'
import ContentPage from './components/ContentPage'
import ContentArchive from './components/ContentArchive'
import AffiliateList from './components/AffiliateList'
import AffiliateForm from './components/AffiliateForm'
import PlanList from './components/PlanList'
import PlanForm from './components/PlanForm'
import ScheduleView from './components/ScheduleView'
import SocialAccounts from './components/SocialAccounts'
import CommentsQueue from './components/CommentsQueue'
import SettingsPage from './components/SettingsPage'
import EditorialCalendar from './components/EditorialCalendar'
export default function App() {
return (
<BrowserRouter basename="/leopost-full">
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/characters" element={<CharacterList />} />
<Route path="/characters/new" element={<CharacterForm />} />
<Route path="/characters/:id/edit" element={<CharacterForm />} />
<Route path="/content" element={<ContentPage />} />
<Route path="/content/archive" element={<ContentArchive />} />
<Route path="/affiliates" element={<AffiliateList />} />
<Route path="/affiliates/new" element={<AffiliateForm />} />
<Route path="/affiliates/:id/edit" element={<AffiliateForm />} />
<Route path="/plans" element={<PlanList />} />
<Route path="/plans/new" element={<PlanForm />} />
<Route path="/plans/:id/edit" element={<PlanForm />} />
<Route path="/schedule" element={<ScheduleView />} />
<Route path="/social" element={<SocialAccounts />} />
<Route path="/comments" element={<CommentsQueue />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/editorial" element={<EditorialCalendar />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</AuthProvider>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,41 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { api } from './api'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
api.get('/auth/me')
.then((data) => setUser(data))
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false))
} else {
setLoading(false)
}
}, [])
const login = async (username, password) => {
const data = await api.post('/auth/login', { username, password })
localStorage.setItem('token', data.access_token)
const me = await api.get('/auth/me')
setUser(me)
}
const logout = () => {
localStorage.removeItem('token')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)

34
frontend/src/api.js Normal file
View File

@@ -0,0 +1,34 @@
const BASE_URL = '/leopost-full/api'
async function request(method, path, body = null) {
const headers = { 'Content-Type': 'application/json' }
const token = localStorage.getItem('token')
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : null,
})
if (res.status === 401) {
localStorage.removeItem('token')
window.location.href = '/leopost-full/login'
return
}
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: 'Request failed' }))
throw new Error(error.detail || 'Request failed')
}
if (res.status === 204) return null
return res.json()
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
}

View File

@@ -0,0 +1,306 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const SUGGESTED_NETWORKS = ['Amazon', 'ClickBank', 'ShareASale', 'CJ', 'Impact']
const EMPTY_FORM = {
character_id: '',
network: '',
name: '',
url: '',
tag: '',
topics: [],
is_active: true,
}
export default function AffiliateForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY_FORM)
const [characters, setCharacters] = useState([])
const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => {
if (isEdit) {
api.get(`/affiliates/${id}`)
.then((data) => {
setForm({
character_id: data.character_id ? String(data.character_id) : '',
network: data.network || '',
name: data.name || '',
url: data.url || '',
tag: data.tag || '',
topics: data.topics || [],
is_active: data.is_active ?? true,
})
})
.catch(() => setError('Link affiliato non trovato'))
.finally(() => setLoading(false))
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const addTopic = () => {
const topic = topicInput.trim()
if (topic && !form.topics.includes(topic)) {
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
}
setTopicInput('')
}
const removeTopic = (topic) => {
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTopic()
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSaving(true)
try {
const payload = {
...form,
character_id: form.character_id ? parseInt(form.character_id) : null,
}
if (isEdit) {
await api.put(`/affiliates/${id}`, payload)
} else {
await api.post('/affiliates/', payload)
}
navigate('/affiliates')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
{isEdit ? 'Modifica link affiliato' : 'Nuovo link affiliato'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna le informazioni del link' : 'Aggiungi un nuovo link affiliato'}
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Main info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni link
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Personaggio
<span className="text-slate-400 font-normal ml-1">(lascia vuoto per globale)</span>
</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Globale (tutti i personaggi)</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Network
</label>
<div className="space-y-2">
<input
type="text"
value={form.network}
onChange={(e) => handleChange('network', e.target.value)}
placeholder="Es. Amazon, ClickBank..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
<div className="flex flex-wrap gap-1.5">
{SUGGESTED_NETWORKS.map((net) => (
<button
key={net}
type="button"
onClick={() => handleChange('network', net)}
className={`text-xs px-2 py-1 rounded-lg transition-colors ${
form.network === net
? 'bg-brand-100 text-brand-700 border border-brand-200'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 border border-transparent'
}`}
>
{net}
</button>
))}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Corso Python, Hosting Premium..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
URL completo
</label>
<input
type="url"
value={form.url}
onChange={(e) => handleChange('url', e.target.value)}
placeholder="https://example.com/ref/..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Tag di tracciamento
</label>
<input
type="text"
value={form.tag}
onChange={(e) => handleChange('tag', e.target.value)}
placeholder="Es. ref-luigi, tag-2026..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic correlati
</h3>
<p className="text-xs text-slate-400 -mt-2">
I topic aiutano l'AI a scegliere il link giusto per ogni contenuto
</p>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown}
placeholder="Scrivi un topic e premi Invio"
className="flex-1 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea link'}
</button>
<button
type="button"
onClick={() => navigate('/affiliates')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,218 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
const networkColors = {
Amazon: 'bg-amber-50 text-amber-700',
ClickBank: 'bg-emerald-50 text-emerald-700',
ShareASale: 'bg-blue-50 text-blue-700',
CJ: 'bg-violet-50 text-violet-700',
Impact: 'bg-rose-50 text-rose-700',
}
export default function AffiliateList() {
const [links, setLinks] = useState([])
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
const [filterCharacter, setFilterCharacter] = useState('')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [linksData, charsData] = await Promise.all([
api.get('/affiliates/'),
api.get('/characters/'),
])
setLinks(linksData)
setCharacters(charsData)
} catch {
// silent
} finally {
setLoading(false)
}
}
const getCharacterName = (id) => {
if (!id) return 'Globale'
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const getNetworkColor = (network) => {
return networkColors[network] || 'bg-slate-100 text-slate-600'
}
const handleToggle = async (link) => {
try {
await api.put(`/affiliates/${link.id}`, { is_active: !link.is_active })
loadData()
} catch {
// silent
}
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare "${name}"?`)) return
try {
await api.delete(`/affiliates/${id}`)
loadData()
} catch {
// silent
}
}
const truncateUrl = (url) => {
if (!url) return '—'
if (url.length <= 50) return url
return url.substring(0, 50) + '...'
}
const filtered = links.filter((l) => {
if (filterCharacter === '') return true
if (filterCharacter === 'global') return !l.character_id
return String(l.character_id) === filterCharacter
})
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Link Affiliati</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i link affiliati per la monetizzazione
</p>
</div>
<Link
to="/affiliates/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo Link
</Link>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<select
value={filterCharacter}
onChange={(e) => setFilterCharacter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Tutti</option>
<option value="global">Globale</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<span className="flex items-center text-xs text-slate-400 ml-auto">
{filtered.length} link
</span>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun link affiliato</p>
<p className="text-slate-400 text-sm mt-1">
Aggiungi i tuoi primi link affiliati per monetizzare i contenuti
</p>
<Link
to="/affiliates/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea link affiliato
</Link>
</div>
) : (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Network</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Nome</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden md:table-cell">URL</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Tag</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider hidden lg:table-cell">Topic</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Personaggio</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Stato</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Click</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">Azioni</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filtered.map((link) => (
<tr key={link.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${getNetworkColor(link.network)}`}>
{link.network || '—'}
</span>
</td>
<td className="px-4 py-3 font-medium text-slate-700">{link.name}</td>
<td className="px-4 py-3 text-slate-500 hidden md:table-cell">
<span className="font-mono text-xs">{truncateUrl(link.url)}</span>
</td>
<td className="px-4 py-3 text-slate-500 hidden lg:table-cell">
<span className="font-mono text-xs">{link.tag || '—'}</span>
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<div className="flex flex-wrap gap-1">
{link.topics && link.topics.slice(0, 2).map((t, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded">
{t}
</span>
))}
{link.topics && link.topics.length > 2 && (
<span className="text-xs text-slate-400">+{link.topics.length - 2}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-slate-500 text-xs">
{getCharacterName(link.character_id)}
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block w-2 h-2 rounded-full ${link.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
</td>
<td className="px-4 py-3 text-center text-slate-500">
{link.click_count ?? 0}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Link
to={`/affiliates/${link.id}/edit`}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleToggle(link)}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
{link.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleDelete(link.id, link.name)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Elimina
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,331 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const EMPTY_FORM = {
name: '',
niche: '',
topics: [],
tone: '',
visual_style: { primary_color: '#f97316', secondary_color: '#1e293b', font: '' },
is_active: true,
}
export default function CharacterForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY_FORM)
const [topicInput, setTopicInput] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
if (isEdit) {
api.get(`/characters/${id}`)
.then((data) => {
setForm({
name: data.name || '',
niche: data.niche || '',
topics: data.topics || [],
tone: data.tone || '',
visual_style: {
primary_color: data.visual_style?.primary_color || '#f97316',
secondary_color: data.visual_style?.secondary_color || '#1e293b',
font: data.visual_style?.font || '',
},
is_active: data.is_active ?? true,
})
})
.catch(() => setError('Personaggio non trovato'))
.finally(() => setLoading(false))
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleStyleChange = (field, value) => {
setForm((prev) => ({
...prev,
visual_style: { ...prev.visual_style, [field]: value },
}))
}
const addTopic = () => {
const topic = topicInput.trim()
if (topic && !form.topics.includes(topic)) {
setForm((prev) => ({ ...prev, topics: [...prev.topics, topic] }))
}
setTopicInput('')
}
const removeTopic = (topic) => {
setForm((prev) => ({
...prev,
topics: prev.topics.filter((t) => t !== topic),
}))
}
const handleTopicKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTopic()
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSaving(true)
try {
if (isEdit) {
await api.put(`/characters/${id}`, form)
} else {
await api.post('/characters/', form)
}
navigate('/characters')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
{isEdit ? 'Modifica personaggio' : 'Nuovo personaggio'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna il profilo editoriale' : 'Crea un nuovo profilo editoriale'}
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome personaggio
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. TechGuru, FoodBlogger..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Niche / Settore
</label>
<input
type="text"
value={form.niche}
onChange={(e) => handleChange('niche', e.target.value)}
placeholder="Es. Tecnologia, Food, Fitness..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Tono di comunicazione
</label>
<textarea
value={form.tone}
onChange={(e) => handleChange('tone', e.target.value)}
placeholder="Descrivi lo stile di comunicazione: informale, professionale, ironico, motivazionale..."
rows={3}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm resize-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Topics */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Topic ricorrenti
</h3>
<div className="flex gap-2">
<input
type="text"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
onKeyDown={handleTopicKeyDown}
placeholder="Scrivi un topic e premi Invio"
className="flex-1 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={addTopic}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-sm font-medium transition-colors"
>
Aggiungi
</button>
</div>
{form.topics.length > 0 && (
<div className="flex flex-wrap gap-2">
{form.topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-brand-50 text-brand-700 rounded-lg text-sm"
>
{topic}
<button
type="button"
onClick={() => removeTopic(topic)}
className="text-brand-400 hover:text-brand-600"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Visual style */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Stile visivo
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore primario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.primary_color}
onChange={(e) => handleStyleChange('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Colore secondario
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={form.visual_style.secondary_color}
onChange={(e) => handleStyleChange('secondary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Font preferito
</label>
<input
type="text"
value={form.visual_style.font}
onChange={(e) => handleStyleChange('font', e.target.value)}
placeholder="Es. Montserrat, Poppins, Inter..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
{/* Preview */}
<div className="mt-4 p-4 rounded-lg border border-dashed border-slate-300">
<p className="text-xs text-slate-400 mb-2">Anteprima</p>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: form.visual_style.primary_color }}
>
{form.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div>
<p className="font-semibold text-sm" style={{ color: form.visual_style.secondary_color }}>
{form.name || 'Nome personaggio'}
</p>
<p className="text-xs text-slate-400">{form.niche || 'Niche'}</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea personaggio'}
</button>
<button
type="button"
onClick={() => navigate('/characters')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
export default function CharacterList() {
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadCharacters()
}, [])
const loadCharacters = () => {
setLoading(true)
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
.finally(() => setLoading(false))
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare "${name}"?`)) return
await api.delete(`/characters/${id}`)
loadCharacters()
}
const handleToggle = async (character) => {
await api.put(`/characters/${character.id}`, { is_active: !character.is_active })
loadCharacters()
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Personaggi</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i tuoi profili editoriali
</p>
</div>
<Link
to="/characters/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo
</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : characters.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun personaggio</p>
<p className="text-slate-400 text-sm mt-1">
Crea il tuo primo profilo editoriale
</p>
<Link
to="/characters/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea personaggio
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{characters.map((c) => (
<div
key={c.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden"
>
{/* Card header with color */}
<div
className="h-2"
style={{
backgroundColor: c.visual_style?.primary_color || '#f97316',
}}
/>
<div className="p-5">
<div className="flex items-start gap-3">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold text-lg shrink-0"
style={{
backgroundColor: c.visual_style?.primary_color || '#f97316',
}}
>
{c.name?.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-800 truncate">{c.name}</h3>
<p className="text-sm text-slate-500 truncate">{c.niche}</p>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full shrink-0 ${
c.is_active
? 'bg-emerald-50 text-emerald-600'
: 'bg-slate-100 text-slate-400'
}`}
>
{c.is_active ? 'Attivo' : 'Off'}
</span>
</div>
{/* Topics */}
{c.topics?.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{c.topics.slice(0, 4).map((t) => (
<span
key={t}
className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded"
>
{t}
</span>
))}
{c.topics.length > 4 && (
<span className="text-xs text-slate-400">
+{c.topics.length - 4}
</span>
)}
</div>
)}
{/* Tone preview */}
{c.tone && (
<p className="text-xs text-slate-400 mt-3 line-clamp-2 italic">
"{c.tone}"
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-slate-100">
<Link
to={`/characters/${c.id}/edit`}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleToggle(c)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
{c.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleDelete(c.id, c.name)}
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
Elimina
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,292 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const TAB_OPTIONS = [
{ value: 'pending', label: 'In Attesa' },
{ value: 'approved', label: 'Approvati' },
{ value: 'replied', label: 'Risposti' },
{ value: 'ignored', label: 'Ignorati' },
]
const platformColors = {
instagram: 'bg-pink-50 text-pink-600',
facebook: 'bg-blue-50 text-blue-600',
youtube: 'bg-red-50 text-red-600',
tiktok: 'bg-slate-800 text-white',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
export default function CommentsQueue() {
const [comments, setComments] = useState([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('pending')
const [counts, setCounts] = useState({})
const [editingReply, setEditingReply] = useState(null)
const [replyText, setReplyText] = useState('')
const [fetching, setFetching] = useState(false)
const [actionLoading, setActionLoading] = useState(null)
useEffect(() => {
loadComments()
}, [activeTab])
const loadComments = async () => {
setLoading(true)
try {
const data = await api.get(`/comments/?reply_status=${activeTab}`)
setComments(data)
// Also load counts for all tabs
loadCounts()
} catch {
setComments([])
} finally {
setLoading(false)
}
}
const loadCounts = async () => {
try {
const results = await Promise.all(
TAB_OPTIONS.map(async (tab) => {
const data = await api.get(`/comments/?reply_status=${tab.value}`)
return [tab.value, Array.isArray(data) ? data.length : 0]
})
)
setCounts(Object.fromEntries(results))
} catch {
// silent
}
}
const handleAction = async (commentId, action, body = null) => {
setActionLoading(commentId)
try {
if (action === 'approve') {
await api.post(`/comments/${commentId}/approve`)
} else if (action === 'ignore') {
await api.post(`/comments/${commentId}/ignore`)
} else if (action === 'edit') {
await api.put(`/comments/${commentId}`, { ai_reply: body })
setEditingReply(null)
} else if (action === 'reply') {
await api.post(`/comments/${commentId}/reply`)
}
loadComments()
} catch {
// silent
} finally {
setActionLoading(null)
}
}
const handleFetchComments = async () => {
setFetching(true)
try {
const platforms = ['instagram', 'facebook', 'youtube', 'tiktok']
await Promise.allSettled(
platforms.map((p) => api.post(`/comments/fetch/${p}`))
)
loadComments()
} catch {
// silent
} finally {
setFetching(false)
}
}
const startEditReply = (comment) => {
setEditingReply(comment.id)
setReplyText(comment.ai_reply || '')
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Commenti</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i commenti e le risposte AI
</p>
</div>
<button
onClick={handleFetchComments}
disabled={fetching}
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white text-sm font-medium rounded-lg transition-colors"
>
{fetching ? (
<span className="flex items-center gap-2">
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-white border-t-transparent" />
Aggiornamento...
</span>
) : (
'Aggiorna Commenti'
)}
</button>
</div>
{/* Tab bar */}
<div className="flex gap-1 mb-6 bg-slate-100 rounded-lg p-1 w-fit">
{TAB_OPTIONS.map((tab) => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${
activeTab === tab.value
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
{counts[tab.value] > 0 && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
activeTab === tab.value
? 'bg-brand-100 text-brand-700'
: 'bg-slate-200 text-slate-500'
}`}>
{counts[tab.value]}
</span>
)}
</button>
))}
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : comments.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun commento {TAB_OPTIONS.find(t => t.value === activeTab)?.label.toLowerCase()}</p>
<p className="text-slate-400 text-sm mt-1">
{activeTab === 'pending'
? 'Clicca "Aggiorna Commenti" per recuperare nuovi commenti'
: 'I commenti appariranno qui quando cambieranno stato'}
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div
key={comment.id}
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
>
<div className="p-5">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${platformColors[comment.platform] || 'bg-slate-100 text-slate-600'}`}>
{platformLabels[comment.platform] || comment.platform}
</span>
<span className="text-xs text-slate-400">da</span>
<span className="text-xs font-medium text-slate-700">
{comment.author_name || 'Utente'}
</span>
{comment.post_reference && (
<span className="text-xs text-slate-400 ml-auto truncate max-w-xs">
su: {comment.post_reference}
</span>
)}
</div>
{/* Comment text */}
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100 mb-3">
<p className="text-sm text-slate-700">
{comment.text}
</p>
</div>
{/* AI suggested reply */}
{comment.ai_reply && (
<div className="mb-3">
<p className="text-xs font-medium text-slate-400 mb-1">Risposta AI suggerita</p>
{editingReply === comment.id ? (
<div className="space-y-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
rows={3}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleAction(comment.id, 'edit', replyText)}
disabled={actionLoading === comment.id}
className="text-xs px-3 py-1.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
Salva
</button>
<button
onClick={() => setEditingReply(null)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Annulla
</button>
</div>
</div>
) : (
<div className="p-3 bg-brand-50 rounded-lg border border-brand-100">
<p className="text-sm text-brand-800 italic">
{comment.ai_reply}
</p>
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-slate-100">
{activeTab === 'pending' && (
<>
<button
onClick={() => handleAction(comment.id, 'approve')}
disabled={actionLoading === comment.id}
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium disabled:opacity-50"
>
Approva Risposta AI
</button>
<button
onClick={() => startEditReply(comment)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica Risposta
</button>
<button
onClick={() => handleAction(comment.id, 'ignore')}
disabled={actionLoading === comment.id}
className="text-xs px-3 py-1.5 text-slate-400 hover:bg-slate-100 rounded-lg transition-colors ml-auto disabled:opacity-50"
>
Ignora
</button>
</>
)}
{activeTab === 'approved' && (
<button
onClick={() => handleAction(comment.id, 'reply')}
disabled={actionLoading === comment.id}
className="text-xs px-3 py-1.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors font-medium disabled:opacity-50"
>
{actionLoading === comment.id ? 'Invio...' : 'Invia Risposta'}
</button>
)}
{(activeTab === 'replied' || activeTab === 'ignored') && (
<span className="text-xs text-slate-400 italic">
{activeTab === 'replied' ? 'Risposta inviata' : 'Commento ignorato'}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const statusLabels = {
draft: 'Bozza',
approved: 'Approvato',
scheduled: 'Schedulato',
published: 'Pubblicato',
}
const statusColors = {
draft: 'bg-amber-50 text-amber-600',
approved: 'bg-emerald-50 text-emerald-600',
scheduled: 'bg-blue-50 text-blue-600',
published: 'bg-violet-50 text-violet-600',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
export default function ContentArchive() {
const [posts, setPosts] = useState([])
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState(null)
const [filterCharacter, setFilterCharacter] = useState('')
const [filterStatus, setFilterStatus] = useState('')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [postsData, charsData] = await Promise.all([
api.get('/content/posts'),
api.get('/characters/'),
])
setPosts(postsData)
setCharacters(charsData)
} catch {
// silent
} finally {
setLoading(false)
}
}
const getCharacterName = (id) => {
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const handleApprove = async (postId) => {
try {
await api.post(`/content/posts/${postId}/approve`)
loadData()
} catch {
// silent
}
}
const handleDelete = async (postId) => {
if (!confirm('Eliminare questo contenuto?')) return
try {
await api.delete(`/content/posts/${postId}`)
loadData()
} catch {
// silent
}
}
const filtered = posts.filter((p) => {
if (filterCharacter && String(p.character_id) !== filterCharacter) return false
if (filterStatus && p.status !== filterStatus) return false
return true
})
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Archivio Contenuti</h2>
<p className="text-slate-500 mt-1 text-sm">
Tutti i contenuti generati
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<select
value={filterCharacter}
onChange={(e) => setFilterCharacter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Tutti i personaggi</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Tutti gli stati</option>
{Object.entries(statusLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
<span className="flex items-center text-xs text-slate-400 ml-auto">
{filtered.length} contenut{filtered.length === 1 ? 'o' : 'i'}
</span>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun contenuto trovato</p>
<p className="text-slate-400 text-sm mt-1">
{posts.length === 0
? 'Genera il tuo primo contenuto dalla pagina Contenuti'
: 'Prova a cambiare i filtri'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((post) => (
<div
key={post.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden cursor-pointer"
onClick={() => setExpandedId(expandedId === post.id ? null : post.id)}
>
<div className="p-5">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[post.status] || 'bg-slate-100 text-slate-500'}`}>
{statusLabels[post.status] || post.status}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
{platformLabels[post.platform] || post.platform}
</span>
</div>
{/* Character name */}
<p className="text-xs font-medium text-slate-500 mb-1">
{getCharacterName(post.character_id)}
</p>
{/* Text preview */}
<p className={`text-sm text-slate-700 ${expandedId === post.id ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>
{post.text}
</p>
{/* Expanded details */}
{expandedId === post.id && (
<div className="mt-3 space-y-2">
{post.hashtags && post.hashtags.length > 0 && (
<div className="flex flex-wrap gap-1">
{post.hashtags.map((tag, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded">
#{tag}
</span>
))}
</div>
)}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
<span className="text-xs text-slate-400">
{formatDate(post.created_at)}
</span>
<div className="flex items-center gap-1">
{post.hashtags && (
<span className="text-xs text-slate-400">
{post.hashtags.length} hashtag
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-slate-100"
onClick={(e) => e.stopPropagation()}
>
{post.status === 'draft' && (
<button
onClick={() => handleApprove(post.id)}
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium"
>
Approva
</button>
)}
<button
onClick={() => handleDelete(post.id)}
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
Elimina
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,334 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const cardStyle = {
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '0.75rem',
padding: '1.5rem',
}
const inputStyle = {
width: '100%',
padding: '0.625rem 1rem',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '0.875rem',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
outline: 'none',
}
export default function ContentPage() {
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [generated, setGenerated] = useState(null)
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState('')
const [form, setForm] = useState({
character_id: '',
platform: 'instagram',
content_type: 'text',
topic_hint: '',
include_affiliates: false,
})
useEffect(() => {
api.get('/characters/').then(setCharacters).catch(() => {})
}, [])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleGenerate = async (e) => {
e.preventDefault()
if (!form.character_id) {
setError('Seleziona un personaggio')
return
}
setError('')
setLoading(true)
setGenerated(null)
try {
const data = await api.post('/content/generate', {
character_id: parseInt(form.character_id),
platform: form.platform,
content_type: form.content_type,
topic_hint: form.topic_hint || null,
include_affiliates: form.include_affiliates,
})
setGenerated(data)
setEditText(data.text_content || '')
} catch (err) {
setError(err.message || 'Errore nella generazione')
} finally {
setLoading(false)
}
}
const handleApprove = async () => {
if (!generated) return
try {
await api.post(`/content/posts/${generated.id}/approve`)
setGenerated((prev) => ({ ...prev, status: 'approved' }))
} catch (err) {
setError(err.message || 'Errore approvazione')
}
}
const handleSaveEdit = async () => {
if (!generated) return
try {
await api.put(`/content/posts/${generated.id}`, { text_content: editText })
setGenerated((prev) => ({ ...prev, text_content: editText }))
setEditing(false)
} catch (err) {
setError(err.message || 'Errore salvataggio')
}
}
const handleDelete = async () => {
if (!generated) return
if (!confirm('Eliminare questo contenuto?')) return
try {
await api.delete(`/content/posts/${generated.id}`)
setGenerated(null)
} catch (err) {
setError(err.message || 'Errore eliminazione')
}
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
const contentTypeLabels = {
text: 'Testo',
image: 'Immagine',
video: 'Video',
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Contenuti</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Genera e gestisci contenuti per i tuoi personaggi
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Generation form */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Genera Contenuto
</h3>
<form onSubmit={handleGenerate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Personaggio</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', e.target.value)}
style={inputStyle}
required
>
<option value="">Seleziona personaggio...</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Piattaforma</label>
<select
value={form.platform}
onChange={(e) => handleChange('platform', e.target.value)}
style={inputStyle}
>
{Object.entries(platformLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Tipo contenuto</label>
<select
value={form.content_type}
onChange={(e) => handleChange('content_type', e.target.value)}
style={inputStyle}
>
{Object.entries(contentTypeLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Suggerimento tema <span className="font-normal" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<input
type="text"
value={form.topic_hint}
onChange={(e) => handleChange('topic_hint', e.target.value)}
placeholder="Es. ultimi trend, tutorial..."
style={inputStyle}
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.include_affiliates}
onChange={(e) => handleChange('include_affiliates', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-coral"></div>
</label>
<span className="text-sm" style={{ color: 'var(--ink)' }}>Includi link affiliati</span>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
Generazione in corso...
</span>
) : 'Genera'}
</button>
</form>
</div>
{/* Preview */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Ultimo Contenuto Generato
</h3>
{loading ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
<p className="text-sm mt-3" style={{ color: 'var(--muted)' }}>Generazione in corso...</p>
</div>
) : generated ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${
generated.status === 'approved' ? 'bg-emerald-50 text-emerald-600' :
generated.status === 'published' ? 'bg-blue-50 text-blue-600' :
'bg-amber-50 text-amber-600'
}`}>
{generated.status === 'approved' ? 'Approvato' :
generated.status === 'published' ? 'Pubblicato' : 'Bozza'}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
{platformLabels[generated.platform_hint] || generated.platform_hint}
</span>
</div>
{editing ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={8}
className="w-full px-4 py-2.5 rounded-lg text-sm resize-none focus:outline-none"
style={{ border: '1px solid var(--border)', color: 'var(--ink)' }}
/>
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
className="px-3 py-1.5 text-white text-xs rounded-lg"
style={{ backgroundColor: 'var(--coral)' }}
>
Salva
</button>
<button
onClick={() => { setEditing(false); setEditText(generated.text_content || '') }}
className="px-3 py-1.5 bg-slate-100 text-slate-600 text-xs rounded-lg"
>
Annulla
</button>
</div>
</div>
) : (
<div className="p-4 rounded-lg" style={{ backgroundColor: 'var(--cream)' }}>
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: 'var(--ink)' }}>
{generated.text_content}
</p>
</div>
)}
{generated.hashtags && generated.hashtags.length > 0 && (
<div>
<p className="text-xs font-medium mb-1.5" style={{ color: 'var(--muted)' }}>Hashtag</p>
<div className="flex flex-wrap gap-1.5">
{generated.hashtags.map((tag, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: '#FFF0EC', color: 'var(--coral)' }}>
{tag}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
{generated.status !== 'approved' && (
<button
onClick={handleApprove}
className="text-xs px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 text-emerald-600 rounded-lg transition-colors font-medium"
>
Approva
</button>
)}
{!editing && (
<button
onClick={() => setEditing(true)}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</button>
)}
<button
onClick={handleDelete}
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
Elimina
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-4xl mb-3"></p>
<p className="font-medium" style={{ color: 'var(--ink)' }}>Nessun contenuto generato</p>
<p className="text-sm mt-1" style={{ color: 'var(--muted)' }}>
Compila il form e clicca "Genera"
</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,201 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
export default function Dashboard() {
const [stats, setStats] = useState({
characters: 0,
active: 0,
posts: 0,
scheduled: 0,
pendingComments: 0,
affiliates: 0,
plans: 0,
})
const [recentPosts, setRecentPosts] = useState([])
const [providerStatus, setProviderStatus] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get('/characters/').catch(() => []),
api.get('/content/posts').catch(() => []),
api.get('/plans/scheduled').catch(() => []),
api.get('/comments/pending').catch(() => []),
api.get('/affiliates/').catch(() => []),
api.get('/plans/').catch(() => []),
api.get('/settings/providers/status').catch(() => null),
]).then(([chars, posts, scheduled, comments, affiliates, plans, providers]) => {
setStats({
characters: chars.length,
active: chars.filter((c) => c.is_active).length,
posts: posts.length,
scheduled: scheduled.filter((s) => s.status === 'pending').length,
pendingComments: comments.length,
affiliates: affiliates.length,
plans: plans.filter((p) => p.is_active).length,
})
setRecentPosts(posts.slice(0, 5))
setProviderStatus(providers)
setLoading(false)
})
}, [])
return (
<div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>
Dashboard
</h2>
<p className="text-sm mb-5" style={{ color: 'var(--muted)' }}>
Panoramica Leopost Full
</p>
{/* Stats grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-5">
<StatCard label="Personaggi" value={loading ? '—' : stats.characters} sub={`${stats.active} attivi`} accentColor="var(--coral)" />
<StatCard label="Post generati" value={loading ? '—' : stats.posts} accentColor="#3B82F6" />
<StatCard label="Schedulati" value={loading ? '—' : stats.scheduled} sub="in coda" accentColor="#10B981" />
<StatCard label="Commenti" value={loading ? '—' : stats.pendingComments} sub="in attesa" accentColor="#8B5CF6" />
<StatCard label="Link Affiliati" value={loading ? '—' : stats.affiliates} accentColor="#F59E0B" />
<StatCard label="Piani Attivi" value={loading ? '—' : stats.plans} accentColor="#14B8A6" />
</div>
{/* Provider status */}
{providerStatus && (
<div
className="mt-6 rounded-xl p-5"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--muted)' }}>
Stato Provider
</h3>
<div className="flex flex-wrap gap-3">
<ProviderBadge name="LLM" ok={providerStatus.llm?.configured} detail={providerStatus.llm?.provider} />
<ProviderBadge name="Immagini" ok={providerStatus.image?.configured} detail={providerStatus.image?.provider} />
<ProviderBadge name="Voiceover" ok={providerStatus.voice?.configured} />
{providerStatus.social && Object.entries(providerStatus.social).map(([k, v]) => (
<ProviderBadge key={k} name={k} ok={v} />
))}
</div>
{!providerStatus.llm?.configured && (
<p className="text-xs mt-2" style={{ color: 'var(--muted)' }}>
Configura le API key in{' '}
<Link to="/settings" style={{ color: 'var(--coral)' }} className="hover:underline">
Impostazioni
</Link>
</p>
)}
</div>
)}
{/* Quick actions */}
<div className="mt-6">
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--muted)' }}>
Azioni rapide
</h3>
<div className="flex flex-wrap gap-2">
<Link
to="/content"
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity hover:opacity-90"
style={{ backgroundColor: 'var(--coral)' }}
>
Genera contenuto
</Link>
<Link
to="/editorial"
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--ink)' }}
>
Calendario AI
</Link>
<Link
to="/characters/new"
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--ink)' }}
>
Nuovo personaggio
</Link>
<Link
to="/plans/new"
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--ink)' }}
>
Nuovo piano
</Link>
</div>
</div>
{/* Recent posts */}
{recentPosts.length > 0 && (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Post recenti
</h3>
<Link to="/content/archive" style={{ color: 'var(--coral)' }} className="text-xs hover:underline">
Vedi tutti
</Link>
</div>
<div className="space-y-2">
{recentPosts.map((p) => (
<div
key={p.id}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${statusColor(p.status)}`}>
{p.status}
</span>
<span className="text-xs" style={{ color: 'var(--muted)' }}>{p.platform_hint}</span>
<p className="text-sm truncate flex-1" style={{ color: 'var(--ink)' }}>
{p.text_content?.slice(0, 80)}...
</p>
</div>
))}
</div>
</div>
)}
</div>
)
}
function StatCard({ label, value, sub, accentColor }) {
return (
<div
className="rounded-xl p-4"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center gap-2 mb-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: accentColor }} />
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
{label}
</span>
</div>
<p className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>{value}</p>
{sub && <p className="text-[11px] mt-0.5" style={{ color: 'var(--muted)' }}>{sub}</p>}
</div>
)
}
function ProviderBadge({ name, ok, detail }) {
return (
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium ${
ok ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-400'
}`}>
<div className={`w-1.5 h-1.5 rounded-full ${ok ? 'bg-emerald-500' : 'bg-slate-300'}`} />
{name}
{detail && <span className="text-[10px] opacity-60">({detail})</span>}
</div>
)
}
function statusColor(s) {
const map = {
draft: 'bg-slate-100 text-slate-500',
approved: 'bg-blue-50 text-blue-600',
scheduled: 'bg-amber-50 text-amber-600',
published: 'bg-emerald-50 text-emerald-600',
failed: 'bg-red-50 text-red-600',
}
return map[s] || 'bg-slate-100 text-slate-500'
}

View File

@@ -0,0 +1,403 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const BASE_URL = '/leopost-full/api'
const cardStyle = {
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '0.75rem',
padding: '1.5rem',
}
const inputStyle = {
width: '100%',
padding: '0.625rem 1rem',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '0.875rem',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
outline: 'none',
}
const AWARENESS_LABELS = {
1: '1 — Unaware',
2: '2 — Problem Aware',
3: '3 — Solution Aware',
4: '4 — Product Aware',
5: '5 — Most Aware',
}
const FORMATO_COLORS = {
PAS: { bg: '#FFF0EC', color: 'var(--coral)' },
AIDA: { bg: '#EFF6FF', color: '#3B82F6' },
BAB: { bg: '#F0FDF4', color: '#16A34A' },
Storytelling: { bg: '#FDF4FF', color: '#9333EA' },
Listicle: { bg: '#FFFBEB', color: '#D97706' },
Dato_Implicazione: { bg: '#F0F9FF', color: '#0284C7' },
}
const AWARENESS_COLORS = {
1: { bg: '#FEF2F2', color: '#DC2626' },
2: { bg: '#FFF7ED', color: '#EA580C' },
3: { bg: '#FFFBEB', color: '#D97706' },
4: { bg: '#F0FDF4', color: '#16A34A' },
5: { bg: '#EFF6FF', color: '#2563EB' },
}
export default function EditorialCalendar() {
const [formats, setFormats] = useState([])
const [awarenessLevels, setAwarenessLevels] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [calendar, setCalendar] = useState(null)
const [exporting, setExporting] = useState(false)
const [form, setForm] = useState({
topics: '',
format_narrativo: '',
awareness_level: '',
num_posts: 7,
start_date: new Date().toISOString().split('T')[0],
})
useEffect(() => {
api.get('/editorial/formats')
.then((data) => {
setFormats(data.formats || [])
setAwarenessLevels(data.awareness_levels || [])
})
.catch(() => {})
}, [])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleGenerate = async (e) => {
e.preventDefault()
const topicsList = form.topics
.split(',')
.map((t) => t.trim())
.filter(Boolean)
if (topicsList.length === 0) {
setError('Inserisci almeno un topic/keyword')
return
}
setError('')
setLoading(true)
setCalendar(null)
try {
const payload = {
topics: topicsList,
num_posts: parseInt(form.num_posts) || 7,
start_date: form.start_date || null,
}
if (form.format_narrativo) payload.format_narrativo = form.format_narrativo
if (form.awareness_level) payload.awareness_level = parseInt(form.awareness_level)
const data = await api.post('/editorial/generate-calendar', payload)
setCalendar(data)
} catch (err) {
setError(err.message || 'Errore nella generazione del calendario')
} finally {
setLoading(false)
}
}
const handleExportCsv = async () => {
if (!calendar?.slots?.length) return
setExporting(true)
try {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE_URL}/editorial/export-csv`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ slots: calendar.slots }),
})
if (!res.ok) throw new Error('Export fallito')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'calendario_editoriale.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
setError(err.message || 'Errore nell\'export CSV')
} finally {
setExporting(false)
}
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>
Calendario Editoriale AI
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Genera un piano editoriale con format narrativi e awareness levels (Schwartz)
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form */}
<div style={cardStyle}>
<h3 className="font-semibold text-sm uppercase tracking-wider mb-4" style={{ color: 'var(--muted)' }}>
Parametri
</h3>
<form onSubmit={handleGenerate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Topics / Keywords
</label>
<textarea
value={form.topics}
onChange={(e) => handleChange('topics', e.target.value)}
placeholder="Es. marketing digitale, social media, content strategy"
rows={3}
style={{ ...inputStyle, resize: 'vertical' }}
/>
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
Separati da virgola
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Formato Narrativo
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<select
value={form.format_narrativo}
onChange={(e) => handleChange('format_narrativo', e.target.value)}
style={inputStyle}
>
<option value="">Distribuzione automatica</option>
{formats.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Awareness Level
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<select
value={form.awareness_level}
onChange={(e) => handleChange('awareness_level', e.target.value)}
style={inputStyle}
>
<option value="">Distribuzione automatica</option>
{awarenessLevels.map((l) => (
<option key={l.value} value={l.value}>
{l.value} {l.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Numero di post
</label>
<input
type="number"
min={1}
max={30}
value={form.num_posts}
onChange={(e) => handleChange('num_posts', e.target.value)}
style={inputStyle}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Data di inizio
</label>
<input
type="date"
value={form.start_date}
onChange={(e) => handleChange('start_date', e.target.value)}
style={inputStyle}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent" />
Generazione...
</span>
) : 'Genera Calendario'}
</button>
</form>
</div>
{/* Results */}
<div className="lg:col-span-2">
{calendar ? (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Calendario Generato
</h3>
<p className="text-xs mt-0.5" style={{ color: 'var(--muted)' }}>
{calendar.totale_post} post pianificati
</p>
</div>
<button
onClick={handleExportCsv}
disabled={exporting}
className="px-4 py-2 text-sm font-medium rounded-lg transition-opacity"
style={{
backgroundColor: 'var(--ink)',
color: '#fff',
opacity: exporting ? 0.7 : 1,
}}
>
{exporting ? 'Export...' : 'Esporta CSV per Canva'}
</button>
</div>
<div className="space-y-3">
{calendar.slots.map((slot) => {
const fmtColor = FORMATO_COLORS[slot.formato_narrativo] || { bg: '#F8F8F8', color: 'var(--ink)' }
const awColor = AWARENESS_COLORS[slot.awareness_level] || { bg: '#F8F8F8', color: 'var(--ink)' }
return (
<div
key={slot.indice}
className="flex gap-4 p-4 rounded-xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{/* Index */}
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0"
style={{ backgroundColor: 'var(--coral)', color: '#fff' }}
>
{slot.indice + 1}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
{/* Date */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: 'var(--cream)', color: 'var(--muted)', border: '1px solid var(--border)' }}
>
{new Date(slot.data_pubblicazione).toLocaleDateString('it-IT', {
weekday: 'short', day: '2-digit', month: 'short'
})}
</span>
{/* Format */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: fmtColor.bg, color: fmtColor.color }}
>
{slot.formato_narrativo.replace('_', ' ')}
</span>
{/* Awareness */}
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ backgroundColor: awColor.bg, color: awColor.color }}
>
L{slot.awareness_level} {slot.awareness_label}
</span>
</div>
{/* Topic */}
<p className="text-sm font-medium" style={{ color: 'var(--ink)' }}>
{slot.topic}
</p>
{/* Note */}
{slot.note && (
<p className="text-xs mt-1" style={{ color: 'var(--muted)' }}>
{slot.note}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
) : (
<div
className="flex flex-col items-center justify-center py-20 rounded-xl text-center"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<p className="text-5xl mb-4"></p>
<p className="font-semibold text-lg font-serif" style={{ color: 'var(--ink)' }}>
Nessun calendario generato
</p>
<p className="text-sm mt-2 max-w-xs" style={{ color: 'var(--muted)' }}>
Inserisci i topic e scegli le impostazioni, poi clicca "Genera Calendario"
</p>
{/* Info boxes */}
<div className="grid grid-cols-2 gap-3 mt-8 text-left max-w-sm">
<InfoBox title="Format narrativi" items={['PAS', 'AIDA', 'BAB', 'Storytelling', 'Listicle', 'Dato Implicazione']} />
<InfoBox title="Awareness levels" items={['1 — Unaware', '2 — Problem', '3 — Solution', '4 — Product', '5 — Most Aware']} />
</div>
</div>
)}
</div>
</div>
</div>
)
}
function InfoBox({ title, items }) {
return (
<div
className="p-3 rounded-lg"
style={{ backgroundColor: 'var(--cream)', border: '1px solid var(--border)' }}
>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--muted)' }}>
{title}
</p>
<ul className="space-y-1">
{items.map((item) => (
<li key={item} className="text-xs" style={{ color: 'var(--ink)' }}>
{item}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { NavLink, Outlet } from 'react-router-dom'
import { useAuth } from '../AuthContext'
const nav = [
{ to: '/', label: 'Dashboard', icon: '◉' },
{ to: '/characters', label: 'Personaggi', icon: '◎' },
{ to: '/content', label: 'Contenuti', icon: '✦' },
{ to: '/affiliates', label: 'Link Affiliati', icon: '⟁' },
{ to: '/plans', label: 'Piano Editoriale', icon: '▦' },
{ to: '/schedule', label: 'Schedulazione', icon: '◈' },
{ to: '/social', label: 'Social', icon: '◇' },
{ to: '/comments', label: 'Commenti', icon: '◌' },
{ to: '/editorial', label: 'Calendario AI', icon: '◰' },
{ to: '/settings', label: 'Impostazioni', icon: '⚙' },
]
export default function Layout() {
const { user, logout } = useAuth()
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--cream)' }}>
{/* Sidebar */}
<aside className="w-60 flex flex-col shrink-0" style={{ backgroundColor: 'var(--ink)' }}>
<div className="p-5 border-b" style={{ borderColor: 'rgba(255,255,255,0.08)' }}>
<h1 className="text-lg font-bold tracking-tight text-white font-serif">
Leopost <span style={{ color: 'var(--coral)' }}>Full</span>
</h1>
<p className="text-[10px] mt-0.5" style={{ color: 'var(--muted)' }}>
Content Automation
</p>
</div>
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
{nav.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded text-[13px] font-medium transition-colors ${
isActive
? 'text-white'
: 'text-slate-400 hover:text-white'
}`
}
style={({ isActive }) =>
isActive ? { backgroundColor: 'var(--coral)' } : {}
}
>
<span className="text-base w-5 text-center">{icon}</span>
{label}
</NavLink>
))}
</nav>
<div className="p-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.08)' }}>
<div className="flex items-center justify-between px-2">
<span className="text-xs" style={{ color: 'var(--muted)' }}>
{user?.username}
</span>
<button
onClick={logout}
className="text-[11px] transition-colors hover:text-white"
style={{ color: 'var(--muted)' }}
>
Logout
</button>
</div>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-6">
<Outlet />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/')
} catch (err) {
setError(err.message || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: 'var(--ink)' }}
>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white tracking-tight font-serif">
Leopost <span style={{ color: 'var(--coral)' }}>Full</span>
</h1>
<p className="mt-2 text-sm" style={{ color: 'var(--muted)' }}>
Content Automation Platform
</p>
</div>
<form
onSubmit={handleSubmit}
className="rounded-xl p-8 shadow-xl"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: 'var(--ink)' }}
>
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
required
/>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
style={{ color: 'var(--ink)' }}
>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg text-sm focus:outline-none"
style={{
border: '1px solid var(--border)',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
}}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="mt-6 w-full py-2.5 text-white font-medium rounded-lg transition-opacity text-sm"
style={{ backgroundColor: 'var(--coral)', opacity: loading ? 0.7 : 1 }}
>
{loading ? 'Accesso...' : 'Accedi'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,408 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
const FREQUENCY_OPTIONS = [
{ value: 'daily', label: 'Giornaliero' },
{ value: 'twice_daily', label: 'Due volte al giorno' },
{ value: 'weekly', label: 'Settimanale' },
{ value: 'custom', label: 'Personalizzato' },
]
const PLATFORM_OPTIONS = [
{ value: 'instagram', label: 'Instagram' },
{ value: 'facebook', label: 'Facebook' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'tiktok', label: 'TikTok' },
]
const CONTENT_TYPE_OPTIONS = [
{ value: 'text', label: 'Testo' },
{ value: 'image', label: 'Immagine' },
{ value: 'video', label: 'Video' },
]
const EMPTY_FORM = {
character_id: '',
name: '',
frequency: 'daily',
posts_per_day: 1,
platforms: [],
content_types: [],
posting_times: ['09:00'],
start_date: '',
end_date: '',
is_active: true,
}
export default function PlanForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY_FORM)
const [characters, setCharacters] = useState([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(isEdit)
useEffect(() => {
api.get('/characters/')
.then(setCharacters)
.catch(() => {})
}, [])
useEffect(() => {
if (isEdit) {
api.get(`/plans/${id}`)
.then((data) => {
setForm({
character_id: data.character_id ? String(data.character_id) : '',
name: data.name || '',
frequency: data.frequency || 'daily',
posts_per_day: data.posts_per_day || 1,
platforms: data.platforms || [],
content_types: data.content_types || [],
posting_times: data.posting_times && data.posting_times.length > 0
? data.posting_times
: ['09:00'],
start_date: data.start_date ? data.start_date.split('T')[0] : '',
end_date: data.end_date ? data.end_date.split('T')[0] : '',
is_active: data.is_active ?? true,
})
})
.catch(() => setError('Piano non trovato'))
.finally(() => setLoading(false))
}
}, [id, isEdit])
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const toggleArrayItem = (field, value) => {
setForm((prev) => {
const arr = prev[field] || []
return {
...prev,
[field]: arr.includes(value)
? arr.filter((v) => v !== value)
: [...arr, value],
}
})
}
const addPostingTime = () => {
setForm((prev) => ({
...prev,
posting_times: [...prev.posting_times, '12:00'],
}))
}
const updatePostingTime = (index, value) => {
setForm((prev) => {
const times = [...prev.posting_times]
times[index] = value
return { ...prev, posting_times: times }
})
}
const removePostingTime = (index) => {
setForm((prev) => ({
...prev,
posting_times: prev.posting_times.filter((_, i) => i !== index),
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!form.character_id) {
setError('Seleziona un personaggio')
return
}
if (form.platforms.length === 0) {
setError('Seleziona almeno una piattaforma')
return
}
if (form.content_types.length === 0) {
setError('Seleziona almeno un tipo di contenuto')
return
}
setSaving(true)
try {
const payload = {
...form,
character_id: parseInt(form.character_id),
posts_per_day: form.frequency === 'custom' ? parseInt(form.posts_per_day) : null,
start_date: form.start_date || null,
end_date: form.end_date || null,
}
if (isEdit) {
await api.put(`/plans/${id}`, payload)
} else {
await api.post('/plans/', payload)
}
navigate('/plans')
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">
{isEdit ? 'Modifica piano editoriale' : 'Nuovo piano editoriale'}
</h2>
<p className="text-slate-500 mt-1 text-sm">
{isEdit ? 'Aggiorna la configurazione del piano' : 'Configura un nuovo piano di pubblicazione'}
</p>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Basic info */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Informazioni base
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Personaggio
</label>
<select
value={form.character_id}
onChange={(e) => handleChange('character_id', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
required
>
<option value="">Seleziona personaggio...</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nome piano
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Es. Piano Instagram Giornaliero..."
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-brand-500"></div>
</label>
<span className="text-sm text-slate-700">Attivo</span>
</div>
</div>
{/* Frequency */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Frequenza pubblicazione
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Frequenza
</label>
<select
value={form.frequency}
onChange={(e) => handleChange('frequency', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{form.frequency === 'custom' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Post al giorno
</label>
<input
type="number"
min="1"
max="20"
value={form.posts_per_day}
onChange={(e) => handleChange('posts_per_day', e.target.value)}
className="w-32 px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
)}
</div>
{/* Platforms & Content Types */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Piattaforme e tipi di contenuto
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Piattaforme
</label>
<div className="flex flex-wrap gap-3">
{PLATFORM_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.platforms.includes(opt.value)}
onChange={() => toggleArrayItem('platforms', opt.value)}
className="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-slate-700">{opt.label}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Tipi di contenuto
</label>
<div className="flex flex-wrap gap-3">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.content_types.includes(opt.value)}
onChange={() => toggleArrayItem('content_types', opt.value)}
className="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-slate-700">{opt.label}</span>
</label>
))}
</div>
</div>
</div>
{/* Posting times */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Orari di pubblicazione
</h3>
<button
type="button"
onClick={addPostingTime}
className="text-xs px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
+ Aggiungi orario
</button>
</div>
<div className="space-y-2">
{form.posting_times.map((time, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="time"
value={time}
onChange={(e) => updatePostingTime(i, e.target.value)}
className="px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
{form.posting_times.length > 1 && (
<button
type="button"
onClick={() => removePostingTime(i)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Rimuovi
</button>
)}
</div>
))}
</div>
</div>
{/* Date range */}
<div className="bg-white rounded-xl border border-slate-200 p-6 space-y-4">
<h3 className="font-semibold text-slate-700 text-sm uppercase tracking-wider">
Periodo
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Data inizio
</label>
<input
type="date"
value={form.start_date}
onChange={(e) => handleChange('start_date', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Data fine
<span className="text-slate-400 font-normal ml-1">(opzionale)</span>
</label>
<input
type="date"
value={form.end_date}
onChange={(e) => handleChange('end_date', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2.5 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white font-medium rounded-lg transition-colors text-sm"
>
{saving ? 'Salvataggio...' : isEdit ? 'Salva modifiche' : 'Crea piano'}
</button>
<button
type="button"
onClick={() => navigate('/plans')}
className="px-6 py-2.5 bg-white hover:bg-slate-50 text-slate-600 font-medium rounded-lg border border-slate-200 transition-colors text-sm"
>
Annulla
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api'
const frequencyLabels = {
daily: 'Giornaliero',
twice_daily: 'Due volte al giorno',
weekly: 'Settimanale',
custom: 'Personalizzato',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
export default function PlanList() {
const [plans, setPlans] = useState([])
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [plansData, charsData] = await Promise.all([
api.get('/plans/'),
api.get('/characters/'),
])
setPlans(plansData)
setCharacters(charsData)
} catch {
// silent
} finally {
setLoading(false)
}
}
const getCharacterName = (id) => {
const c = characters.find((ch) => ch.id === id)
return c ? c.name : '—'
}
const handleToggle = async (plan) => {
try {
await api.post(`/plans/${plan.id}/toggle`)
loadData()
} catch {
// silent
}
}
const handleDelete = async (id, name) => {
if (!confirm(`Eliminare il piano "${name}"?`)) return
try {
await api.delete(`/plans/${id}`)
loadData()
} catch {
// silent
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' })
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Piano Editoriale</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci i piani di pubblicazione automatica
</p>
</div>
<Link
to="/plans/new"
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Nuovo Piano
</Link>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : plans.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun piano editoriale</p>
<p className="text-slate-400 text-sm mt-1">
Crea un piano per automatizzare la pubblicazione dei contenuti
</p>
<Link
to="/plans/new"
className="inline-block mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors"
>
+ Crea piano
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{plans.map((plan) => (
<div
key={plan.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all overflow-hidden"
>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${plan.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
<h3 className="font-semibold text-slate-800">{plan.name}</h3>
</div>
<button
onClick={() => handleToggle(plan)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-colors ${
plan.is_active
? 'bg-emerald-50 text-emerald-600 hover:bg-emerald-100'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{plan.is_active ? 'Attivo' : 'Inattivo'}
</button>
</div>
{/* Character */}
<p className="text-sm text-slate-500 mb-3">
{getCharacterName(plan.character_id)}
</p>
{/* Info grid */}
<div className="space-y-2">
{/* Frequency */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Frequenza</span>
<span className="text-xs font-medium text-slate-600">
{frequencyLabels[plan.frequency] || plan.frequency}
{plan.frequency === 'custom' && plan.posts_per_day && (
<span className="text-slate-400 font-normal ml-1">
({plan.posts_per_day} post/giorno)
</span>
)}
</span>
</div>
{/* Platforms */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Piattaforme</span>
<div className="flex flex-wrap gap-1">
{plan.platforms && plan.platforms.map((p) => (
<span key={p} className="text-xs px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded">
{platformLabels[p] || p}
</span>
))}
</div>
</div>
{/* Posting times */}
{plan.posting_times && plan.posting_times.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Orari</span>
<div className="flex flex-wrap gap-1">
{plan.posting_times.map((t, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded font-mono">
{t}
</span>
))}
</div>
</div>
)}
{/* Date range */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-20 shrink-0">Periodo</span>
<span className="text-xs text-slate-600">
{formatDate(plan.start_date)}
{plan.end_date ? `${formatDate(plan.end_date)}` : ' — In corso'}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-slate-100">
<Link
to={`/plans/${plan.id}/edit`}
className="text-xs px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg transition-colors"
>
Modifica
</Link>
<button
onClick={() => handleDelete(plan.id, plan.name)}
className="text-xs px-3 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors ml-auto"
>
Elimina
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../AuthContext'
export default function ProtectedRoute() {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
)
}
return user ? <Outlet /> : <Navigate to="/login" />
}

View File

@@ -0,0 +1,263 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const statusLabels = {
pending: 'In attesa',
publishing: 'Pubblicazione...',
published: 'Pubblicato',
failed: 'Fallito',
}
const statusColors = {
pending: 'bg-amber-50 text-amber-600',
publishing: 'bg-blue-50 text-blue-600',
published: 'bg-emerald-50 text-emerald-600',
failed: 'bg-red-50 text-red-600',
}
const statusDotColors = {
pending: 'bg-amber-400',
publishing: 'bg-blue-400',
published: 'bg-emerald-400',
failed: 'bg-red-400',
}
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
export default function ScheduleView() {
const [scheduled, setScheduled] = useState([])
const [loading, setLoading] = useState(true)
const [filterPlatform, setFilterPlatform] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [publishing, setPublishing] = useState(null)
useEffect(() => {
loadScheduled()
}, [])
const loadScheduled = async () => {
setLoading(true)
try {
const data = await api.get('/plans/scheduled')
setScheduled(data)
} catch {
// silent
} finally {
setLoading(false)
}
}
const handlePublishNow = async (id) => {
setPublishing(id)
try {
await api.post(`/social/publish/${id}`)
loadScheduled()
} catch {
// silent
} finally {
setPublishing(null)
}
}
const handleDelete = async (id) => {
if (!confirm('Rimuovere questo post schedulato?')) return
try {
await api.delete(`/plans/scheduled/${id}`)
loadScheduled()
} catch {
// silent
}
}
const filtered = scheduled.filter((s) => {
if (filterPlatform && s.platform !== filterPlatform) return false
if (filterStatus && s.status !== filterStatus) return false
return true
})
// Group by date
const groupByDate = (items) => {
const groups = {}
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
items.forEach((item) => {
const date = new Date(item.scheduled_at || item.publish_at)
date.setHours(0, 0, 0, 0)
let label
if (date.getTime() === today.getTime()) {
label = 'Oggi'
} else if (date.getTime() === tomorrow.getTime()) {
label = 'Domani'
} else {
label = date.toLocaleDateString('it-IT', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
if (!groups[label]) groups[label] = []
groups[label].push(item)
})
return groups
}
const formatTime = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })
}
const grouped = groupByDate(filtered)
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-slate-800">Schedulazione</h2>
<p className="text-slate-500 mt-1 text-sm">
Post programmati e in attesa di pubblicazione
</p>
</div>
<button
onClick={loadScheduled}
className="px-4 py-2 bg-white hover:bg-slate-50 text-slate-700 text-sm font-medium rounded-lg border border-slate-200 transition-colors"
>
Aggiorna
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<select
value={filterPlatform}
onChange={(e) => setFilterPlatform(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Tutte le piattaforme</option>
{Object.entries(platformLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
<option value="">Tutti gli stati</option>
{Object.entries(statusLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
<span className="flex items-center text-xs text-slate-400 ml-auto">
{filtered.length} post programmati
</span>
</div>
{/* View toggle - for future */}
<div className="flex gap-1 mb-4">
<button className="px-3 py-1.5 text-xs font-medium bg-brand-600 text-white rounded-lg">
Lista
</button>
<button className="px-3 py-1.5 text-xs font-medium bg-slate-100 text-slate-500 rounded-lg cursor-not-allowed" title="Disponibile prossimamente">
Calendario
</button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3"></p>
<p className="text-slate-500 font-medium">Nessun post schedulato</p>
<p className="text-slate-400 text-sm mt-1">
I post verranno schedulati automaticamente dai piani editoriali attivi
</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([dateLabel, items]) => (
<div key={dateLabel}>
{/* Date header */}
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
{dateLabel}
<span className="text-xs font-normal text-slate-400 lowercase">
({items.length} post)
</span>
</h3>
{/* Items */}
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
className="bg-white rounded-xl border border-slate-200 hover:border-brand-300 transition-all p-4"
>
<div className="flex items-start gap-3">
{/* Time + status dot */}
<div className="flex flex-col items-center gap-1 shrink-0 w-14">
<span className="text-sm font-mono font-medium text-slate-700">
{formatTime(item.scheduled_at || item.publish_at)}
</span>
<span className={`w-2 h-2 rounded-full ${statusDotColors[item.status] || 'bg-slate-300'}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600">
{platformLabels[item.platform] || item.platform}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[item.status] || 'bg-slate-100 text-slate-500'}`}>
{statusLabels[item.status] || item.status}
</span>
</div>
<p className="text-sm text-slate-700 line-clamp-2">
{item.text || item.post_text || 'Contenuto in fase di generazione...'}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{(item.status === 'pending' || item.status === 'failed') && (
<button
onClick={() => handlePublishNow(item.id)}
disabled={publishing === item.id}
className="text-xs px-2.5 py-1.5 bg-brand-50 hover:bg-brand-100 text-brand-600 rounded-lg transition-colors font-medium disabled:opacity-50"
>
{publishing === item.id ? 'Invio...' : 'Pubblica ora'}
</button>
)}
<button
onClick={() => handleDelete(item.id)}
className="text-xs px-2 py-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
Rimuovi
</button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,287 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const LLM_PROVIDERS = [
{ value: 'claude', label: 'Claude (Anthropic)' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini (Google)' },
]
const IMAGE_PROVIDERS = [
{ value: 'dalle', label: 'DALL-E (OpenAI)' },
{ value: 'replicate', label: 'Replicate' },
]
const LLM_DEFAULTS = {
claude: 'claude-sonnet-4-20250514',
openai: 'gpt-4o',
gemini: 'gemini-2.0-flash',
}
const cardStyle = {
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '0.75rem',
padding: '1.5rem',
}
const inputStyle = {
width: '100%',
padding: '0.625rem 1rem',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
fontSize: '0.875rem',
color: 'var(--ink)',
backgroundColor: 'var(--cream)',
outline: 'none',
}
export default function SettingsPage() {
const [settings, setSettings] = useState({})
const [providerStatus, setProviderStatus] = useState({})
const [loading, setLoading] = useState(true)
const [sectionSaving, setSectionSaving] = useState({})
const [sectionSuccess, setSectionSuccess] = useState({})
const [sectionError, setSectionError] = useState({})
const [llmForm, setLlmForm] = useState({
llm_provider: 'claude',
llm_api_key: '',
llm_model: '',
})
const [imageForm, setImageForm] = useState({
image_provider: 'dalle',
image_api_key: '',
})
const [voiceForm, setVoiceForm] = useState({
elevenlabs_api_key: '',
elevenlabs_voice_id: '',
})
useEffect(() => {
loadSettings()
}, [])
const loadSettings = async () => {
setLoading(true)
try {
const [settingsData, statusData] = await Promise.all([
api.get('/settings/').catch(() => ({})),
api.get('/settings/providers/status').catch(() => ({})),
])
let normalizedSettings = {}
if (Array.isArray(settingsData)) {
settingsData.forEach((s) => { normalizedSettings[s.key] = s.value })
} else {
normalizedSettings = settingsData || {}
}
setSettings(normalizedSettings)
setProviderStatus(statusData || {})
setLlmForm({
llm_provider: normalizedSettings.llm_provider || 'claude',
llm_api_key: normalizedSettings.llm_api_key || '',
llm_model: normalizedSettings.llm_model || '',
})
setImageForm({
image_provider: normalizedSettings.image_provider || 'dalle',
image_api_key: normalizedSettings.image_api_key || '',
})
setVoiceForm({
elevenlabs_api_key: normalizedSettings.elevenlabs_api_key || '',
elevenlabs_voice_id: normalizedSettings.elevenlabs_voice_id || '',
})
} catch {
// silent
} finally {
setLoading(false)
}
}
const saveSection = async (section, data) => {
setSectionSaving((prev) => ({ ...prev, [section]: true }))
setSectionSuccess((prev) => ({ ...prev, [section]: false }))
setSectionError((prev) => ({ ...prev, [section]: '' }))
try {
for (const [key, value] of Object.entries(data)) {
await api.put(`/settings/${key}`, { value })
}
setSectionSuccess((prev) => ({ ...prev, [section]: true }))
setTimeout(() => {
setSectionSuccess((prev) => ({ ...prev, [section]: false }))
}, 3000)
const statusData = await api.get('/settings/providers/status').catch(() => ({}))
setProviderStatus(statusData || {})
} catch (err) {
setSectionError((prev) => ({ ...prev, [section]: err.message || 'Errore nel salvataggio' }))
} finally {
setSectionSaving((prev) => ({ ...prev, [section]: false }))
}
}
if (loading) {
return (
<div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>Impostazioni</h2>
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Impostazioni</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Configurazione dei provider AI e dei servizi esterni
</p>
</div>
<div className="max-w-2xl space-y-6">
{/* LLM Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Provider LLM
</h3>
{sectionError.llm && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.llm}</div>}
{sectionSuccess.llm && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={llmForm.llm_provider}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_provider: e.target.value }))}
style={inputStyle}
>
{LLM_PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={llmForm.llm_api_key}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_api_key: e.target.value }))}
placeholder="sk-..."
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Modello <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(default: {LLM_DEFAULTS[llmForm.llm_provider]})</span>
</label>
<input
type="text"
value={llmForm.llm_model}
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_model: e.target.value }))}
placeholder={LLM_DEFAULTS[llmForm.llm_provider]}
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<button
onClick={() => saveSection('llm', llmForm)}
disabled={sectionSaving.llm}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.llm ? 0.7 : 1 }}
>
{sectionSaving.llm ? 'Salvataggio...' : 'Salva'}
</button>
</div>
{/* Image Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Generazione Immagini
</h3>
{sectionError.image && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.image}</div>}
{sectionSuccess.image && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={imageForm.image_provider}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_provider: e.target.value }))}
style={inputStyle}
>
{IMAGE_PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={imageForm.image_api_key}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_api_key: e.target.value }))}
placeholder="API key del provider immagini"
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<button
onClick={() => saveSection('image', imageForm)}
disabled={sectionSaving.image}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.image ? 0.7 : 1 }}
>
{sectionSaving.image ? 'Salvataggio...' : 'Salva'}
</button>
</div>
{/* Voiceover */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Voiceover (ElevenLabs)
</h3>
{sectionError.voice && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.voice}</div>}
{sectionSuccess.voice && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={voiceForm.elevenlabs_api_key}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_api_key: e.target.value }))}
placeholder="ElevenLabs API key"
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Voice ID <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<input
type="text"
value={voiceForm.elevenlabs_voice_id}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_voice_id: e.target.value }))}
placeholder="ID della voce ElevenLabs"
style={inputStyle}
/>
</div>
<button
onClick={() => saveSection('voice', voiceForm)}
disabled={sectionSaving.voice}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.voice ? 0.7 : 1 }}
>
{sectionSaving.voice ? 'Salvataggio...' : 'Salva'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,331 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const platformLabels = {
instagram: 'Instagram',
facebook: 'Facebook',
youtube: 'YouTube',
tiktok: 'TikTok',
}
const platformColors = {
instagram: 'bg-pink-50 text-pink-600 border-pink-200',
facebook: 'bg-blue-50 text-blue-600 border-blue-200',
youtube: 'bg-red-50 text-red-600 border-red-200',
tiktok: 'bg-slate-900 text-white border-slate-700',
}
const EMPTY_ACCOUNT = {
platform: 'instagram',
account_name: '',
access_token: '',
page_id: '',
}
export default function SocialAccounts() {
const [characters, setCharacters] = useState([])
const [accounts, setAccounts] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(null) // character_id or null
const [form, setForm] = useState(EMPTY_ACCOUNT)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [testing, setTesting] = useState(null)
const [testResult, setTestResult] = useState({})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [charsData, accsData] = await Promise.all([
api.get('/characters/'),
api.get('/social/accounts'),
])
setCharacters(charsData)
setAccounts(accsData)
} catch {
// silent
} finally {
setLoading(false)
}
}
const getAccountsForCharacter = (characterId) => {
return accounts.filter((a) => a.character_id === characterId)
}
const handleFormChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }))
}
const handleAddAccount = async (characterId) => {
setError('')
setSaving(true)
try {
await api.post('/social/accounts', {
character_id: characterId,
platform: form.platform,
account_name: form.account_name,
access_token: form.access_token,
page_id: form.page_id || null,
})
setShowForm(null)
setForm(EMPTY_ACCOUNT)
loadData()
} catch (err) {
setError(err.message || 'Errore nel salvataggio')
} finally {
setSaving(false)
}
}
const handleTest = async (accountId) => {
setTesting(accountId)
setTestResult((prev) => ({ ...prev, [accountId]: null }))
try {
const result = await api.post(`/social/accounts/${accountId}/test`)
setTestResult((prev) => ({ ...prev, [accountId]: { success: true, message: result.message || 'Connessione OK' } }))
} catch (err) {
setTestResult((prev) => ({ ...prev, [accountId]: { success: false, message: err.message || 'Test fallito' } }))
} finally {
setTesting(null)
}
}
const handleToggle = async (account) => {
try {
await api.put(`/social/accounts/${account.id}`, { is_active: !account.is_active })
loadData()
} catch {
// silent
}
}
const handleRemove = async (accountId) => {
if (!confirm('Rimuovere questo account social?')) return
try {
await api.delete(`/social/accounts/${accountId}`)
loadData()
} catch {
// silent
}
}
if (loading) {
return (
<div>
<h2 className="text-2xl font-bold text-slate-800 mb-1">Account Social</h2>
<p className="text-slate-500 text-sm mb-6">Gestisci le connessioni ai social network</p>
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-brand-500 border-t-transparent" />
</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800">Account Social</h2>
<p className="text-slate-500 mt-1 text-sm">
Gestisci le connessioni ai social network per ogni personaggio
</p>
</div>
{/* Info box */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl">
<div className="flex gap-3">
<span className="text-blue-500 text-lg shrink-0">i</span>
<div>
<p className="text-sm text-blue-700 font-medium">Configurazione OAuth</p>
<p className="text-xs text-blue-600 mt-0.5">
Per la pubblicazione automatica, ogni piattaforma richiede la configurazione di un'app
developer con le relative credenziali OAuth. Inserisci access token e page ID ottenuti
dalla console developer di ciascuna piattaforma.
</p>
</div>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{characters.length === 0 ? (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<p className="text-4xl mb-3">◇</p>
<p className="text-slate-500 font-medium">Nessun personaggio</p>
<p className="text-slate-400 text-sm mt-1">
Crea un personaggio per poi collegare gli account social
</p>
</div>
) : (
<div className="space-y-6">
{characters.map((character) => {
const charAccounts = getAccountsForCharacter(character.id)
const isFormOpen = showForm === character.id
return (
<div key={character.id} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Character header */}
<div className="p-5 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0"
style={{ backgroundColor: character.visual_style?.primary_color || '#f97316' }}
>
{character.name?.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="font-semibold text-slate-800">{character.name}</h3>
<p className="text-xs text-slate-400">{character.niche}</p>
</div>
</div>
<button
onClick={() => {
setShowForm(isFormOpen ? null : character.id)
setForm(EMPTY_ACCOUNT)
setError('')
}}
className="text-xs px-3 py-1.5 bg-brand-50 hover:bg-brand-100 text-brand-600 rounded-lg transition-colors font-medium"
>
{isFormOpen ? 'Annulla' : '+ Connetti Account'}
</button>
</div>
</div>
{/* Inline form */}
{isFormOpen && (
<div className="p-5 bg-slate-50 border-b border-slate-100">
<div className="max-w-md space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Piattaforma</label>
<select
value={form.platform}
onChange={(e) => handleFormChange('platform', e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm bg-white"
>
{Object.entries(platformLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nome account</label>
<input
type="text"
value={form.account_name}
onChange={(e) => handleFormChange('account_name', e.target.value)}
placeholder="Es. @mio_profilo"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Access Token</label>
<input
type="password"
value={form.access_token}
onChange={(e) => handleFormChange('access_token', e.target.value)}
placeholder="Token di accesso dalla piattaforma"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Page ID
<span className="text-slate-400 font-normal ml-1">(opzionale)</span>
</label>
<input
type="text"
value={form.page_id}
onChange={(e) => handleFormChange('page_id', e.target.value)}
placeholder="ID pagina (per Facebook/YouTube)"
className="w-full px-4 py-2.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent text-sm font-mono"
/>
</div>
<button
onClick={() => handleAddAccount(character.id)}
disabled={saving || !form.account_name || !form.access_token}
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 disabled:bg-brand-300 text-white text-sm font-medium rounded-lg transition-colors"
>
{saving ? 'Salvataggio...' : 'Salva Account'}
</button>
</div>
</div>
)}
{/* Accounts list */}
<div className="divide-y divide-slate-50">
{charAccounts.length === 0 ? (
<div className="px-5 py-8 text-center">
<p className="text-sm text-slate-400">Nessun account collegato</p>
</div>
) : (
charAccounts.map((account) => (
<div key={account.id} className="px-5 py-3 flex items-center gap-3">
{/* Platform badge */}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium border ${platformColors[account.platform] || 'bg-slate-100 text-slate-600 border-slate-200'}`}>
{platformLabels[account.platform] || account.platform}
</span>
{/* Account name */}
<span className="text-sm font-medium text-slate-700 flex-1 min-w-0 truncate">
{account.account_name}
</span>
{/* Status */}
<span className={`w-2 h-2 rounded-full shrink-0 ${account.is_active ? 'bg-emerald-500' : 'bg-slate-300'}`} />
{/* Test result */}
{testResult[account.id] && (
<span className={`text-xs ${testResult[account.id].success ? 'text-emerald-600' : 'text-red-500'}`}>
{testResult[account.id].message}
</span>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleTest(account.id)}
disabled={testing === account.id}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors disabled:opacity-50"
>
{testing === account.id ? 'Test...' : 'Test'}
</button>
<button
onClick={() => handleToggle(account)}
className="text-xs px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded transition-colors"
>
{account.is_active ? 'Disattiva' : 'Attiva'}
</button>
<button
onClick={() => handleRemove(account.id)}
className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
>
Rimuovi
</button>
</div>
</div>
))
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}

25
frontend/src/index.css Normal file
View File

@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,600;0,700;1,400&family=DM+Sans:wght@400;500;600&display=swap');
:root {
--coral: #FF6B4A;
--cream: #FAF8F3;
--ink: #1A1A2E;
--muted: #8B8B9A;
--surface: #FFFFFF;
--border: #E8E4DE;
}
body {
background-color: var(--cream);
color: var(--ink);
font-family: 'DM Sans', sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: 'Fraunces', serif;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
coral: '#FF6B4A',
cream: '#FAF8F3',
ink: '#1A1A2E',
muted: '#8B8B9A',
border: '#E8E4DE',
// Brand alias per compatibilità con componenti esistenti
brand: {
50: '#fff4f1',
100: '#ffe4dd',
200: '#ffc4b5',
300: '#ff9d85',
400: '#ff7a5c',
500: '#FF6B4A',
600: '#e8522f',
700: '#c43f22',
800: '#9e3219',
900: '#7c2912',
},
},
fontFamily: {
serif: ['Fraunces', 'serif'],
sans: ['DM Sans', 'sans-serif'],
},
},
},
plugins: [],
}

20
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/leopost-full/',
server: {
port: 5173,
proxy: {
'/leopost-full/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/leopost-full/, ''),
},
},
},
build: {
outDir: 'dist',
},
})