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

@@ -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 {
@@ -78,4 +79,101 @@
.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);
}

View File

@@ -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);
// 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);
getCelebrityById(id)
.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));
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,31 +67,104 @@ 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}`);
}
}
};
// --- 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>

View File

@@ -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();
};
@@ -45,4 +45,46 @@ export const deleteCelebrity = async (id) => {
throw new Error(errorData.detail || 'Errore durante l\'eliminazione');
}
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);
};