upload from file and url

This commit is contained in:
Nicola Malizia
2025-10-10 20:50:46 +02:00
parent 82a342ca25
commit a3a0bfc971
16 changed files with 466 additions and 35 deletions

View File

@@ -1,8 +1,13 @@
from sqlalchemy.orm import Session import os
from sqlalchemy.orm import Session, joinedload
from . import models, schemas from . import models, schemas
def get_celebrity(db: Session, celebrity_id: int): def get_celebrity(db: Session, celebrity_id: int):
return db.query(models.Celebrity).filter(models.Celebrity.id == celebrity_id).first() # 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)
).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):
return db.query(models.Celebrity).offset(skip).limit(limit).all() return db.query(models.Celebrity).offset(skip).limit(limit).all()
@@ -34,4 +39,49 @@ def delete_celebrity(db: Session, celebrity_id: int):
db.delete(db_celebrity) db.delete(db_celebrity)
db.commit() db.commit()
return db_celebrity return db_celebrity
# --- Funzioni CRUD per le Immagini ---
def get_image(db: Session, image_id: int):
return db.query(models.Image).filter(models.Image.id == image_id).first()
def create_celebrity_image(db: Session, celebrity_id: int, file_path: str):
db_image = models.Image(celebrity_id=celebrity_id, file_path=file_path)
db.add(db_image)
db.commit()
db.refresh(db_image)
return db_image
def set_profile_image(db: Session, celebrity_id: int, image_id: int):
db_celebrity = get_celebrity(db, celebrity_id)
db_image = get_image(db, image_id)
if not db_celebrity or not db_image or db_image.celebrity_id != celebrity_id:
return None
db_celebrity.profile_image_id = image_id
db.commit()
db.refresh(db_celebrity)
return get_celebrity(db, celebrity_id) # Ritorna con le relazioni aggiornate
def delete_image(db: Session, image_id: int):
db_image = get_image(db, image_id)
if not db_image:
return None
db_celebrity = get_celebrity(db, db_image.celebrity_id)
if db_celebrity and db_celebrity.profile_image_id == image_id:
db_celebrity.profile_image_id = None
db.add(db_celebrity)
try:
file_on_disk = os.path.join("uploads", db_image.file_path)
if os.path.exists(file_on_disk):
os.remove(file_on_disk)
except OSError as e:
print(f"Error deleting file {file_on_disk}: {e}")
db.delete(db_image)
db.commit()
return db_image

View File

@@ -1,8 +1,13 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from .routers import celebrities # Importa il nuovo router from .routers import celebrities # Importa il nuovo router
app = FastAPI() app = FastAPI()
# Monta la directory 'uploads' per servire i file statici (immagini caricate)
# Ora saranno accessibili tramite l'URL /api/uploads/<nome_file>
app.mount("/api/uploads", StaticFiles(directory="uploads"), name="uploads")
# Includi il router delle celebrities nell'app principale # Includi il router delle celebrities nell'app principale
app.include_router(celebrities.router) app.include_router(celebrities.router)

View File

@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
import uuid
import shutil
from .. import crud, models, schemas from .. import crud, models, schemas
from ..database import get_db from ..database import get_db
@@ -13,7 +15,7 @@ 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])
@@ -40,4 +42,80 @@ def delete_celebrity(celebrity_id: int, db: Session = Depends(get_db)):
db_celebrity = crud.delete_celebrity(db, celebrity_id=celebrity_id) db_celebrity = crud.delete_celebrity(db, celebrity_id=celebrity_id)
if db_celebrity is None: if db_celebrity is None:
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 ---
@router.post("/{celebrity_id}/images", response_model=schemas.Image, status_code=201)
def upload_celebrity_image(celebrity_id: int, file: UploadFile = File(...), 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")
file_extension = file.filename.split('.')[-1]
unique_filename = f"{uuid.uuid4()}.{file_extension}"
file_location = f"uploads/{unique_filename}"
with open(file_location, "wb+") as file_object:
shutil.copyfileobj(file.file, file_object)
return crud.create_celebrity_image(db=db, celebrity_id=celebrity_id, file_path=unique_filename)
@router.post("/{celebrity_id}/images/from-url", response_model=schemas.Image, status_code=201)
async def add_image_from_url(celebrity_id: int, request: schemas.ImageUrlRequest, db: Session = Depends(get_db)):
db_celebrity = crud.get_celebrity(db, celebrity_id=celebrity_id)
if not db_celebrity:
raise HTTPException(status_code=404, detail="Celebrity not found")
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()
content_type = head_response.headers.get('content-type')
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'
unique_filename = f"{uuid.uuid4()}.{file_extension}"
file_location = f"uploads/{unique_filename}"
with open(file_location, "wb") as file_object:
file_object.write(get_response.content)
return crud.create_celebrity_image(db=db, celebrity_id=celebrity_id, file_path=unique_filename)
except httpx.RequestError as exc:
raise HTTPException(status_code=400, detail=f"Failed to fetch image from URL: {exc}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"An internal error occurred: {e}")
@router.put("/{celebrity_id}/profile-image", response_model=schemas.Celebrity)
def set_celebrity_profile_image(celebrity_id: int, request: schemas.SetProfileImageRequest, db: Session = Depends(get_db)):
updated_celebrity = crud.set_profile_image(db, celebrity_id=celebrity_id, image_id=request.image_id)
if updated_celebrity is None:
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)
if db_image is None:
raise HTTPException(status_code=404, detail="Image not found")
return db_image

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, HttpUrl
from typing import Optional, List from typing import Optional, List
from datetime import date, datetime from datetime import date, datetime
from .models import GenderType, ShoeSystemType, BraSystemType, SurgeryType from .models import GenderType, ShoeSystemType, BraSystemType, SurgeryType
@@ -74,6 +74,12 @@ class Image(ImageBase):
uploaded_at: datetime uploaded_at: datetime
class Config: from_attributes = True class Config: from_attributes = True
# --- Richiesta per impostare l'immagine del profilo ---
class SetProfileImageRequest(BaseModel):
image_id: int
class ImageUrlRequest(BaseModel):
url: HttpUrl # Pydantic valida automaticamente che sia un URL valido
# ============================================================================= # =============================================================================
# SCHEMI PER CELEBRITY # SCHEMI PER CELEBRITY
@@ -107,7 +113,7 @@ class CelebrityBase(BaseModel):
official_website: Optional[str] = None official_website: Optional[str] = None
profile_image_id: Optional[int] = None profile_image_id: Optional[int] = None
# Schema per la creazione di una nuova celebrità (eredita da Base) # Schema per la creazione di una nuova celebrit├á (eredita da Base)
class CelebrityCreate(CelebrityBase): class CelebrityCreate(CelebrityBase):
pass pass
@@ -146,11 +152,12 @@ class Celebrity(CelebrityBase):
updated_at: datetime updated_at: datetime
# Campi relazionali che verranno popolati automaticamente da SQLAlchemy # Campi relazionali che verranno popolati automaticamente da SQLAlchemy
profile_image: Optional[Image] = None
images: List[Image] = [] images: List[Image] = []
tattoos: List[Tattoo] = [] tattoos: List[Tattoo] = []
aliases: List[CelebrityAlias] = [] aliases: List[CelebrityAlias] = []
# Per semplicità, possiamo usare qui gli schemi di base, # Per semplicit├á, possiamo usare qui gli schemi di base,
# ma in un'app reale potresti volere schemi specifici per la "lettura". # ma in un'app reale potresti volere schemi specifici per la "lettura".
professions: List[Profession] = [] professions: List[Profession] = []
studios: List[Studio] = [] studios: List[Studio] = []

View File

@@ -1,6 +1,7 @@
# FastAPI e server # FastAPI e server
fastapi fastapi
uvicorn[standard] uvicorn[standard]
python-multipart
# Pydantic per la validazione e le impostazioni # Pydantic per la validazione e le impostazioni
pydantic pydantic
@@ -9,4 +10,7 @@ pydantic-settings
# Database # Database
sqlalchemy sqlalchemy
psycopg2-binary psycopg2-binary
alembic alembic
# Per richieste HTTP (scaricare immagini da URL)
httpx

View File

@@ -18,12 +18,13 @@
text-align: center; text-align: center;
} }
.profile-sidebar img { .profile-sidebar .profile-main-image {
border-radius: var(--border-radius); border-radius: var(--border-radius);
margin-bottom: 1rem; margin-bottom: 1rem;
width: 100%; width: 100%;
height: auto; 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 */
} }
.profile-sidebar .editable-field-container { .profile-sidebar .editable-field-container {
@@ -78,4 +79,101 @@
.error-message { .error-message {
color: var(--pico-color-red-500); color: var(--pico-color-red-500);
}
/* --- Stili per Galleria e Upload --- */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.gallery-item {
position: relative;
border-radius: var(--border-radius);
overflow: hidden;
border: 1px solid var(--pico-card-border-color);
}
.gallery-item img {
display: block;
width: 100%;
height: 150px;
object-fit: cover;
}
.gallery-item-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
padding: 0.25rem;
display: flex;
justify-content: space-around;
transform: translateY(100%);
transition: transform 0.2s ease-in-out;
}
.gallery-item:hover .gallery-item-actions {
transform: translateY(0);
}
.gallery-item-actions button {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
margin: 0;
--pico-font-weight: 500;
}
.gallery-item-actions button:disabled {
background-color: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-color: var(--pico-primary-background);
opacity: 1;
}
.upload-section {
margin-top: 1.5rem;
padding: 1rem;
border: 1px solid var(--pico-card-border-color);
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
gap: 1.5rem; /* Aumentato lo spazio */
}
.upload-section h4 {
margin: 0 0 0.5rem 0;
}
.upload-section .grid {
grid-template-columns: 1fr auto;
gap: 1rem;
margin: 0;
align-items: center; /* Allinea verticalmente */
}
.upload-section input {
margin: 0;
}
.upload-section button {
margin: 0;
}
.drop-zone {
padding: 2rem;
border: 2px dashed var(--pico-muted-border-color);
border-radius: var(--border-radius);
text-align: center;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
background-color: var(--pico-card-background-color);
}
.drop-zone:hover {
border-color: var(--pico-primary-hover-border);
}
.drop-zone.dragging-over {
background-color: var(--pico-primary-background);
border-color: var(--pico-primary);
color: var(--pico-primary-inverse);
}
.drop-zone p {
margin: 0;
color: var(--pico-secondary);
}
.drop-zone strong {
color: var(--pico-primary);
} }

View File

@@ -1,8 +1,8 @@
// packages/frontend/src/components/CelebrityProfile.jsx // packages/frontend/src/components/CelebrityProfile.jsx
import React, { useState, useEffect } 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 } from '../services/api'; import { getCelebrityById, updateCelebrity, deleteCelebrity, uploadImage, setProfileImage, deleteImage, addImageFromUrl } from '../services/api';
import EditableField from './EditableField'; import EditableField from './EditableField';
import './CelebrityProfile.css'; import './CelebrityProfile.css';
@@ -13,22 +13,42 @@ 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 [selectedFile, setSelectedFile] = 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);
const fetchProfile = useCallback(() => {
getCelebrityById(id)
.then((data) => {
if (data.birth_date) {
data.birth_date = data.birth_date.split('T')[0];
}
setCelebrity(data);
setError(null);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
setLoading(true); setLoading(true);
getCelebrityById(id) fetchProfile();
.then((data) => {
if (data.birth_date) {
data.birth_date = data.birth_date.split('T')[0];
}
setCelebrity(data);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
} else { } else {
navigate('/'); navigate('/');
} }
}, [id, navigate]); }, [id, navigate, fetchProfile]);
// Effetto per caricare il file quando `selectedFile` cambia
useEffect(() => {
if (selectedFile) {
handleUpload();
}
}, [selectedFile]);
const handleFieldSave = async (fieldName, newValue) => { const handleFieldSave = async (fieldName, newValue) => {
let valueToSend = newValue === '' ? null : newValue; let valueToSend = newValue === '' ? null : newValue;
@@ -39,8 +59,7 @@ function CelebrityProfile() {
try { try {
const updatedCelebrity = await updateCelebrity(id, payload); const updatedCelebrity = await updateCelebrity(id, payload);
setCelebrity((prev) => ({ ...prev, [fieldName]: newValue })); setCelebrity(prev => ({ ...prev, ...updatedCelebrity })); // Aggiornamento pi├╣ robusto
console.log('Salvataggio riuscito:', 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;
@@ -48,31 +67,104 @@ 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}`);
} }
} }
}; };
// --- Gestori per le Immagini ---
const handleFileChange = (e) => setSelectedFile(e.target.files[0]);
const handleUpload = async () => {
if (!selectedFile) return;
setIsUploading(true);
setError(null);
try {
await uploadImage(id, selectedFile);
setSelectedFile(null);
document.querySelector('input[type="file"]').value = ""; // Reset del campo file
fetchProfile(); // Ricarica i dati per vedere la nuova immagine
} catch (err) {
setError(`Upload fallito: ${err.message}`);
} finally {
setIsUploading(false);
}
};
const handleFetchFromUrl = async () => {
if (!imageUrl) return;
setIsFetchingFromUrl(true);
setError(null);
try {
await addImageFromUrl(id, imageUrl);
setImageUrl(''); // Pulisce l'input
fetchProfile();
} catch (err) {
setError(`Fetch da URL fallito: ${err.message}`);
} finally {
setIsFetchingFromUrl(false);
}
};
const handleSetProfileImage = async (imageId) => {
try {
const updatedCelebrity = await setProfileImage(id, imageId);
setCelebrity(updatedCelebrity); // Aggiorna lo stato con i dati freschi dal server
} catch (err) {
setError(`Errore nell'impostare l'immagine: ${err.message}`);
}
};
const handleDeleteImage = async (imageId) => {
if (window.confirm("Sei sicuro di voler eliminare questa immagine?")) {
try {
await deleteImage(imageId);
fetchProfile(); // Ricarica per aggiornare la galleria
} 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 handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
setSelectedFile(files[0]);
}
};
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
? `/api/uploads/${celebrity.profile_image.file_path}`
: 'https://via.placeholder.com/400x550.png?text=Nessuna+Immagine';
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">
<aside className="profile-sidebar"> <aside className="profile-sidebar">
<figure> <figure>
<img src={`https://i.pravatar.cc/400?u=${id}`} alt={celebrity.name} /> <img src={profileImageUrl} alt={celebrity.name} className="profile-main-image" />
<figcaption> <figcaption>
<EditableField label="Nome" name="name" value={celebrity.name} onSave={handleFieldSave} /> <EditableField label="Nome" name="name" value={celebrity.name} onSave={handleFieldSave} />
</figcaption> </figcaption>
@@ -84,16 +176,68 @@ function CelebrityProfile() {
</aside> </aside>
<main className="profile-content"> <main className="profile-content">
{error && <p className="error-message">Errore: {error}</p>} {error && <p className="error-message"><strong>Errore:</strong> {error}</p>}
<details open> <details open>
<summary><h3>Galleria e Upload</h3></summary>
<div className="gallery-grid">
{celebrity.images.map(img => (
<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>
</div>
</div>
))}
</div>
<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}
>
<input type="file" ref={fileInputRef} onChange={handleFileChange} hidden />
{isUploading ? <p>Caricamento...</p> : <p>Trascina un 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>
</div>
</div>
</div>
</details>
<details>
<summary><h3>Dati Anagrafici</h3></summary> <summary><h3>Dati Anagrafici</h3></summary>
<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,7 +3,7 @@ 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();
}; };
@@ -45,4 +45,46 @@ export const deleteCelebrity = async (id) => {
throw new Error(errorData.detail || 'Errore durante l\'eliminazione'); throw new Error(errorData.detail || 'Errore durante l\'eliminazione');
} }
return response.json(); // O un messaggio di successo return response.json(); // O un messaggio di successo
};
// --- NUOVE FUNZIONI PER LE IMMAGINI ---
export const uploadImage = async (celebrityId, file) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/images`, {
method: 'POST',
body: formData, // Il browser imposta automaticamente l'header Content-Type corretto
});
return handleResponse(response);
};
export const setProfileImage = async (celebrityId, imageId) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/profile-image`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId }),
});
return handleResponse(response);
};
export const deleteImage = async (imageId) => {
const response = await fetch(`${API_BASE_URL}/celebrities/images/${imageId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Errore durante l'eliminazione dell'immagine");
}
return response.json();
};
export const addImageFromUrl = async (celebrityId, imageUrl) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/images/from-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: imageUrl }),
});
return handleResponse(response);
}; };

View File

@@ -1,6 +1,9 @@
server { server {
listen 80; listen 80;
# Aumenta la dimensione massima del corpo della richiesta a 10GBS
client_max_body_size 10g;
# Indirizza tutte le richieste che iniziano con /api al servizio backend # Indirizza tutte le richieste che iniziano con /api al servizio backend
location /api { location /api {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB