upload from file and url
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,13 @@
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from . import models, schemas
|
||||
|
||||
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):
|
||||
return db.query(models.Celebrity).offset(skip).limit(limit).all()
|
||||
@@ -35,3 +40,48 @@ def delete_celebrity(db: Session, celebrity_id: int):
|
||||
db.delete(db_celebrity)
|
||||
db.commit()
|
||||
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
|
||||
@@ -1,8 +1,13 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from .routers import celebrities # Importa il nuovo router
|
||||
|
||||
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
|
||||
app.include_router(celebrities.router)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import uuid
|
||||
import shutil
|
||||
|
||||
from .. import crud, models, schemas
|
||||
from ..database import get_db
|
||||
@@ -13,7 +15,7 @@ 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])
|
||||
@@ -41,3 +43,79 @@ def delete_celebrity(celebrity_id: int, db: Session = Depends(get_db)):
|
||||
if db_celebrity is None:
|
||||
raise HTTPException(status_code=404, detail="Celebrity not found")
|
||||
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
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from typing import Optional, List
|
||||
from datetime import date, datetime
|
||||
from .models import GenderType, ShoeSystemType, BraSystemType, SurgeryType
|
||||
@@ -74,6 +74,12 @@ class Image(ImageBase):
|
||||
uploaded_at: datetime
|
||||
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
|
||||
@@ -107,7 +113,7 @@ class CelebrityBase(BaseModel):
|
||||
official_website: Optional[str] = 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):
|
||||
pass
|
||||
|
||||
@@ -146,11 +152,12 @@ class Celebrity(CelebrityBase):
|
||||
updated_at: datetime
|
||||
|
||||
# Campi relazionali che verranno popolati automaticamente da SQLAlchemy
|
||||
profile_image: Optional[Image] = None
|
||||
images: List[Image] = []
|
||||
tattoos: List[Tattoo] = []
|
||||
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".
|
||||
professions: List[Profession] = []
|
||||
studios: List[Studio] = []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# FastAPI e server
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-multipart
|
||||
|
||||
# Pydantic per la validazione e le impostazioni
|
||||
pydantic
|
||||
@@ -10,3 +11,6 @@ pydantic-settings
|
||||
sqlalchemy
|
||||
psycopg2-binary
|
||||
alembic
|
||||
|
||||
# Per richieste HTTP (scaricare immagini da URL)
|
||||
httpx
|
||||
@@ -18,12 +18,13 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-sidebar img {
|
||||
.profile-sidebar .profile-main-image {
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
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 */
|
||||
}
|
||||
|
||||
.profile-sidebar .editable-field-container {
|
||||
@@ -79,3 +80,100 @@
|
||||
.error-message {
|
||||
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);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// 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 { getCelebrityById, updateCelebrity, deleteCelebrity } from '../services/api';
|
||||
import { getCelebrityById, updateCelebrity, deleteCelebrity, uploadImage, setProfileImage, deleteImage, addImageFromUrl } from '../services/api';
|
||||
import EditableField from './EditableField';
|
||||
import './CelebrityProfile.css';
|
||||
|
||||
@@ -13,22 +13,42 @@ function CelebrityProfile() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
// 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(() => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
fetchProfile();
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}, [id, navigate]);
|
||||
}, [id, navigate, fetchProfile]);
|
||||
|
||||
// Effetto per caricare il file quando `selectedFile` cambia
|
||||
useEffect(() => {
|
||||
if (selectedFile) {
|
||||
handleUpload();
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
const handleFieldSave = async (fieldName, newValue) => {
|
||||
let valueToSend = newValue === '' ? null : newValue;
|
||||
@@ -39,8 +59,7 @@ function CelebrityProfile() {
|
||||
|
||||
try {
|
||||
const updatedCelebrity = await updateCelebrity(id, payload);
|
||||
setCelebrity((prev) => ({ ...prev, [fieldName]: newValue }));
|
||||
console.log('Salvataggio riuscito:', updatedCelebrity);
|
||||
setCelebrity(prev => ({ ...prev, ...updatedCelebrity })); // Aggiornamento pi├╣ robusto
|
||||
} catch (err) {
|
||||
console.error(`Errore durante il salvataggio del campo ${fieldName}:`, err);
|
||||
throw err;
|
||||
@@ -48,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}`);
|
||||
@@ -59,20 +78,93 @@ function CelebrityProfile() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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 (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 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">
|
||||
<aside className="profile-sidebar">
|
||||
<figure>
|
||||
<img src={`https://i.pravatar.cc/400?u=${id}`} alt={celebrity.name} />
|
||||
<img src={profileImageUrl} alt={celebrity.name} className="profile-main-image" />
|
||||
<figcaption>
|
||||
<EditableField label="Nome" name="name" value={celebrity.name} onSave={handleFieldSave} />
|
||||
</figcaption>
|
||||
@@ -84,16 +176,68 @@ function CelebrityProfile() {
|
||||
</aside>
|
||||
|
||||
<main className="profile-content">
|
||||
{error && <p className="error-message">Errore: {error}</p>}
|
||||
{error && <p className="error-message"><strong>Errore:</strong> {error}</p>}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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();
|
||||
};
|
||||
@@ -46,3 +46,45 @@ export const deleteCelebrity = async (id) => {
|
||||
}
|
||||
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);
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
server {
|
||||
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
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
|
||||
BIN
uploads/25109b88-28f1-4040-8675-3db632bbc9dc.png
Normal file
BIN
uploads/25109b88-28f1-4040-8675-3db632bbc9dc.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
uploads/303993ba-8ed3-4a5c-89e0-bc7873dc23aa.png
Normal file
BIN
uploads/303993ba-8ed3-4a5c-89e0-bc7873dc23aa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
uploads/76a0502b-da2b-44c5-9f3f-18b942877c10.jpg
Normal file
BIN
uploads/76a0502b-da2b-44c5-9f3f-18b942877c10.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Reference in New Issue
Block a user