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 sqlalchemy as sa
from sqlalchemy.orm import Session, joinedload
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
return db.query(models.Celebrity).options(
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()
def get_celebrities(db: Session, skip: int = 0, limit: int = 100):
return db.query(models.Celebrity).offset(skip).limit(limit).all()
def get_celebrities(db: Session, skip: int = 0, limit: int = 100, search: str = None):
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):
db_celebrity = models.Celebrity(**celebrity.model_dump())
@@ -41,6 +56,23 @@ def delete_celebrity(db: Session, celebrity_id: int):
db.commit()
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 ---
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 typing import List
from typing import List, Optional
import uuid
import shutil
import httpx
from .. import crud, models, schemas
from ..database import get_db
@@ -15,12 +16,12 @@ router = APIRouter(
@router.post("/", response_model=schemas.Celebrity, status_code=201)
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)
@router.get("/", response_model=List[schemas.Celebrity])
def read_celebrities(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
celebrities = crud.get_celebrities(db, skip=skip, limit=limit)
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, search=search)
return celebrities
@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")
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)
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)
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}")
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)
try:
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.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/'):
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))
if content_length > 10 * 1024 * 1024:
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.raise_for_status()
# Determina l'estensione del file
file_extension = content_type.split('/')[-1]
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.")
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)
def delete_image(image_id: int, db: Session = Depends(get_db)):
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 { Link, useNavigate } from 'react-router-dom';
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 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>;
function CelebrityList() {
const [celebrities, setCelebrities] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
// Stato per la paginazione
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
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(() => {
fetchCelebrities();
}, []);
}, [debouncedSearchTerm]);
const fetchCelebrities = async () => {
try {
setLoading(true);
const data = await getCelebrities();
const data = await getCelebrities(debouncedSearchTerm);
setCelebrities(data);
setError(null);
} catch (err) {
@@ -51,24 +58,18 @@ function CelebrityList() {
}
};
// Memoizzazione dei dati filtrati e paginati per performance
const filteredCelebrities = useMemo(() => {
return celebrities.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [celebrities, searchTerm]);
// La paginazione ora opera sulla lista (già filtrata) ricevuta dal backend
const paginatedCelebrities = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredCelebrities.slice(startIndex, startIndex + itemsPerPage);
}, [filteredCelebrities, currentPage, itemsPerPage]);
return celebrities.slice(startIndex, startIndex + itemsPerPage);
}, [celebrities, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredCelebrities.length / itemsPerPage);
const totalPages = Math.ceil(celebrities.length / itemsPerPage);
const renderPagination = () => (
<div className="pagination-controls">
<span>
Pagina {currentPage} di {totalPages}
Pagina {currentPage} di {totalPages} ({celebrities.length} risultati)
</span>
<div className="grid">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}>
@@ -89,8 +90,8 @@ function CelebrityList() {
const renderEmptyState = () => (
<div className="empty-state">
<h3>Nessun Profilo Trovato</h3>
<p>La tua lista è vuota. Inizia aggiungendo una nuova celebrità.</p>
<Link to="/celebrity/new" role="button" className="primary">+ Aggiungi la prima celebrità</Link>
<p>{searchTerm ? "Prova a modificare i termini della ricerca." : "Inizia aggiungendo una nuova celebrità."}</p>
<Link to="/celebrity/new" role="button" className="primary">+ Aggiungi una celebrità</Link>
</div>
);
@@ -106,18 +107,18 @@ function CelebrityList() {
<div className="list-controls">
<input
type="search"
placeholder="Cerca per nome..."
placeholder="Cerca per nome o alias..."
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</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>}
{!loading && !error && (
<>
{celebrities.length === 0 ? renderEmptyState() : (
{paginatedCelebrities.length === 0 ? renderEmptyState() : (
<>
<div className="table-responsive">
<table>
@@ -135,7 +136,7 @@ function CelebrityList() {
{paginatedCelebrities.map((celeb) => (
<tr key={celeb.id}>
<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>
<Link to={`/celebrity/${celeb.id}`} className="celeb-link">

View File

@@ -24,7 +24,7 @@
width: 100%;
height: 400px; /* Altezza fissa per l'immagine profilo */
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 {
@@ -32,7 +32,7 @@
}
.profile-sidebar .editable-field-container label {
display: none; /* Nasconde l'etichetta "Nome" sotto la foto */
display: none;
}
.profile-sidebar .display-value {
font-size: 1.5rem;
@@ -47,7 +47,7 @@
}
.profile-content {
flex: 1 1 auto; /* Occupa lo spazio rimanente */
flex: 1 1 auto;
min-width: 0;
}
@@ -81,6 +81,43 @@
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 --- */
.gallery-grid {
display: grid;
@@ -135,7 +172,7 @@
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
gap: 1.5rem; /* Aumentato lo spazio */
gap: 1.5rem;
}
.upload-section h4 {
margin: 0 0 0.5rem 0;
@@ -144,7 +181,7 @@
grid-template-columns: 1fr auto;
gap: 1rem;
margin: 0;
align-items: center; /* Allinea verticalmente */
align-items: center;
}
.upload-section input {
margin: 0;

View File

@@ -1,8 +1,6 @@
// packages/frontend/src/components/CelebrityProfile.jsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
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 './CelebrityProfile.css';
@@ -13,14 +11,17 @@ function CelebrityProfile() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Stati per la gestione upload
const [selectedFiles, setSelectedFiles] = useState(null); // Da file a files
const [selectedFiles, setSelectedFiles] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [isFetchingFromUrl, setIsFetchingFromUrl] = useState(false);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const fileInputRef = useRef(null);
// Stati per la gestione degli alias
const [newAlias, setNewAlias] = useState('');
const [isAddingAlias, setIsAddingAlias] = useState(false);
const fetchProfile = useCallback(() => {
getCelebrityById(id)
.then((data) => {
@@ -43,12 +44,10 @@ function CelebrityProfile() {
}
}, [id, navigate, fetchProfile]);
// Effetto per caricare i file quando `selectedFiles` cambia
useEffect(() => {
if (selectedFiles && selectedFiles.length > 0) {
handleUpload();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFiles]);
const handleFieldSave = async (fieldName, newValue) => {
@@ -60,7 +59,7 @@ function CelebrityProfile() {
try {
const updatedCelebrity = await updateCelebrity(id, payload);
setCelebrity(prev => ({ ...prev, ...updatedCelebrity })); // Aggiornamento pi├╣ robusto
setCelebrity(prev => ({ ...prev, ...updatedCelebrity }));
} catch (err) {
console.error(`Errore durante il salvataggio del campo ${fieldName}:`, err);
throw err;
@@ -68,10 +67,10 @@ function CelebrityProfile() {
};
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 {
await deleteCelebrity(id);
alert(`${celebrity.name} è stato eliminato con successo.`);
alert(`${celebrity.name} è stato eliminato con successo.`);
navigate('/');
} catch (err) {
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 ---
const handleFileChange = (e) => {
if (e.target.files.length > 0) {
@@ -91,7 +123,7 @@ function CelebrityProfile() {
setIsUploading(true);
setError(null);
try {
await uploadImages(id, selectedFiles); // Chiama la nuova funzione plurale
await uploadImages(id, selectedFiles);
fetchProfile();
} catch (err) {
setError(`Upload fallito: ${err.message}`);
@@ -108,7 +140,7 @@ function CelebrityProfile() {
setError(null);
try {
await addImageFromUrl(id, imageUrl);
setImageUrl(''); // Pulisce l'input
setImageUrl('');
fetchProfile();
} catch (err) {
setError(`Fetch da URL fallito: ${err.message}`);
@@ -120,7 +152,7 @@ function CelebrityProfile() {
const handleSetProfileImage = async (imageId) => {
try {
const updatedCelebrity = await setProfileImage(id, imageId);
setCelebrity(updatedCelebrity); // Aggiorna lo stato con i dati freschi dal server
setCelebrity(updatedCelebrity);
} catch (err) {
setError(`Errore nell'impostare l'immagine: ${err.message}`);
}
@@ -130,17 +162,16 @@ function CelebrityProfile() {
if (window.confirm("Sei sicuro di voler eliminare questa immagine?")) {
try {
await deleteImage(imageId);
fetchProfile(); // Ricarica per aggiornare la galleria
fetchProfile();
} catch (err) {
setError(`Errore nell'eliminazione dell'immagine: ${err.message}`);
}
}
};
// --- Gestori per Drag & Drop ---
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setIsDraggingOver(true); };
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) => {
e.preventDefault();
e.stopPropagation();
@@ -151,10 +182,9 @@ function CelebrityProfile() {
}
};
if (loading) return <article aria-busy="true">Caricamento profilo...</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
? `/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 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 booleanOptions = [{ value: 'true', label: 'Sì' }, { value: 'false', label: 'No' }];
const booleanOptions = [{ value: 'true', label: 'Sì' }, { value: 'false', label: 'No' }];
return (
<div className="profile-container">
@@ -183,6 +213,33 @@ function CelebrityProfile() {
<main className="profile-content">
{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>
<summary><h3>Galleria e Upload</h3></summary>
<div className="gallery-grid">
@@ -190,12 +247,8 @@ function CelebrityProfile() {
<div key={img.id} className="gallery-item">
<img src={`/api/uploads/${img.file_path}`} alt={`Immagine di ${celebrity.name}`} />
<div className="gallery-item-actions">
<button onClick={() => handleSetProfileImage(img.id)} disabled={celebrity.profile_image_id === img.id} title="Imposta come immagine profilo">
Profilo
</button>
<button onClick={() => handleDeleteImage(img.id)} className="secondary" title="Elimina immagine">
X
</button>
<button onClick={() => handleSetProfileImage(img.id)} disabled={celebrity.profile_image_id === img.id} title="Imposta come immagine profilo">Profilo</button>
<button onClick={() => handleDeleteImage(img.id)} className="secondary" title="Elimina immagine">X</button>
</div>
</div>
))}
@@ -203,36 +256,16 @@ function CelebrityProfile() {
<div className="upload-section">
<div>
<h4>Carica da File</h4>
<div
className={`drop-zone ${isDraggingOver ? 'dragging-over' : ''}`}
onClick={() => fileInputRef.current.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
aria-busy={isUploading}
>
<div 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 />
{isUploading
? <p>Caricamento di {selectedFiles.length} file...</p>
: <p>Trascina i file qui, o <strong>clicca per selezionare</strong>.</p>
}
{isUploading ? <p>Caricamento di {selectedFiles.length} file...</p> : <p>Trascina i file qui, o <strong>clicca per selezionare</strong>.</p>}
</div>
</div>
<div>
<h4>Aggiungi da URL</h4>
<div className="grid">
<input
type="url"
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>
<input type="url" 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>
@@ -243,9 +276,9 @@ function CelebrityProfile() {
<div className="profile-grid">
<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="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="Sessualità" name="sexuality" value={celebrity.sexuality} onSave={handleFieldSave} />
<EditableField label="Sessualità" name="sexuality" value={celebrity.sexuality} onSave={handleFieldSave} />
</div>
<EditableField label="Sito Ufficiale" name="official_website" value={celebrity.official_website} type="url" onSave={handleFieldSave} />
</details>

View File

@@ -3,13 +3,17 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
const handleResponse = async (response) => {
if (!response.ok) {
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();
};
export const getCelebrities = async () => {
const response = await fetch(`${API_BASE_URL}/celebrities/`);
export const getCelebrities = async (searchTerm = '') => {
const params = new URLSearchParams();
if (searchTerm) {
params.append('search', searchTerm);
}
const response = await fetch(`${API_BASE_URL}/celebrities/?${params.toString()}`);
return handleResponse(response);
};
@@ -47,22 +51,35 @@ export const deleteCelebrity = async (id) => {
return response.json(); // O un messaggio di successo
};
// --- NUOVE FUNZIONI PER LE IMMAGINI ---
// --- NUOVE FUNZIONI PER GLI ALIAS ---
export const uploadImage = async (celebrityId, file) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/images`, {
export const addAlias = async (celebrityId, aliasName) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/aliases`, {
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);
};
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) => {
const formData = new FormData();
// Aggiunge ogni file allo stesso campo 'files'. Il backend lo interpreterà come una lista.
files.forEach(file => {
formData.append('files', file);
});