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:
15
.gitattributes
vendored
Normal file
15
.gitattributes
vendored
Normal 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
36
.gitignore
vendored
Normal 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
31
.vps-lab-config.json
Normal 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
17
Dockerfile
Normal 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
27
README.md
Normal 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
0
backend/app/__init__.py
Normal file
60
backend/app/auth.py
Normal file
60
backend/app/auth.py
Normal 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
15
backend/app/config.py
Normal 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
20
backend/app/database.py
Normal 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
156
backend/app/main.py
Normal 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
156
backend/app/models.py
Normal 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)
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
75
backend/app/routers/affiliates.py
Normal file
75
backend/app/routers/affiliates.py
Normal 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()
|
||||||
62
backend/app/routers/characters.py
Normal file
62
backend/app/routers/characters.py
Normal 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()
|
||||||
281
backend/app/routers/comments.py
Normal file
281
backend/app/routers/comments.py
Normal 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}"}
|
||||||
225
backend/app/routers/content.py
Normal file
225
backend/app/routers/content.py
Normal 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
|
||||||
112
backend/app/routers/editorial.py
Normal file
112
backend/app/routers/editorial.py
Normal 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"
|
||||||
|
},
|
||||||
|
)
|
||||||
150
backend/app/routers/plans.py
Normal file
150
backend/app/routers/plans.py
Normal 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
|
||||||
122
backend/app/routers/settings.py
Normal file
122
backend/app/routers/settings.py
Normal 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()
|
||||||
203
backend/app/routers/social.py
Normal file
203
backend/app/routers/social.py
Normal 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
176
backend/app/scheduler.py
Normal 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
320
backend/app/schemas.py
Normal 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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
168
backend/app/services/calendar_service.py
Normal file
168
backend/app/services/calendar_service.py
Normal 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}")
|
||||||
202
backend/app/services/content.py
Normal file
202
backend/app/services/content.py
Normal 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
|
||||||
181
backend/app/services/images.py
Normal file
181
backend/app/services/images.py
Normal 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
194
backend/app/services/llm.py
Normal 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)
|
||||||
169
backend/app/services/prompt_service.py
Normal file
169
backend/app/services/prompt_service.py
Normal 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"
|
||||||
706
backend/app/services/social.py
Normal file
706
backend/app/services/social.py
Normal 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
9
backend/requirements.txt
Normal 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
20
docker-compose.yml
Normal 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
15
frontend/index.html
Normal 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
23
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
53
frontend/src/App.jsx
Normal file
53
frontend/src/App.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/AuthContext.jsx
Normal file
41
frontend/src/AuthContext.jsx
Normal 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
34
frontend/src/api.js
Normal 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),
|
||||||
|
}
|
||||||
306
frontend/src/components/AffiliateForm.jsx
Normal file
306
frontend/src/components/AffiliateForm.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
frontend/src/components/AffiliateList.jsx
Normal file
218
frontend/src/components/AffiliateList.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
331
frontend/src/components/CharacterForm.jsx
Normal file
331
frontend/src/components/CharacterForm.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
frontend/src/components/CharacterList.jsx
Normal file
161
frontend/src/components/CharacterList.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
292
frontend/src/components/CommentsQueue.jsx
Normal file
292
frontend/src/components/CommentsQueue.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
frontend/src/components/ContentArchive.jsx
Normal file
227
frontend/src/components/ContentArchive.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
334
frontend/src/components/ContentPage.jsx
Normal file
334
frontend/src/components/ContentPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
201
frontend/src/components/Dashboard.jsx
Normal file
201
frontend/src/components/Dashboard.jsx
Normal 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'
|
||||||
|
}
|
||||||
403
frontend/src/components/EditorialCalendar.jsx
Normal file
403
frontend/src/components/EditorialCalendar.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
frontend/src/components/Layout.jsx
Normal file
80
frontend/src/components/Layout.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
frontend/src/components/LoginPage.jsx
Normal file
109
frontend/src/components/LoginPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
408
frontend/src/components/PlanForm.jsx
Normal file
408
frontend/src/components/PlanForm.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
213
frontend/src/components/PlanList.jsx
Normal file
213
frontend/src/components/PlanList.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
frontend/src/components/ProtectedRoute.jsx
Normal file
16
frontend/src/components/ProtectedRoute.jsx
Normal 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" />
|
||||||
|
}
|
||||||
263
frontend/src/components/ScheduleView.jsx
Normal file
263
frontend/src/components/ScheduleView.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
287
frontend/src/components/SettingsPage.jsx
Normal file
287
frontend/src/components/SettingsPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
331
frontend/src/components/SocialAccounts.jsx
Normal file
331
frontend/src/components/SocialAccounts.jsx
Normal 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
25
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>
|
||||||
|
)
|
||||||
33
frontend/tailwind.config.js
Normal file
33
frontend/tailwind.config.js
Normal 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
20
frontend/vite.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user