This commit is contained in:
Nicola Malizia
2025-10-10 21:05:58 +02:00
parent a0a5bab4f1
commit 8ed8fcde6c
8 changed files with 239 additions and 111 deletions

View File

@@ -1,4 +1,5 @@
import os import os
import sqlalchemy as sa
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from . import models, schemas from . import models, schemas
@@ -6,11 +7,25 @@ def get_celebrity(db: Session, celebrity_id: int):
# Usiamo joinedload per caricare in anticipo le relazioni e evitare query N+1 # Usiamo joinedload per caricare in anticipo le relazioni e evitare query N+1
return db.query(models.Celebrity).options( return db.query(models.Celebrity).options(
joinedload(models.Celebrity.profile_image), joinedload(models.Celebrity.profile_image),
joinedload(models.Celebrity.images) joinedload(models.Celebrity.images),
joinedload(models.Celebrity.aliases) # Carica anche gli alias
).filter(models.Celebrity.id == celebrity_id).first() ).filter(models.Celebrity.id == celebrity_id).first()
def get_celebrities(db: Session, skip: int = 0, limit: int = 100): def get_celebrities(db: Session, skip: int = 0, limit: int = 100, search: str = None):
return db.query(models.Celebrity).offset(skip).limit(limit).all() query = db.query(models.Celebrity).options(
joinedload(models.Celebrity.profile_image)
)
if search:
search_term = f"%{search}%"
# Esegue un join con la tabella degli alias e filtra per nome o per alias
query = query.outerjoin(models.Celebrity.aliases).filter(
sa.or_(
models.Celebrity.name.ilike(search_term),
models.CelebrityAlias.alias_name.ilike(search_term)
)
).distinct() # distinct() evita duplicati se una celebrità ha più alias che matchano
return query.offset(skip).limit(limit).all()
def create_celebrity(db: Session, celebrity: schemas.CelebrityCreate): def create_celebrity(db: Session, celebrity: schemas.CelebrityCreate):
db_celebrity = models.Celebrity(**celebrity.model_dump()) db_celebrity = models.Celebrity(**celebrity.model_dump())
@@ -41,6 +56,23 @@ def delete_celebrity(db: Session, celebrity_id: int):
db.commit() db.commit()
return db_celebrity return db_celebrity
# --- Funzioni CRUD per gli Alias ---
def create_celebrity_alias(db: Session, celebrity_id: int, alias: schemas.CelebrityAliasCreate):
db_alias = models.CelebrityAlias(celebrity_id=celebrity_id, alias_name=alias.alias_name)
db.add(db_alias)
db.commit()
db.refresh(db_alias)
return db_alias
def delete_celebrity_alias(db: Session, alias_id: int):
db_alias = db.query(models.CelebrityAlias).filter(models.CelebrityAlias.id == alias_id).first()
if not db_alias:
return None
db.delete(db_alias)
db.commit()
return db_alias
# --- Funzioni CRUD per le Immagini --- # --- Funzioni CRUD per le Immagini ---
def get_image(db: Session, image_id: int): def get_image(db: Session, image_id: int):

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List, Optional
import uuid import uuid
import shutil import shutil
import httpx
from .. import crud, models, schemas from .. import crud, models, schemas
from ..database import get_db from ..database import get_db
@@ -15,12 +16,12 @@ router = APIRouter(
@router.post("/", response_model=schemas.Celebrity, status_code=201) @router.post("/", response_model=schemas.Celebrity, status_code=201)
def create_celebrity(celebrity: schemas.CelebrityCreate, db: Session = Depends(get_db)): def create_celebrity(celebrity: schemas.CelebrityCreate, db: Session = Depends(get_db)):
# Qui potresti aggiungere un check per vedere se una celebrit├á con lo stesso nome esiste gi├á # Qui potresti aggiungere un check per vedere se una celebrità con lo stesso nome esiste già
return crud.create_celebrity(db=db, celebrity=celebrity) return crud.create_celebrity(db=db, celebrity=celebrity)
@router.get("/", response_model=List[schemas.Celebrity]) @router.get("/", response_model=List[schemas.Celebrity])
def read_celebrities(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): def read_celebrities(skip: int = 0, limit: int = 100, search: Optional[str] = None, db: Session = Depends(get_db)):
celebrities = crud.get_celebrities(db, skip=skip, limit=limit) celebrities = crud.get_celebrities(db, skip=skip, limit=limit, search=search)
return celebrities return celebrities
@router.get("/{celebrity_id}", response_model=schemas.Celebrity) @router.get("/{celebrity_id}", response_model=schemas.Celebrity)
@@ -44,7 +45,23 @@ def delete_celebrity(celebrity_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Celebrity not found") raise HTTPException(status_code=404, detail="Celebrity not found")
return db_celebrity return db_celebrity
# --- NUOVI ENDPOINT PER LE IMMAGINI --- # --- NUOVI ENDPOINT PER GLI ALIAS ---
@router.post("/{celebrity_id}/aliases", response_model=schemas.CelebrityAlias, status_code=201)
def add_alias_to_celebrity(celebrity_id: int, alias: schemas.CelebrityAliasCreate, db: Session = Depends(get_db)):
db_celebrity = crud.get_celebrity(db, celebrity_id=celebrity_id)
if db_celebrity is None:
raise HTTPException(status_code=404, detail="Celebrity not found")
return crud.create_celebrity_alias(db=db, celebrity_id=celebrity_id, alias=alias)
@router.delete("/aliases/{alias_id}", status_code=204)
def delete_alias(alias_id: int, db: Session = Depends(get_db)):
db_alias = crud.delete_celebrity_alias(db, alias_id=alias_id)
if db_alias is None:
raise HTTPException(status_code=404, detail="Alias not found")
return Response(status_code=204)
# --- ENDPOINT PER LE IMMAGINI ---
@router.post("/{celebrity_id}/images", response_model=List[schemas.Image], status_code=201) @router.post("/{celebrity_id}/images", response_model=List[schemas.Image], status_code=201)
def upload_celebrity_images(celebrity_id: int, files: List[UploadFile] = File(...), db: Session = Depends(get_db)): def upload_celebrity_images(celebrity_id: int, files: List[UploadFile] = File(...), db: Session = Depends(get_db)):
@@ -69,9 +86,6 @@ def upload_celebrity_images(celebrity_id: int, files: List[UploadFile] = File(..
created_images.append(db_image) created_images.append(db_image)
except Exception as e: except Exception as e:
# Se un file fallisce, potremmo voler continuare con gli altri,
# ma per ora lanciamo un'eccezione per l'intero batch.
# In un'app di produzione, si potrebbe restituire una risposta parziale.
print(f"Failed to upload file {file.filename}: {e}") print(f"Failed to upload file {file.filename}: {e}")
raise HTTPException(status_code=500, detail=f"Could not upload file: {file.filename}") raise HTTPException(status_code=500, detail=f"Could not upload file: {file.filename}")
@@ -89,7 +103,6 @@ async def add_image_from_url(celebrity_id: int, request: schemas.ImageUrlRequest
url = str(request.url) url = str(request.url)
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Eseguiamo prima una richiesta HEAD per controllare il tipo e la dimensione
head_response = await client.head(url, follow_redirects=True, timeout=10) head_response = await client.head(url, follow_redirects=True, timeout=10)
head_response.raise_for_status() head_response.raise_for_status()
@@ -97,16 +110,13 @@ async def add_image_from_url(celebrity_id: int, request: schemas.ImageUrlRequest
if not content_type or not content_type.startswith('image/'): if not content_type or not content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="URL does not point to a valid image.") raise HTTPException(status_code=400, detail="URL does not point to a valid image.")
# Limite di dimensione (es. 10MB)
content_length = int(head_response.headers.get('content-length', 0)) content_length = int(head_response.headers.get('content-length', 0))
if content_length > 10 * 1024 * 1024: if content_length > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Image size exceeds 10MB limit.") raise HTTPException(status_code=400, detail="Image size exceeds 10MB limit.")
# Scarica l'immagine
get_response = await client.get(url, follow_redirects=True, timeout=30) get_response = await client.get(url, follow_redirects=True, timeout=30)
get_response.raise_for_status() get_response.raise_for_status()
# Determina l'estensione del file
file_extension = content_type.split('/')[-1] file_extension = content_type.split('/')[-1]
if file_extension == 'jpeg': file_extension = 'jpg' if file_extension == 'jpeg': file_extension = 'jpg'
@@ -130,8 +140,6 @@ def set_celebrity_profile_image(celebrity_id: int, request: schemas.SetProfileIm
raise HTTPException(status_code=404, detail="Celebrity or Image not found, or image does not belong to celebrity.") raise HTTPException(status_code=404, detail="Celebrity or Image not found, or image does not belong to celebrity.")
return updated_celebrity return updated_celebrity
# NOTA: Un approccio pi├╣ RESTful sarebbe /api/images/{image_id} in un router dedicato.
# Per semplicità, lo inseriamo qui.
@router.delete("/images/{image_id}", response_model=schemas.Image) @router.delete("/images/{image_id}", response_model=schemas.Image)
def delete_image(image_id: int, db: Session = Depends(get_db)): def delete_image(image_id: int, db: Session = Depends(get_db)):
db_image = crud.delete_image(db, image_id=image_id) db_image = crud.delete_image(db, image_id=image_id)

View File

@@ -1,36 +1,43 @@
// packages/frontend/src/components/CelebrityList.jsx
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { getCelebrities, deleteCelebrity } from '../services/api'; import { getCelebrities, deleteCelebrity } from '../services/api';
import './CelebrityList.css'; // Importa il nuovo CSS import './CelebrityList.css';
// Icone SVG semplici per le azioni
const EditIcon = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>; const EditIcon = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>;
const DeleteIcon = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>; const DeleteIcon = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>;
const MoreIcon = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>; const MoreIcon = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>;
function CelebrityList() { function CelebrityList() {
const [celebrities, setCelebrities] = useState([]); const [celebrities, setCelebrities] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
// Stato per la paginazione
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const navigate = useNavigate(); const navigate = useNavigate();
// Effetto per "debouncare" il termine di ricerca
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300); // Attende 300ms dopo l'ultima digitazione prima di aggiornare
return () => {
clearTimeout(handler);
};
}, [searchTerm]);
// Effetto per caricare i dati quando il termine di ricerca (debounced) cambia
useEffect(() => { useEffect(() => {
fetchCelebrities(); fetchCelebrities();
}, []); }, [debouncedSearchTerm]);
const fetchCelebrities = async () => { const fetchCelebrities = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await getCelebrities(); const data = await getCelebrities(debouncedSearchTerm);
setCelebrities(data); setCelebrities(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -51,24 +58,18 @@ function CelebrityList() {
} }
}; };
// Memoizzazione dei dati filtrati e paginati per performance // La paginazione ora opera sulla lista (già filtrata) ricevuta dal backend
const filteredCelebrities = useMemo(() => {
return celebrities.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [celebrities, searchTerm]);
const paginatedCelebrities = useMemo(() => { const paginatedCelebrities = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage; const startIndex = (currentPage - 1) * itemsPerPage;
return filteredCelebrities.slice(startIndex, startIndex + itemsPerPage); return celebrities.slice(startIndex, startIndex + itemsPerPage);
}, [filteredCelebrities, currentPage, itemsPerPage]); }, [celebrities, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredCelebrities.length / itemsPerPage); const totalPages = Math.ceil(celebrities.length / itemsPerPage);
const renderPagination = () => ( const renderPagination = () => (
<div className="pagination-controls"> <div className="pagination-controls">
<span> <span>
Pagina {currentPage} di {totalPages} Pagina {currentPage} di {totalPages} ({celebrities.length} risultati)
</span> </span>
<div className="grid"> <div className="grid">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}> <button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}>
@@ -89,8 +90,8 @@ function CelebrityList() {
const renderEmptyState = () => ( const renderEmptyState = () => (
<div className="empty-state"> <div className="empty-state">
<h3>Nessun Profilo Trovato</h3> <h3>Nessun Profilo Trovato</h3>
<p>La tua lista è vuota. Inizia aggiungendo una nuova celebrità.</p> <p>{searchTerm ? "Prova a modificare i termini della ricerca." : "Inizia aggiungendo una nuova celebrità."}</p>
<Link to="/celebrity/new" role="button" className="primary">+ Aggiungi la prima celebrità</Link> <Link to="/celebrity/new" role="button" className="primary">+ Aggiungi una celebrità</Link>
</div> </div>
); );
@@ -106,18 +107,18 @@ function CelebrityList() {
<div className="list-controls"> <div className="list-controls">
<input <input
type="search" type="search"
placeholder="Cerca per nome..." placeholder="Cerca per nome o alias..."
value={searchTerm} value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }} onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/> />
</div> </div>
{loading && <article aria-busy="true">Caricamento in corso...</article>} {loading && <article aria-busy="true">Ricerca in corso...</article>}
{error && <p className="error-message">Errore: {error}</p>} {error && <p className="error-message">Errore: {error}</p>}
{!loading && !error && ( {!loading && !error && (
<> <>
{celebrities.length === 0 ? renderEmptyState() : ( {paginatedCelebrities.length === 0 ? renderEmptyState() : (
<> <>
<div className="table-responsive"> <div className="table-responsive">
<table> <table>
@@ -135,7 +136,7 @@ function CelebrityList() {
{paginatedCelebrities.map((celeb) => ( {paginatedCelebrities.map((celeb) => (
<tr key={celeb.id}> <tr key={celeb.id}>
<td> <td>
<img src={`https://i.pravatar.cc/50?u=${celeb.id}`} alt={celeb.name} className="avatar-image" /> <img src={celeb.profile_image ? `/api/uploads/${celeb.profile_image.file_path}` : `https://i.pravatar.cc/50?u=${celeb.id}`} alt={celeb.name} className="avatar-image" />
</td> </td>
<td> <td>
<Link to={`/celebrity/${celeb.id}`} className="celeb-link"> <Link to={`/celebrity/${celeb.id}`} className="celeb-link">

View File

@@ -24,7 +24,7 @@
width: 100%; width: 100%;
height: 400px; /* Altezza fissa per l'immagine profilo */ height: 400px; /* Altezza fissa per l'immagine profilo */
object-fit: cover; object-fit: cover;
background-color: var(--pico-muted-background-color); /* Sfondo per immagini trasparenti o in caricamento */ background-color: var(--pico-muted-background-color);
} }
.profile-sidebar .editable-field-container { .profile-sidebar .editable-field-container {
@@ -32,7 +32,7 @@
} }
.profile-sidebar .editable-field-container label { .profile-sidebar .editable-field-container label {
display: none; /* Nasconde l'etichetta "Nome" sotto la foto */ display: none;
} }
.profile-sidebar .display-value { .profile-sidebar .display-value {
font-size: 1.5rem; font-size: 1.5rem;
@@ -47,7 +47,7 @@
} }
.profile-content { .profile-content {
flex: 1 1 auto; /* Occupa lo spazio rimanente */ flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
@@ -81,6 +81,43 @@
color: var(--pico-color-red-500); color: var(--pico-color-red-500);
} }
/* --- Stili per Sezione Alias --- */
.alias-section {
margin-top: 1rem;
}
.alias-list {
list-style: none;
padding: 0;
margin: 0 0 1.5rem 0;
border: 1px solid var(--pico-card-border-color);
border-radius: var(--border-radius);
}
.alias-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.alias-list li:last-child {
border-bottom: none;
}
.alias-list li button {
padding: 0.1rem 0.5rem;
line-height: 1;
font-size: 1.2rem;
font-weight: bold;
}
.alias-add-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 1rem;
align-items: end;
}
.alias-add-form input, .alias-add-form button {
margin: 0;
}
/* --- Stili per Galleria e Upload --- */ /* --- Stili per Galleria e Upload --- */
.gallery-grid { .gallery-grid {
display: grid; display: grid;
@@ -135,7 +172,7 @@
border-radius: var(--border-radius); border-radius: var(--border-radius);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; /* Aumentato lo spazio */ gap: 1.5rem;
} }
.upload-section h4 { .upload-section h4 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
@@ -144,7 +181,7 @@
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: 1rem; gap: 1rem;
margin: 0; margin: 0;
align-items: center; /* Allinea verticalmente */ align-items: center;
} }
.upload-section input { .upload-section input {
margin: 0; margin: 0;

View File

@@ -1,8 +1,6 @@
// packages/frontend/src/components/CelebrityProfile.jsx
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { getCelebrityById, updateCelebrity, deleteCelebrity, uploadImages, setProfileImage, deleteImage, addImageFromUrl } from '../services/api'; import { getCelebrityById, updateCelebrity, deleteCelebrity, uploadImages, setProfileImage, deleteImage, addImageFromUrl, addAlias, deleteAlias } from '../services/api';
import EditableField from './EditableField'; import EditableField from './EditableField';
import './CelebrityProfile.css'; import './CelebrityProfile.css';
@@ -13,14 +11,17 @@ function CelebrityProfile() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Stati per la gestione upload const [selectedFiles, setSelectedFiles] = useState(null);
const [selectedFiles, setSelectedFiles] = useState(null); // Da file a files
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [imageUrl, setImageUrl] = useState(''); const [imageUrl, setImageUrl] = useState('');
const [isFetchingFromUrl, setIsFetchingFromUrl] = useState(false); const [isFetchingFromUrl, setIsFetchingFromUrl] = useState(false);
const [isDraggingOver, setIsDraggingOver] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// Stati per la gestione degli alias
const [newAlias, setNewAlias] = useState('');
const [isAddingAlias, setIsAddingAlias] = useState(false);
const fetchProfile = useCallback(() => { const fetchProfile = useCallback(() => {
getCelebrityById(id) getCelebrityById(id)
.then((data) => { .then((data) => {
@@ -43,12 +44,10 @@ function CelebrityProfile() {
} }
}, [id, navigate, fetchProfile]); }, [id, navigate, fetchProfile]);
// Effetto per caricare i file quando `selectedFiles` cambia
useEffect(() => { useEffect(() => {
if (selectedFiles && selectedFiles.length > 0) { if (selectedFiles && selectedFiles.length > 0) {
handleUpload(); handleUpload();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFiles]); }, [selectedFiles]);
const handleFieldSave = async (fieldName, newValue) => { const handleFieldSave = async (fieldName, newValue) => {
@@ -60,7 +59,7 @@ function CelebrityProfile() {
try { try {
const updatedCelebrity = await updateCelebrity(id, payload); const updatedCelebrity = await updateCelebrity(id, payload);
setCelebrity(prev => ({ ...prev, ...updatedCelebrity })); // Aggiornamento pi├╣ robusto setCelebrity(prev => ({ ...prev, ...updatedCelebrity }));
} catch (err) { } catch (err) {
console.error(`Errore durante il salvataggio del campo ${fieldName}:`, err); console.error(`Errore durante il salvataggio del campo ${fieldName}:`, err);
throw err; throw err;
@@ -68,10 +67,10 @@ function CelebrityProfile() {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (window.confirm(`Sei sicuro di voler eliminare ${celebrity.name}? L'azione ├¿ irreversibile.`)) { if (window.confirm(`Sei sicuro di voler eliminare ${celebrity.name}? L'azione è irreversibile.`)) {
try { try {
await deleteCelebrity(id); await deleteCelebrity(id);
alert(`${celebrity.name} ├¿ stato eliminato con successo.`); alert(`${celebrity.name} è stato eliminato con successo.`);
navigate('/'); navigate('/');
} catch (err) { } catch (err) {
setError(`Errore durante l'eliminazione: ${err.message}`); setError(`Errore durante l'eliminazione: ${err.message}`);
@@ -79,6 +78,39 @@ function CelebrityProfile() {
} }
}; };
// --- Gestori per gli Alias ---
const handleAddAlias = async (e) => {
e.preventDefault();
if (!newAlias.trim()) return;
setIsAddingAlias(true);
try {
const addedAlias = await addAlias(id, newAlias.trim());
setCelebrity(prev => ({
...prev,
aliases: [...prev.aliases, addedAlias].sort((a, b) => a.alias_name.localeCompare(b.alias_name))
}));
setNewAlias('');
} catch (err) {
setError(`Errore durante l'aggiunta dell'alias: ${err.message}`);
} finally {
setIsAddingAlias(false);
}
};
const handleDeleteAlias = async (aliasId) => {
if (window.confirm("Sei sicuro di voler eliminare questo alias?")) {
try {
await deleteAlias(aliasId);
setCelebrity(prev => ({
...prev,
aliases: prev.aliases.filter(a => a.id !== aliasId)
}));
} catch (err) {
setError(`Errore durante l'eliminazione dell'alias: ${err.message}`);
}
}
};
// --- Gestori per le Immagini --- // --- Gestori per le Immagini ---
const handleFileChange = (e) => { const handleFileChange = (e) => {
if (e.target.files.length > 0) { if (e.target.files.length > 0) {
@@ -91,7 +123,7 @@ function CelebrityProfile() {
setIsUploading(true); setIsUploading(true);
setError(null); setError(null);
try { try {
await uploadImages(id, selectedFiles); // Chiama la nuova funzione plurale await uploadImages(id, selectedFiles);
fetchProfile(); fetchProfile();
} catch (err) { } catch (err) {
setError(`Upload fallito: ${err.message}`); setError(`Upload fallito: ${err.message}`);
@@ -108,7 +140,7 @@ function CelebrityProfile() {
setError(null); setError(null);
try { try {
await addImageFromUrl(id, imageUrl); await addImageFromUrl(id, imageUrl);
setImageUrl(''); // Pulisce l'input setImageUrl('');
fetchProfile(); fetchProfile();
} catch (err) { } catch (err) {
setError(`Fetch da URL fallito: ${err.message}`); setError(`Fetch da URL fallito: ${err.message}`);
@@ -120,7 +152,7 @@ function CelebrityProfile() {
const handleSetProfileImage = async (imageId) => { const handleSetProfileImage = async (imageId) => {
try { try {
const updatedCelebrity = await setProfileImage(id, imageId); const updatedCelebrity = await setProfileImage(id, imageId);
setCelebrity(updatedCelebrity); // Aggiorna lo stato con i dati freschi dal server setCelebrity(updatedCelebrity);
} catch (err) { } catch (err) {
setError(`Errore nell'impostare l'immagine: ${err.message}`); setError(`Errore nell'impostare l'immagine: ${err.message}`);
} }
@@ -130,17 +162,16 @@ function CelebrityProfile() {
if (window.confirm("Sei sicuro di voler eliminare questa immagine?")) { if (window.confirm("Sei sicuro di voler eliminare questa immagine?")) {
try { try {
await deleteImage(imageId); await deleteImage(imageId);
fetchProfile(); // Ricarica per aggiornare la galleria fetchProfile();
} catch (err) { } catch (err) {
setError(`Errore nell'eliminazione dell'immagine: ${err.message}`); setError(`Errore nell'eliminazione dell'immagine: ${err.message}`);
} }
} }
}; };
// --- Gestori per Drag & Drop ---
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(true); }; const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(true); };
const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(false); }; const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(false); };
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); }; // Necessario per permettere il drop const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
const handleDrop = (e) => { const handleDrop = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -151,10 +182,9 @@ function CelebrityProfile() {
} }
}; };
if (loading) return <article aria-busy="true">Caricamento profilo...</article>; if (loading) return <article aria-busy="true">Caricamento profilo...</article>;
if (error && !celebrity) return <article><p className="error-message">Errore: {error}</p></article>; if (error && !celebrity) return <article><p className="error-message">Errore: {error}</p></article>;
if (!celebrity) return <p>Nessuna celebritá trovata.</p>; if (!celebrity) return <p>Nessuna celebrità trovata.</p>;
const profileImageUrl = celebrity.profile_image const profileImageUrl = celebrity.profile_image
? `/api/uploads/${celebrity.profile_image.file_path}` ? `/api/uploads/${celebrity.profile_image.file_path}`
@@ -163,7 +193,7 @@ function CelebrityProfile() {
const genderOptions = [{ value: 'female', label: 'Female' }, { value: 'male', label: 'Male' }, { value: 'other', label: 'Other' }]; const genderOptions = [{ value: 'female', label: 'Female' }, { value: 'male', label: 'Male' }, { value: 'other', label: 'Other' }];
const braSystemOptions = [{ value: 'US', label: 'US' }, { value: 'UK', label: 'UK' }, { value: 'EU', label: 'EU' }, { value: 'FR', label: 'FR' }, { value: 'AU', label: 'AU' }, { value: 'IT', label: 'IT' }, { value: 'JP', label: 'JP' }]; const braSystemOptions = [{ value: 'US', label: 'US' }, { value: 'UK', label: 'UK' }, { value: 'EU', label: 'EU' }, { value: 'FR', label: 'FR' }, { value: 'AU', label: 'AU' }, { value: 'IT', label: 'IT' }, { value: 'JP', label: 'JP' }];
const shoeSystemOptions = [{ value: 'EU', label: 'EU' }, { value: 'US', label: 'US' }, { value: 'UK', label: 'UK' }]; const shoeSystemOptions = [{ value: 'EU', label: 'EU' }, { value: 'US', label: 'US' }, { value: 'UK', label: 'UK' }];
const booleanOptions = [{ value: 'true', label: 'S├¼' }, { value: 'false', label: 'No' }]; const booleanOptions = [{ value: 'true', label: 'Sì' }, { value: 'false', label: 'No' }];
return ( return (
<div className="profile-container"> <div className="profile-container">
@@ -183,6 +213,33 @@ function CelebrityProfile() {
<main className="profile-content"> <main className="profile-content">
{error && <p className="error-message"><strong>Errore:</strong> {error}</p>} {error && <p className="error-message"><strong>Errore:</strong> {error}</p>}
<details>
<summary><h3>Alias e Pseudonimi ({celebrity.aliases.length})</h3></summary>
<div className="alias-section">
<ul className="alias-list">
{celebrity.aliases.map(alias => (
<li key={alias.id}>
<span>{alias.alias_name}</span>
<button onClick={() => handleDeleteAlias(alias.id)} className="outline secondary button-icon" title="Elimina alias">&times;</button>
</li>
))}
</ul>
{celebrity.aliases.length === 0 && <p><em>Nessun alias registrato.</em></p>}
<form onSubmit={handleAddAlias} className="alias-add-form">
<input
type="text"
placeholder="Aggiungi nuovo alias..."
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
disabled={isAddingAlias}
/>
<button type="submit" disabled={!newAlias.trim() || isAddingAlias} aria-busy={isAddingAlias}>
Aggiungi
</button>
</form>
</div>
</details>
<details open> <details open>
<summary><h3>Galleria e Upload</h3></summary> <summary><h3>Galleria e Upload</h3></summary>
<div className="gallery-grid"> <div className="gallery-grid">
@@ -190,12 +247,8 @@ function CelebrityProfile() {
<div key={img.id} className="gallery-item"> <div key={img.id} className="gallery-item">
<img src={`/api/uploads/${img.file_path}`} alt={`Immagine di ${celebrity.name}`} /> <img src={`/api/uploads/${img.file_path}`} alt={`Immagine di ${celebrity.name}`} />
<div className="gallery-item-actions"> <div className="gallery-item-actions">
<button onClick={() => handleSetProfileImage(img.id)} disabled={celebrity.profile_image_id === img.id} title="Imposta come immagine profilo"> <button onClick={() => handleSetProfileImage(img.id)} disabled={celebrity.profile_image_id === img.id} title="Imposta come immagine profilo">Profilo</button>
Profilo <button onClick={() => handleDeleteImage(img.id)} className="secondary" title="Elimina immagine">X</button>
</button>
<button onClick={() => handleDeleteImage(img.id)} className="secondary" title="Elimina immagine">
X
</button>
</div> </div>
</div> </div>
))} ))}
@@ -203,36 +256,16 @@ function CelebrityProfile() {
<div className="upload-section"> <div className="upload-section">
<div> <div>
<h4>Carica da File</h4> <h4>Carica da File</h4>
<div <div className={`drop-zone ${isDraggingOver ? 'dragging-over' : ''}`} onClick={() => fileInputRef.current.click()} onDrop={handleDrop} onDragOver={handleDragOver} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} aria-busy={isUploading}>
className={`drop-zone ${isDraggingOver ? 'dragging-over' : ''}`}
onClick={() => fileInputRef.current.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
aria-busy={isUploading}
>
<input type="file" ref={fileInputRef} onChange={handleFileChange} hidden multiple /> <input type="file" ref={fileInputRef} onChange={handleFileChange} hidden multiple />
{isUploading {isUploading ? <p>Caricamento di {selectedFiles.length} file...</p> : <p>Trascina i file qui, o <strong>clicca per selezionare</strong>.</p>}
? <p>Caricamento di {selectedFiles.length} file...</p>
: <p>Trascina i file qui, o <strong>clicca per selezionare</strong>.</p>
}
</div> </div>
</div> </div>
<div> <div>
<h4>Aggiungi da URL</h4> <h4>Aggiungi da URL</h4>
<div className="grid"> <div className="grid">
<input <input type="url" placeholder="https://esempio.com/immagine.jpg" value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} disabled={isFetchingFromUrl} />
type="url" <button onClick={handleFetchFromUrl} disabled={!imageUrl || isFetchingFromUrl} aria-busy={isFetchingFromUrl}>{isFetchingFromUrl ? '...' : 'Aggiungi'}</button>
placeholder="https://esempio.com/immagine.jpg"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
disabled={isFetchingFromUrl}
/>
<button onClick={handleFetchFromUrl} disabled={!imageUrl || isFetchingFromUrl} aria-busy={isFetchingFromUrl}>
{isFetchingFromUrl ? '...' : 'Aggiungi'}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -243,9 +276,9 @@ function CelebrityProfile() {
<div className="profile-grid"> <div className="profile-grid">
<EditableField label="Data di nascita" name="birth_date" value={celebrity.birth_date} type="date" onSave={handleFieldSave} /> <EditableField label="Data di nascita" name="birth_date" value={celebrity.birth_date} type="date" onSave={handleFieldSave} />
<EditableField label="Luogo di nascita" name="birth_place" value={celebrity.birth_place} onSave={handleFieldSave} /> <EditableField label="Luogo di nascita" name="birth_place" value={celebrity.birth_place} onSave={handleFieldSave} />
<EditableField label="Nazionalit├á" name="nationality" value={celebrity.nationality} onSave={handleFieldSave} /> <EditableField label="Nazionalità" name="nationality" value={celebrity.nationality} onSave={handleFieldSave} />
<EditableField label="Genere" name="gender" value={celebrity.gender} type="select" options={genderOptions} onSave={handleFieldSave} /> <EditableField label="Genere" name="gender" value={celebrity.gender} type="select" options={genderOptions} onSave={handleFieldSave} />
<EditableField label="Sessualit├á" name="sexuality" value={celebrity.sexuality} onSave={handleFieldSave} /> <EditableField label="Sessualità" name="sexuality" value={celebrity.sexuality} onSave={handleFieldSave} />
</div> </div>
<EditableField label="Sito Ufficiale" name="official_website" value={celebrity.official_website} type="url" onSave={handleFieldSave} /> <EditableField label="Sito Ufficiale" name="official_website" value={celebrity.official_website} type="url" onSave={handleFieldSave} />
</details> </details>

View File

@@ -3,13 +3,17 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
const handleResponse = async (response) => { const handleResponse = async (response) => {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.detail || 'Si ├¿ verificato un errore'); throw new Error(errorData.detail || 'Si è verificato un errore');
} }
return response.json(); return response.json();
}; };
export const getCelebrities = async () => { export const getCelebrities = async (searchTerm = '') => {
const response = await fetch(`${API_BASE_URL}/celebrities/`); const params = new URLSearchParams();
if (searchTerm) {
params.append('search', searchTerm);
}
const response = await fetch(`${API_BASE_URL}/celebrities/?${params.toString()}`);
return handleResponse(response); return handleResponse(response);
}; };
@@ -47,22 +51,35 @@ export const deleteCelebrity = async (id) => {
return response.json(); // O un messaggio di successo return response.json(); // O un messaggio di successo
}; };
// --- NUOVE FUNZIONI PER LE IMMAGINI --- // --- NUOVE FUNZIONI PER GLI ALIAS ---
export const uploadImage = async (celebrityId, file) => { export const addAlias = async (celebrityId, aliasName) => {
const formData = new FormData(); const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/aliases`, {
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/images`, {
method: 'POST', method: 'POST',
body: formData, // Il browser imposta automaticamente l'header Content-Type corretto headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias_name: aliasName }),
}); });
return handleResponse(response); return handleResponse(response);
}; };
export const deleteAlias = async (aliasId) => {
const response = await fetch(`${API_BASE_URL}/celebrities/aliases/${aliasId}`, {
method: 'DELETE',
});
if (response.status === 204) {
return { success: true }; // Nessun contenuto da parsare
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Errore durante l'eliminazione dell'alias");
}
return response.json();
};
// --- FUNZIONI PER LE IMMAGINI ---
export const uploadImages = async (celebrityId, files) => { export const uploadImages = async (celebrityId, files) => {
const formData = new FormData(); const formData = new FormData();
// Aggiunge ogni file allo stesso campo 'files'. Il backend lo interpreterà come una lista.
files.forEach(file => { files.forEach(file => {
formData.append('files', file); formData.append('files', file);
}); });