Compare commits

..

10 Commits

Author SHA1 Message Date
Nicola Malizia
b159d4512f improve profile 2025-10-10 21:07:00 +02:00
Nicola Malizia
8ed8fcde6c aliases 2025-10-10 21:05:58 +02:00
Nicola Malizia
a0a5bab4f1 upload multiple files 2025-10-10 20:57:02 +02:00
Nicola Malizia
a3a0bfc971 upload from file and url 2025-10-10 20:50:46 +02:00
Nicola Malizia
82a342ca25 improve profile 2025-10-10 20:21:55 +02:00
Nicola Malizia
74a2c00e29 improving home 2025-10-10 20:20:37 +02:00
Nicola Malizia
bc21450776 improve profile 2025-10-10 20:17:57 +02:00
Nicola Malizia
7c4feddfa5 update schema 2025-10-10 19:30:59 +02:00
Nicola Malizia
5fc75397e4 migrating to pico.css 2025-10-10 19:27:57 +02:00
Nicola Malizia
41da3553aa basic profile 2025-10-10 19:22:01 +02:00
49 changed files with 2761 additions and 150 deletions

3
.env
View File

@@ -1,11 +1,12 @@
# ============== POSTGRES ============== # ============== POSTGRES ==============
POSTGRES_SERVER=db
POSTGRES_DB=mydatabase POSTGRES_DB=mydatabase
POSTGRES_USER=myuser POSTGRES_USER=myuser
POSTGRES_PASSWORD=mysecretpassword POSTGRES_PASSWORD=mysecretpassword
# URL del database per SQLAlchemy e Alembic # URL del database per SQLAlchemy e Alembic
# La porta 5432 è quella interna di Docker. 'db' è il nome del servizio in docker-compose. # La porta 5432 è quella interna di Docker. 'db' è il nome del servizio in docker-compose.
DATABASE_URL=postgresql://myuser:mysecretpassword@db:5432/mydatabase DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_SERVER}/${POSTGRES_DB}
# ============== FRONTEND ============== # ============== FRONTEND ==============
# L'URL a cui il frontend contatterà il backend (attraverso il proxy) # L'URL a cui il frontend contatterà il backend (attraverso il proxy)

View File

@@ -0,0 +1,154 @@
// packages/frontend/src/components/CelebrityProfile.jsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { getCelebrityById, updateCelebrity, deleteCelebrity } from '../services/api';
import EditableField from './EditableField';
import './CelebrityProfile.css'; // Importa il nuovo CSS
function CelebrityProfile() {
const { id } = useParams();
const navigate = useNavigate();
const [celebrity, setCelebrity] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Ignoriamo la modalità "new", questa pagina è solo per visualizzare/modificare
if (id) {
setLoading(true);
getCelebrityById(id)
.then((data) => {
// Formatta la data per l'input type="date"
if (data.birth_date) {
data.birth_date = data.birth_date.split('T')[0];
}
setCelebrity(data);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
} else {
navigate('/'); // Se non c'è id, torna alla home
}
}, [id, navigate]);
const handleFieldSave = async (fieldName, newValue) => {
// Converte stringa vuota a null per il backend
const valueToSend = newValue === '' ? null : newValue;
// Converti la stringa 'true'/'false' in un booleano per il campo specifico
if (fieldName === 'boobs_are_natural') {
valueToSend = newValue === 'true';
}
const payload = { [fieldName]: valueToSend };
try {
const updatedCelebrity = await updateCelebrity(id, payload);
// Aggiorna lo stato locale per un feedback immediato
setCelebrity((prev) => ({ ...prev, [fieldName]: newValue }));
console.log('Salvataggio riuscito:', updatedCelebrity);
} catch (err) {
setError(`Errore durante il salvataggio del campo ${fieldName}: ${err.message}`);
// Potresti voler ripristinare il valore precedente in caso di errore
}
};
const handleDelete = async () => {
if (window.confirm(`Sei sicuro di voler eliminare ${celebrity.name}? L'azione è irreversibile.`)) {
try {
await deleteCelebrity(id);
alert(`${celebrity.name} è stato eliminato con successo.`);
navigate('/');
} catch (err) {
setError(`Errore durante l'eliminazione: ${err.message}`);
}
}
};
if (loading) return <p>Caricamento profilo...</p>;
if (error) return <p className="error-message">Errore: {error}</p>;
if (!celebrity) return <p>Nessuna celebrità trovata.</p>;
// Opzioni per i campi <select>
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 booleanOptions = [
{ value: 'true', label: 'Sì' }, { value: 'false', label: 'No' },
];
return (
<div className="profile-container">
<div className="profile-sidebar">
{/* Immagine del profilo (placeholder) */}
<img src={`https://i.pravatar.cc/300?u=${id}`} alt={celebrity.name} className="profile-main-image" />
<div className="thumbnails">
{/* Placeholder per le miniature */}
<div className="thumbnail"><img src={`https://i.pravatar.cc/150?u=${id}a`} alt="thumbnail" /></div>
<div className="thumbnail"><img src={`https://i.pravatar.cc/150?u=${id}b`} alt="thumbnail" /></div>
<div className="thumbnail"><img src={`https://i.pravatar.cc/150?u=${id}c`} alt="thumbnail" /></div>
</div>
</div>
<div className="profile-content">
<div className="profile-header">
<h1>{celebrity.name}</h1>
<EditableField label="Nome completo / Alias" name="name" value={celebrity.name} onSave={handleFieldSave} />
</div>
<section className="profile-section">
<h2>Dati Personali</h2>
<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="Etnia" name="ethnicity" value={celebrity.ethnicity} 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} />
</div>
</section>
<section className="profile-section">
<h2>Aspetto Fisico</h2>
<div className="profile-grid">
<EditableField label="Colore Capelli" name="hair_color" value={celebrity.hair_color} onSave={handleFieldSave} />
<EditableField label="Colore Occhi" name="eye_color" value={celebrity.eye_color} onSave={handleFieldSave} />
<EditableField label="Altezza (cm)" name="height_cm" value={celebrity.height_cm} type="number" onSave={handleFieldSave} />
<EditableField label="Peso (kg)" name="weight_kg" value={celebrity.weight_kg} type="number" onSave={handleFieldSave} />
<EditableField label="Seno (cm)" name="bust_cm" value={celebrity.bust_cm} type="number" onSave={handleFieldSave} />
<EditableField label="Vita (cm)" name="waist_cm" value={celebrity.waist_cm} type="number" onSave={handleFieldSave} />
<EditableField label="Fianchi (cm)" name="hips_cm" value={celebrity.hips_cm} type="number" onSave={handleFieldSave} />
</div>
</section>
<section className="profile-section">
<h2>Reggiseno</h2>
<div className="profile-grid">
<EditableField label="Misura Fascia" name="bra_band_size" value={celebrity.bra_band_size} type="number" onSave={handleFieldSave} />
<EditableField label="Coppa" name="bra_cup_size" value={celebrity.bra_cup_size} onSave={handleFieldSave} />
<EditableField label="Sistema" name="bra_size_system" value={celebrity.bra_size_system} type="select" options={braSystemOptions} onSave={handleFieldSave} />
<EditableField label="Seno Naturale?" name="boobs_are_natural" value={String(celebrity.boobs_are_natural)} type="select" options={booleanOptions} onSave={handleFieldSave} />
</div>
</section>
<section className="profile-section">
<h2>Biografia</h2>
<EditableField label="Note biografiche" name="biography" value={celebrity.biography} type="textarea" onSave={handleFieldSave} />
</section>
<div className="profile-actions">
<Link to="/" className="button-back">Torna alla Lista</Link>
<button onClick={handleDelete} className="button-delete">Elimina Profilo</button>
</div>
</div>
</div>
);
}
export default CelebrityProfile;

249
gemini-schema.json Normal file
View File

@@ -0,0 +1,249 @@
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Il nome principale della celebrità.",
"minLength": 1
},
"gender": {
"type": "string",
"enum": [
"male",
"female",
"other"
],
"description": "Il genere della celebrità."
},
"birth_date": {
"type": "string",
"format": "date",
"description": "La data di nascita in formato ISO 8601 (YYYY-MM-DD)."
},
"birth_place": {
"type": "string"
},
"nationality": {
"type": "string"
},
"ethnicity": {
"type": "string"
},
"sexuality": {
"type": "string"
},
"physical_details": {
"type": "object",
"properties": {
"hair_color": {
"type": "string"
},
"eye_color": {
"type": "string"
},
"height_cm": {
"type": "integer",
"minimum": 1
},
"weight_kg": {
"type": "integer",
"minimum": 1
},
"body_type": {
"type": "string"
},
"measurements_cm": {
"type": "object",
"properties": {
"bust": {
"type": "integer",
"minimum": 1
},
"waist": {
"type": "integer",
"minimum": 1
},
"hips": {
"type": "integer",
"minimum": 1
},
"chest_circumference": {
"type": "integer",
"minimum": 1
}
},
"propertyOrdering": [
"bust",
"waist",
"hips",
"chest_circumference"
],
"description": "Misure del corpo in centimetri."
},
"bra_size": {
"type": "object",
"properties": {
"band": {
"type": "integer",
"minimum": 1
},
"cup": {
"type": "string"
},
"system": {
"type": "string",
"enum": [
"US",
"UK",
"EU",
"FR",
"AU",
"IT",
"JP"
]
}
},
"propertyOrdering": [
"band",
"cup",
"system"
],
"required": [
"band",
"cup",
"system"
],
"description": "La taglia attuale del reggiseno."
},
"boobs_are_natural": {
"type": "boolean"
},
"shoe_size": {
"type": "object",
"properties": {
"size": {
"type": "number"
},
"system": {
"type": "string",
"enum": [
"EU",
"US",
"UK"
]
}
},
"propertyOrdering": [
"size",
"system"
],
"required": [
"size",
"system"
]
}
},
"propertyOrdering": [
"hair_color",
"eye_color",
"height_cm",
"weight_kg",
"body_type",
"measurements_cm",
"bra_size",
"boobs_are_natural",
"shoe_size"
],
"description": "Dettagli fisici e misure della celebrità."
},
"biography": {
"type": "string"
},
"official_website": {
"type": "string",
"format": "uri"
},
"aliases": {
"type": "array",
"items": {
"type": "string"
},
"description": "Elenco di nomi alternativi o alias."
},
"professions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Elenco delle professioni della celebrità."
},
"activity_periods": {
"type": "array",
"items": {
"type": "object",
"properties": {
"start_year": {
"type": "integer",
"minimum": 1900,
"maximum": 2100
},
"end_year": {
"type": "integer",
"minimum": 1900,
"maximum": 2100
},
"notes": {
"type": "string"
}
},
"propertyOrdering": [
"start_year",
"end_year",
"notes"
],
"required": [
"start_year"
]
},
"description": "Periodi di attività noti della carriera."
},
"tattoos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"body_location": {
"type": "string"
}
},
"propertyOrdering": [
"description",
"body_location"
],
"required": [
"description"
]
},
"description": "Elenco dei tatuaggi conosciuti."
}
},
"propertyOrdering": [
"name",
"gender",
"birth_date",
"birth_place",
"nationality",
"ethnicity",
"sexuality",
"physical_details",
"biography",
"official_website",
"aliases",
"professions",
"activity_periods",
"tattoos"
]
}

13
packages/backend/.env Normal file
View File

@@ -0,0 +1,13 @@
# ============== POSTGRES ==============
POSTGRES_SERVER=db
POSTGRES_DB=mydatabase
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mysecretpassword
# URL del database per SQLAlchemy e Alembic
# La porta 5432 è quella interna di Docker. 'db' è il nome del servizio in docker-compose.
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_SERVER}/${POSTGRES_DB}
# ============== FRONTEND ==============
# L'URL a cui il frontend contatterà il backend (attraverso il proxy)
VITE_API_URL=http://localhost:8080/api

View File

@@ -1,10 +1,147 @@
# ... altre configurazioni ... # A generic, single database configuration.
[alembic]
# ...
script_location = alembic
# ...
# Aggiungi questa riga per leggere l'URL dal file .env [alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = ${DATABASE_URL} sqlalchemy.url = ${DATABASE_URL}
# ... altre configurazioni ...
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,45 @@
# packages/backend/alembic/env.py
import os
import sys
from logging.config import fileConfig
from dotenv import load_dotenv # <--- 1. AGGIUNGI QUESTO IMPORT
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Aggiungi la root del progetto (/app) al percorso di ricerca di Python
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Aggiungi l'import del tuo modello specifico sotto l'import di Base
from app.models import Base, Celebrity
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# === 2. AGGIUNGI QUESTA SEZIONE PER CARICARE LA VARIABILE D'AMBIENTE ===
# Carica il file .env che si trova nella root del progetto (/app)
# Dato che lo script è in /app/alembic, dobbiamo risalire di un livello
dotenv_path = os.path.join(os.path.dirname(__file__), '..', '.env')
load_dotenv(dotenv_path=dotenv_path)
# Imposta programmaticamente l'URL del database nell'oggetto di configurazione di Alembic
config.set_main_option('sqlalchemy.url', os.getenv('DATABASE_URL'))
# === FINE DELLA SEZIONE DA AGGIUNGERE ===
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# ... il resto del file rimane invariato ...

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""Initial migration with celebrities table
Revision ID: 23353f53a7cf
Revises:
Create Date: 2025-10-10 17:00:56.622311
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '23353f53a7cf'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

Binary file not shown.

View File

@@ -0,0 +1,119 @@
import os
import sqlalchemy as sa
from sqlalchemy.orm import Session, joinedload
from . import models, schemas
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.aliases) # Carica anche gli alias
).filter(models.Celebrity.id == celebrity_id).first()
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())
db.add(db_celebrity)
db.commit()
db.refresh(db_celebrity)
return db_celebrity
def update_celebrity(db: Session, celebrity_id: int, celebrity_update: schemas.CelebrityUpdate):
db_celebrity = get_celebrity(db, celebrity_id)
if not db_celebrity:
return None
update_data = celebrity_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_celebrity, key, value)
db.commit()
db.refresh(db_celebrity)
return db_celebrity
def delete_celebrity(db: Session, celebrity_id: int):
db_celebrity = get_celebrity(db, celebrity_id)
if not db_celebrity:
return None
db.delete(db_celebrity)
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):
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

@@ -0,0 +1,25 @@
import os
from sqlalchemy import create_engine
# MODIFICA: La best practice moderna è usare DeclarativeBase
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Assicurati che Base sia definito così
class Base(DeclarativeBase):
pass
# Dependency per ottenere una sessione DB per ogni richiesta
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -1,7 +1,16 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
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
app.include_router(celebrities.router)
@app.get("/api") @app.get("/api")
def read_root(): def read_root():
return {"message": "Ciao dal backend FastAPI!"} return {"message": "Ciao dal backend FastAPI!"}

View File

@@ -0,0 +1,216 @@
import enum
from sqlalchemy import (Column, Integer, String, Date, Enum, Boolean,
DECIMAL, Text, TIMESTAMP, ForeignKey, Table)
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from .database import Base
# =============================================================================
# DEFINIZIONE DEGLI ENUM
# =============================================================================
class GenderType(str, enum.Enum):
male = "male"
female = "female"
other = "other"
class ShoeSystemType(str, enum.Enum):
EU = "EU"
US = "US"
UK = "UK"
class BraSystemType(str, enum.Enum):
US = "US"
UK = "UK"
EU = "EU"
FR = "FR"
AU = "AU"
IT = "IT"
JP = "JP"
# NUOVO ENUM: Tipo di intervento estetico
class SurgeryType(str, enum.Enum):
breast_reduction = "breast_reduction"
breast_augmentation = "breast_augmentation"
breast_lift = "breast_lift"
rhinoplasty = "rhinoplasty"
other = "other"
# =============================================================================
# TABELLE DI ASSOCIAZIONE (PER RELAZIONI MOLTI-A-MOLTI)
# =============================================================================
celebrity_professions = Table(
"celebrity_professions",
Base.metadata,
Column("celebrity_id", Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), primary_key=True),
Column("profession_id", Integer, ForeignKey("professions.id", ondelete="CASCADE"), primary_key=True),
)
celebrity_studios = Table(
"celebrity_studios",
Base.metadata,
Column("celebrity_id", Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), primary_key=True),
Column("studio_id", Integer, ForeignKey("studios.id", ondelete="CASCADE"), primary_key=True),
)
video_performers = Table(
"video_performers",
Base.metadata,
Column("video_id", Integer, ForeignKey("videos.id", ondelete="CASCADE"), primary_key=True),
Column("celebrity_id", Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), primary_key=True),
)
# =============================================================================
# MODELLI PRINCIPALI
# =============================================================================
class Celebrity(Base):
__tablename__ = "celebrities"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False, index=True)
gender = Column(Enum(GenderType), nullable=False)
birth_date = Column(Date, nullable=True)
birth_place = Column(String, nullable=True)
nationality = Column(String, nullable=True)
ethnicity = Column(String, nullable=True)
sexuality = Column(String, nullable=True)
hair_color = Column(String, nullable=True)
eye_color = Column(String, nullable=True)
height_cm = Column(Integer, nullable=True)
weight_kg = Column(Integer, nullable=True)
body_type = Column(String, nullable=True)
bust_cm = Column(Integer, nullable=True)
waist_cm = Column(Integer, nullable=True)
hips_cm = Column(Integer, nullable=True)
chest_circumference_cm = Column(Integer, nullable=True)
bra_band_size = Column(Integer, nullable=True)
bra_cup_size = Column(String, nullable=True)
bra_size_system = Column(Enum(BraSystemType), nullable=True)
boobs_are_natural = Column(Boolean, nullable=True)
shoe_size = Column(DECIMAL(4, 1), nullable=True)
shoe_size_system = Column(Enum(ShoeSystemType), nullable=True)
biography = Column(Text, nullable=True)
official_website = Column(String, nullable=True)
profile_image_id = Column(Integer, ForeignKey("images.id", ondelete="SET NULL"), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
updated_at = Column(TIMESTAMP(timezone=True), default=func.now(), onupdate=func.now())
# RELAZIONI
profile_image = relationship("Image", foreign_keys=[profile_image_id])
# Relazioni Uno-a-Molti (Una celebrità ha molti...)
images = relationship("Image", back_populates="celebrity", foreign_keys="Image.celebrity_id", cascade="all, delete-orphan")
tattoos = relationship("Tattoo", back_populates="celebrity", cascade="all, delete-orphan")
aliases = relationship("CelebrityAlias", back_populates="celebrity", cascade="all, delete-orphan")
activity_periods = relationship("ActivityPeriod", back_populates="celebrity", cascade="all, delete-orphan")
cosmetic_surgeries = relationship("CosmeticSurgery", back_populates="celebrity", cascade="all, delete-orphan")
social_media = relationship("SocialMediaAccount", back_populates="celebrity", cascade="all, delete-orphan")
external_links = relationship("ExternalLink", back_populates="celebrity", cascade="all, delete-orphan")
# Relazioni Molti-a-Molti
professions = relationship("Profession", secondary=celebrity_professions, back_populates="celebrities")
studios = relationship("Studio", secondary=celebrity_studios, back_populates="celebrities")
videos = relationship("Video", secondary=video_performers, back_populates="performers")
# =============================================================================
# MODELLI "LOOKUP" (PROFESSIONI, STUDIOS)
# =============================================================================
class Profession(Base):
__tablename__ = "professions"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
celebrities = relationship("Celebrity", secondary=celebrity_professions, back_populates="professions")
class Studio(Base):
__tablename__ = "studios"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
celebrities = relationship("Celebrity", secondary=celebrity_studios, back_populates="studios")
videos = relationship("Video", back_populates="studio")
class Video(Base):
__tablename__ = "videos"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
release_date = Column(Date)
studio_id = Column(Integer, ForeignKey("studios.id"))
description = Column(Text)
url_preview = Column(String)
studio = relationship("Studio", back_populates="videos")
performers = relationship("Celebrity", secondary=video_performers, back_populates="videos")
# =============================================================================
# MODELLI SATELLITE (LEGATI A CELEBRITY CON RELAZIONE 1-A-MOLTI)
# =============================================================================
class Image(Base):
__tablename__ = "images"
id = Column(Integer, primary_key=True, index=True)
celebrity_id = Column(Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), nullable=False)
file_path = Column(String, nullable=False)
caption = Column(Text, nullable=True)
uploaded_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
celebrity = relationship("Celebrity", back_populates="images", foreign_keys=[celebrity_id])
class Tattoo(Base):
__tablename__ = "tattoos"
id = Column(Integer, primary_key=True)
celebrity_id = Column(Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), nullable=False)
description = Column(String, nullable=False)
body_location = Column(String)
celebrity = relationship("Celebrity", back_populates="tattoos")
class CelebrityAlias(Base):
__tablename__ = "celebrity_aliases"
id = Column(Integer, primary_key=True)
celebrity_id = Column(Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), nullable=False)
alias_name = Column(String, nullable=False)
celebrity = relationship("Celebrity", back_populates="aliases")
class ActivityPeriod(Base):
__tablename__ = "activity_periods"
id = Column(Integer, primary_key=True)
celebrity_id = Column(Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), nullable=False)
start_year = Column(Integer, nullable=False)
end_year = Column(Integer, nullable=True)
notes = Column(Text)
celebrity = relationship("Celebrity", back_populates="activity_periods")
class CosmeticSurgery(Base):
__tablename__ = "cosmetic_surgeries"
id = Column(Integer, primary_key=True)
celebrity_id = Column(Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), nullable=False)
surgery_type = Column(Enum(SurgeryType), nullable=False)
surgery_date = Column(Date)
new_bra_band_size = Column(Integer)
new_bra_cup_size = Column(String)
new_bra_size_system = Column(Enum(BraSystemType))
notes = Column(Text)
created_at = Column(TIMESTAMP(timezone=True), server_default=func.now())
celebrity = relationship("Celebrity", back_populates="cosmetic_surgeries")
class SocialMediaAccount(Base):
__tablename__ = "social_media_accounts"
id = Column(Integer, primary_key=True)
celebrity_id = Column(Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), nullable=False)
platform = Column(String, nullable=False)
url = Column(String, unique=True, nullable=False)
follower_count = Column(Integer)
last_checked_date = Column(Date)
celebrity = relationship("Celebrity", back_populates="social_media")
class ExternalLink(Base):
__tablename__ = "external_links"
id = Column(Integer, primary_key=True)
celebrity_id = Column(Integer, ForeignKey("celebrities.id", ondelete="CASCADE"), nullable=False)
url = Column(String, nullable=False)
title = Column(String)
source_website = Column(String)
publication_date = Column(Date)
celebrity = relationship("Celebrity", back_populates="external_links")

View File

@@ -0,0 +1,148 @@
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Response
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
import shutil
import httpx
from .. import crud, models, schemas
from ..database import get_db
router = APIRouter(
prefix="/api/celebrities",
tags=["celebrities"],
responses={404: {"description": "Not found"}},
)
@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à
return crud.create_celebrity(db=db, celebrity=celebrity)
@router.get("/", response_model=List[schemas.Celebrity])
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)
def read_celebrity(celebrity_id: int, 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 db_celebrity
@router.put("/{celebrity_id}", response_model=schemas.Celebrity)
def update_celebrity(celebrity_id: int, celebrity: schemas.CelebrityUpdate, db: Session = Depends(get_db)):
db_celebrity = crud.update_celebrity(db, celebrity_id=celebrity_id, celebrity_update=celebrity)
if db_celebrity is None:
raise HTTPException(status_code=404, detail="Celebrity not found")
return db_celebrity
@router.delete("/{celebrity_id}", response_model=schemas.Celebrity)
def delete_celebrity(celebrity_id: int, db: Session = Depends(get_db)):
db_celebrity = crud.delete_celebrity(db, celebrity_id=celebrity_id)
if db_celebrity is None:
raise HTTPException(status_code=404, detail="Celebrity not found")
return db_celebrity
# --- 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)):
db_celebrity = crud.get_celebrity(db, celebrity_id=celebrity_id)
if db_celebrity is None:
raise HTTPException(status_code=404, detail="Celebrity not found")
created_images = []
for file in files:
try:
file_extension = file.filename.split('.')[-1]
if not file_extension: # Gestisce file senza estensione
file_extension = "jpg" # Default
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)
db_image = crud.create_celebrity_image(db=db, celebrity_id=celebrity_id, file_path=unique_filename)
created_images.append(db_image)
except Exception as e:
print(f"Failed to upload file {file.filename}: {e}")
raise HTTPException(status_code=500, detail=f"Could not upload file: {file.filename}")
if not created_images:
raise HTTPException(status_code=400, detail="No files were successfully uploaded.")
return created_images
@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:
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.")
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.")
get_response = await client.get(url, follow_redirects=True, timeout=30)
get_response.raise_for_status()
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
@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

@@ -0,0 +1,167 @@
from pydantic import BaseModel, HttpUrl
from typing import Optional, List
from datetime import date, datetime
from .models import GenderType, ShoeSystemType, BraSystemType, SurgeryType
# =============================================================================
# SCHEMI PER I MODELLI CORRELATI (LOOKUP E SATELLITE)
# =============================================================================
# --- Profession ---
class ProfessionBase(BaseModel):
name: str
class ProfessionCreate(ProfessionBase):
pass
class Profession(ProfessionBase):
id: int
class Config: from_attributes = True
# --- Studio ---
class StudioBase(BaseModel):
name: str
class StudioCreate(StudioBase):
pass
class Studio(StudioBase):
id: int
class Config: from_attributes = True
# --- Tattoo ---
class TattooBase(BaseModel):
description: str
body_location: Optional[str] = None
class TattooCreate(TattooBase):
pass
class Tattoo(TattooBase):
id: int
celebrity_id: int
class Config: from_attributes = True
# --- Alias ---
class CelebrityAliasBase(BaseModel):
alias_name: str
class CelebrityAliasCreate(CelebrityAliasBase):
pass
class CelebrityAlias(CelebrityAliasBase):
id: int
celebrity_id: int
class Config: from_attributes = True
# --- Cosmetic Surgery ---
class CosmeticSurgeryBase(BaseModel):
surgery_type: SurgeryType
surgery_date: Optional[date] = None
new_bra_band_size: Optional[int] = None
new_bra_cup_size: Optional[str] = None
new_bra_size_system: Optional[BraSystemType] = None
notes: Optional[str] = None
class CosmeticSurgeryCreate(CosmeticSurgeryBase):
pass
class CosmeticSurgery(CosmeticSurgeryBase):
id: int
celebrity_id: int
created_at: datetime
class Config: from_attributes = True
# --- Image ---
class ImageBase(BaseModel):
file_path: str
caption: Optional[str] = None
class ImageCreate(ImageBase):
pass
class Image(ImageBase):
id: int
celebrity_id: int
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
# =============================================================================
# Schema di base con i campi comuni
class CelebrityBase(BaseModel):
name: str
gender: GenderType
birth_date: Optional[date] = None
birth_place: Optional[str] = None
nationality: Optional[str] = None
ethnicity: Optional[str] = None
sexuality: Optional[str] = None
hair_color: Optional[str] = None
eye_color: Optional[str] = None
height_cm: Optional[int] = None
weight_kg: Optional[int] = None
body_type: Optional[str] = None
bust_cm: Optional[int] = None
waist_cm: Optional[int] = None
hips_cm: Optional[int] = None
chest_circumference_cm: Optional[int] = None
bra_band_size: Optional[int] = None
bra_cup_size: Optional[str] = None
bra_size_system: Optional[BraSystemType] = None
boobs_are_natural: Optional[bool] = None
shoe_size: Optional[float] = None
shoe_size_system: Optional[ShoeSystemType] = None
biography: Optional[str] = None
official_website: Optional[str] = None
profile_image_id: Optional[int] = None
# Schema per la creazione di una nuova celebrità (eredita da Base)
class CelebrityCreate(CelebrityBase):
pass
# Schema per l'aggiornamento (tutti i campi sono opzionali)
class CelebrityUpdate(BaseModel):
name: Optional[str] = None
gender: Optional[GenderType] = None
birth_date: Optional[date] = None
birth_place: Optional[str] = None
nationality: Optional[str] = None
ethnicity: Optional[str] = None
sexuality: Optional[str] = None
hair_color: Optional[str] = None
eye_color: Optional[str] = None
height_cm: Optional[int] = None
weight_kg: Optional[int] = None
body_type: Optional[str] = None
bust_cm: Optional[int] = None
waist_cm: Optional[int] = None
hips_cm: Optional[int] = None
chest_circumference_cm: Optional[int] = None
bra_band_size: Optional[int] = None
bra_cup_size: Optional[str] = None
bra_size_system: Optional[BraSystemType] = None
boobs_are_natural: Optional[bool] = None
shoe_size: Optional[float] = None
shoe_size_system: Optional[ShoeSystemType] = None
biography: Optional[str] = None
official_website: Optional[str] = None
profile_image_id: Optional[int] = None
# Schema per la lettura dei dati (include campi generati dal DB e le relazioni)
class Celebrity(CelebrityBase):
id: int
created_at: datetime
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,
# ma in un'app reale potresti volere schemi specifici per la "lettura".
professions: List[Profession] = []
studios: List[Studio] = []
cosmetic_surgeries: List[CosmeticSurgery] = []
class Config:
from_attributes = True # Permette a Pydantic di leggere dati da un modello ORM

View File

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

View File

@@ -1,10 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <!-- 1. Aggiunto il link a Pico.css -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
<title>Celebrity Catalog</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -9,7 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
@@ -1628,6 +1629,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2503,6 +2513,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
"integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz",
"integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -2571,6 +2619,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,25 +1,21 @@
import { useState, useEffect } from 'react'; // packages/frontend/src/App.jsx
import './App.css';
import { Routes, Route } from 'react-router-dom';
import CelebrityList from './components/CelebrityList';
import CelebrityProfile from './components/CelebrityProfile';
import CelebrityCreate from './components/CelebrityCreate';
// 3. Rimosso App.css
function App() { function App() {
const [message, setMessage] = useState('Caricamento...');
useEffect(() => {
// Vite espone le variabili d'ambiente prefixate con VITE_
// sull'oggetto import.meta.env
const apiUrl = import.meta.env.VITE_API_URL || '/api';
fetch(apiUrl)
.then((res) => res.json())
.then((data) => setMessage(data.message))
.catch((err) => setMessage(`Errore: ${err.message}`));
}, []);
return ( return (
<div className="App"> // 4. Aggiunto il container principale di Pico
<h1>Monorepo React + FastAPI</h1> <main className="container">
<p>Messaggio dal backend: <strong>{message}</strong></p> <Routes>
</div> <Route path="/" element={<CelebrityList />} />
<Route path="/celebrity/new" element={<CelebrityCreate />} />
<Route path="/celebrity/:id" element={<CelebrityProfile />} />
</Routes>
</main>
); );
} }

View File

@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { createCelebrity } from '../services/api';
const initialFormState = {
name: '',
gender: 'female',
birth_date: '',
height_cm: '',
weight_kg: '',
body_type: '',
bust_cm: '',
waist_cm: '',
hips_cm: '',
chest_circumference_cm: '',
bra_band_size: '',
bra_cup_size: '',
bra_size_system: 'US',
boobs_are_natural: true,
birth_place: '',
nationality: '',
ethnicity: '',
sexuality: '',
hair_color: '',
eye_color: '',
biography: '',
shoe_size: '',
shoe_size_system: 'EU',
official_website: '',
};
function CelebrityCreate() {
const navigate = useNavigate();
const [formData, setFormData] = useState(initialFormState);
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
const payload = { ...formData };
Object.keys(payload).forEach(key => {
if (payload[key] === '') payload[key] = null;
});
try {
await createCelebrity(payload);
navigate('/');
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
return (
<article>
<h2>Aggiungi Nuova Celebrità</h2>
<p>Compila i campi sottostanti per aggiungere un nuovo profilo al catalogo.</p>
<form onSubmit={handleSubmit}>
<fieldset>
<legend>Anagrafica</legend>
<div className="grid">
<label>
Nome *
<input type="text" name="name" value={formData.name} onChange={handleChange} required />
</label>
<label>
Genere
<select name="gender" value={formData.gender} onChange={handleChange}>
<option value="female">Female</option>
<option value="male">Male</option>
<option value="other">Other</option>
</select>
</label>
</div>
<div className="grid">
<label>Data di Nascita<input type="date" name="birth_date" value={formData.birth_date} onChange={handleChange} /></label>
<label>Luogo di Nascita<input type="text" name="birth_place" value={formData.birth_place} onChange={handleChange} /></label>
<label>Nazionalità<input type="text" name="nationality" value={formData.nationality} onChange={handleChange} /></label>
</div>
<label>
Sito Ufficiale
<input type="url" name="official_website" value={formData.official_website} onChange={handleChange} placeholder="https://..."/>
</label>
</fieldset>
<fieldset>
<legend>Misure e Aspetto</legend>
<div className="grid">
<label>Altezza (cm)<input type="number" name="height_cm" value={formData.height_cm} onChange={handleChange} /></label>
<label>Peso (kg)<input type="number" name="weight_kg" value={formData.weight_kg} onChange={handleChange} /></label>
<label>Corporatura<input type="text" name="body_type" value={formData.body_type} onChange={handleChange} /></label>
</div>
<div className="grid">
<label>Seno (cm)<input type="number" name="bust_cm" value={formData.bust_cm} onChange={handleChange} /></label>
<label>Vita (cm)<input type="number" name="waist_cm" value={formData.waist_cm} onChange={handleChange} /></label>
<label>Fianchi (cm)<input type="number" name="hips_cm" value={formData.hips_cm} onChange={handleChange} /></label>
{formData.gender === 'male' && <label>Torace (cm)<input type="number" name="chest_circumference_cm" value={formData.chest_circumference_cm} onChange={handleChange} /></label>}
</div>
<div className="grid">
<label>Misura Scarpe<input type="number" step="0.5" name="shoe_size" value={formData.shoe_size} onChange={handleChange} /></label>
<label>Sistema
<select name="shoe_size_system" value={formData.shoe_size_system} onChange={handleChange}>
<option value="EU">EU</option> <option value="US">US</option> <option value="UK">UK</option>
</select>
</label>
</div>
<div className="grid">
<label>Colore Capelli<input type="text" name="hair_color" value={formData.hair_color} onChange={handleChange} /></label>
<label>Colore Occhi<input type="text" name="eye_color" value={formData.eye_color} onChange={handleChange} /></label>
</div>
</fieldset>
<fieldset>
<legend>Reggiseno</legend>
<div className="grid">
<label>Misura Fascia<input type="number" name="bra_band_size" value={formData.bra_band_size} onChange={handleChange} /></label>
<label>Coppa<input type="text" name="bra_cup_size" value={formData.bra_cup_size} onChange={handleChange} /></label>
<label>Sistema
<select name="bra_size_system" value={formData.bra_size_system} onChange={handleChange}>
<option value="US">US</option> <option value="UK">UK</option> <option value="EU">EU</option> <option value="FR">FR</option>
<option value="AU">AU</option> <option value="IT">IT</option> <option value="JP">JP</option>
</select>
</label>
</div>
<label>
<input type="checkbox" name="boobs_are_natural" checked={formData.boobs_are_natural} onChange={handleChange} />
Seno Naturale?
</label>
</fieldset>
{error && <p><strong>Errore:</strong> {error}</p>}
<div className="grid">
<Link to="/" role="button" className="secondary">Annulla</Link>
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting ? 'Salvataggio...' : 'Salva Profilo'}
</button>
</div>
</form>
</article>
);
}
export default CelebrityCreate;

View File

@@ -0,0 +1,119 @@
/* packages/frontend/src/components/CelebrityList.css */
.celebrity-list-container {
max-width: 1100px;
margin: 0 auto;
padding: 1rem;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.list-header h1 {
margin: 0;
}
.list-controls {
margin-bottom: 1rem;
}
.table-responsive {
overflow-x: auto;
}
.avatar-image {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
vertical-align: middle;
}
.celeb-link {
font-weight: bold;
text-decoration: none;
color: var(--pico-primary);
}
.celeb-link:hover {
text-decoration: underline;
}
/* Menu Azioni a tendina */
.actions-cell {
text-align: center;
}
.actions-button {
padding: 0.25rem 0.5rem !important;
width: auto !important;
display: inline-flex !important;
}
.actions-cell details[role="list"] {
margin: 0;
}
.actions-cell summary::after {
display: none; /* Rimuove la freccia di default */
}
.actions-cell ul[role="listbox"] {
text-align: left;
}
.actions-cell ul a {
display: flex;
align-items: center;
gap: 0.5rem;
}
.actions-cell ul a.danger {
color: var(--pico-color-red-500);
}
/* Paginazione */
.pagination-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding: 0.5rem;
background-color: var(--pico-card-background-color);
border-radius: var(--pico-card-border-radius);
border: 1px solid var(--pico-card-border-color);
}
.pagination-controls .grid {
margin: 0;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.pagination-controls button,
.pagination-controls select {
margin: 0;
}
.pagination-controls select {
max-width: 150px;
}
/* Stato vuoto */
.empty-state {
text-align: center;
padding: 3rem 1rem;
border: 2px dashed var(--pico-muted-border-color);
border-radius: var(--border-radius);
margin-top: 2rem;
}
.empty-state h3 {
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--pico-secondary);
margin-bottom: 1.5rem;
}
.empty-state .primary {
display: inline-block;
}
/* Allineamento verticale nella tabella */
tbody td {
vertical-align: middle;
}

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { getCelebrities, deleteCelebrity } from '../services/api';
import './CelebrityList.css';
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('');
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(debouncedSearchTerm);
setCelebrities(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleDelete = async (id, name) => {
if (window.confirm(`Sei sicuro di voler eliminare ${name}?`)) {
try {
await deleteCelebrity(id);
setCelebrities(prev => prev.filter((c) => c.id !== id));
} catch (err) {
alert(`Errore: ${err.message}`);
}
}
};
// La paginazione ora opera sulla lista (già filtrata) ricevuta dal backend
const paginatedCelebrities = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return celebrities.slice(startIndex, startIndex + itemsPerPage);
}, [celebrities, currentPage, itemsPerPage]);
const totalPages = Math.ceil(celebrities.length / itemsPerPage);
const renderPagination = () => (
<div className="pagination-controls">
<span>
Pagina {currentPage} di {totalPages} ({celebrities.length} risultati)
</span>
<div className="grid">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}>
Precedente
</button>
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}>
Successiva
</button>
</div>
<select value={itemsPerPage} onChange={e => { setItemsPerPage(Number(e.target.value)); setCurrentPage(1); }}>
<option value="5">5 per pagina</option>
<option value="10">10 per pagina</option>
<option value="20">20 per pagina</option>
</select>
</div>
);
const renderEmptyState = () => (
<div className="empty-state">
<h3>Nessun Profilo Trovato</h3>
<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>
);
return (
<div className="celebrity-list-container">
<header className="list-header">
<h1>Catalogo Celebrità</h1>
<Link to="/celebrity/new" role="button">
+ Aggiungi Profilo
</Link>
</header>
<div className="list-controls">
<input
type="search"
placeholder="Cerca per nome o alias..."
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
{loading && <article aria-busy="true">Ricerca in corso...</article>}
{error && <p className="error-message">Errore: {error}</p>}
{!loading && !error && (
<>
{paginatedCelebrities.length === 0 ? renderEmptyState() : (
<>
<div className="table-responsive">
<table>
<thead>
<tr>
<th scope="col" style={{ width: '50px' }}></th>
<th scope="col">Nome</th>
<th scope="col">Misure (S-V-F)</th>
<th scope="col">Taglia Reggiseno</th>
<th scope="col">Naturale?</th>
<th scope="col" style={{ width: '80px', textAlign: 'center' }}>Azioni</th>
</tr>
</thead>
<tbody>
{paginatedCelebrities.map((celeb) => (
<tr key={celeb.id}>
<td>
<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">
<strong>{celeb.name}</strong>
</Link>
</td>
<td>
{celeb.bust_cm || '?'}-{celeb.waist_cm || '?'}-{celeb.hips_cm || '?'} cm
</td>
<td>
{celeb.bra_band_size && celeb.bra_cup_size
? `${celeb.bra_band_size}${celeb.bra_cup_size} (${celeb.bra_size_system || 'N/A'})`
: 'N/A'}
</td>
<td>
{celeb.boobs_are_natural === null ? '?' : celeb.boobs_are_natural ? 'Sì' : 'No'}
</td>
<td className="actions-cell">
<details role="list" dir="rtl">
<summary aria-haspopup="listbox" role="button" className="outline actions-button"><MoreIcon/></summary>
<ul role="listbox">
<li><a href="#" onClick={(e) => { e.preventDefault(); navigate(`/celebrity/${celeb.id}`); }}><EditIcon/> Modifica</a></li>
<li><a href="#" onClick={(e) => { e.preventDefault(); handleDelete(celeb.id, celeb.name); }} className="danger"><DeleteIcon/> Elimina</a></li>
</ul>
</details>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && renderPagination()}
</>
)}
</>
)}
</div>
);
}
export default CelebrityList;

View File

@@ -0,0 +1,216 @@
/* packages/frontend/src/components/CelebrityProfile.css */
.profile-container {
display: flex;
gap: 2rem;
align-items: flex-start;
margin-top: 2rem;
}
.profile-sidebar {
flex: 0 0 300px; /* Larghezza fissa per la sidebar */
position: sticky;
top: 2rem;
}
.profile-sidebar figure {
margin: 0;
text-align: center;
}
.profile-sidebar .profile-main-image {
border-radius: var(--border-radius);
margin-bottom: 1rem;
width: 100%;
height: 400px; /* Altezza fissa per l'immagine profilo */
object-fit: cover;
background-color: var(--pico-muted-background-color);
}
.profile-sidebar .editable-field-container {
text-align: center;
}
.profile-sidebar .editable-field-container label {
display: none;
}
.profile-sidebar .display-value {
font-size: 1.5rem;
font-weight: bold;
}
.profile-actions {
margin-top: 1.5rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.profile-content {
flex: 1 1 auto;
min-width: 0;
}
.profile-content details {
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--border-radius);
margin-bottom: 1rem;
padding: 0.5rem 1rem;
}
.profile-content summary {
cursor: pointer;
font-weight: bold;
padding: 0.5rem 0;
}
.profile-content h3 {
margin: 0;
display: inline;
font-size: 1.2rem;
}
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
}
.error-message {
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;
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;
}
.upload-section h4 {
margin: 0 0 0.5rem 0;
}
.upload-section .grid {
grid-template-columns: 1fr auto;
gap: 1rem;
margin: 0;
align-items: center;
}
.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

@@ -0,0 +1,332 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { getCelebrityById, updateCelebrity, deleteCelebrity, uploadImages, setProfileImage, deleteImage, addImageFromUrl, addAlias, deleteAlias } from '../services/api';
import EditableField from './EditableField';
import './CelebrityProfile.css';
function CelebrityProfile() {
const { id } = useParams();
const navigate = useNavigate();
const [celebrity, setCelebrity] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
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) => {
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, fetchProfile]);
useEffect(() => {
if (selectedFiles && selectedFiles.length > 0) {
handleUpload();
}
}, [selectedFiles]);
const handleFieldSave = async (fieldName, newValue) => {
let valueToSend = newValue === '' ? null : newValue;
if (fieldName === 'boobs_are_natural') {
valueToSend = newValue === 'true';
}
const payload = { [fieldName]: valueToSend };
try {
const updatedCelebrity = await updateCelebrity(id, payload);
setCelebrity(prev => ({ ...prev, ...updatedCelebrity }));
} catch (err) {
console.error(`Errore durante il salvataggio del campo ${fieldName}:`, err);
throw err;
}
};
const handleDelete = async () => {
if (window.confirm(`Sei sicuro di voler eliminare ${celebrity.name}? L'azione è irreversibile.`)) {
try {
await deleteCelebrity(id);
alert(`${celebrity.name} è stato eliminato con successo.`);
navigate('/');
} catch (err) {
setError(`Errore durante l'eliminazione: ${err.message}`);
}
}
};
// --- 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) {
setSelectedFiles(Array.from(e.target.files));
}
};
const handleUpload = async () => {
if (!selectedFiles || selectedFiles.length === 0) return;
setIsUploading(true);
setError(null);
try {
await uploadImages(id, selectedFiles);
fetchProfile();
} catch (err) {
setError(`Upload fallito: ${err.message}`);
} finally {
setSelectedFiles(null);
if(fileInputRef.current) fileInputRef.current.value = "";
setIsUploading(false);
}
};
const handleFetchFromUrl = async () => {
if (!imageUrl) return;
setIsFetchingFromUrl(true);
setError(null);
try {
await addImageFromUrl(id, imageUrl);
setImageUrl('');
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);
} 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();
} catch (err) {
setError(`Errore nell'eliminazione dell'immagine: ${err.message}`);
}
}
};
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(); };
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
setSelectedFiles(Array.from(files));
}
};
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>;
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' }];
return (
<div className="profile-container">
<aside className="profile-sidebar">
<figure>
<img src={profileImageUrl} alt={celebrity.name} className="profile-main-image" />
<figcaption>
<EditableField label="Nome" name="name" value={celebrity.name} onSave={handleFieldSave} />
</figcaption>
</figure>
<div className="profile-actions">
<Link to="/" role="button" className="secondary outline">Torna alla Lista</Link>
<button onClick={handleDelete} className="secondary">Elimina Profilo</button>
</div>
</aside>
<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>
<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="Genere" name="gender" value={celebrity.gender} type="select" options={genderOptions} 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>
<details>
<summary><h3>Aspetto Fisico</h3></summary>
<div className="profile-grid">
<EditableField label="Etnia" name="ethnicity" value={celebrity.ethnicity} onSave={handleFieldSave} />
<EditableField label="Colore Capelli" name="hair_color" value={celebrity.hair_color} onSave={handleFieldSave} />
<EditableField label="Colore Occhi" name="eye_color" value={celebrity.eye_color} onSave={handleFieldSave} />
<EditableField label="Corporatura" name="body_type" value={celebrity.body_type} onSave={handleFieldSave} />
</div>
</details>
<details>
<summary><h3>Misure Corporee</h3></summary>
<div className="profile-grid">
<EditableField label="Altezza (cm)" name="height_cm" value={celebrity.height_cm} type="number" onSave={handleFieldSave} />
<EditableField label="Peso (kg)" name="weight_kg" value={celebrity.weight_kg} type="number" onSave={handleFieldSave} />
<EditableField label="Seno (cm)" name="bust_cm" value={celebrity.bust_cm} type="number" onSave={handleFieldSave} />
<EditableField label="Vita (cm)" name="waist_cm" value={celebrity.waist_cm} type="number" onSave={handleFieldSave} />
<EditableField label="Fianchi (cm)" name="hips_cm" value={celebrity.hips_cm} type="number" onSave={handleFieldSave} />
{celebrity.gender === 'male' && <EditableField label="Torace (cm)" name="chest_circumference_cm" value={celebrity.chest_circumference_cm} type="number" onSave={handleFieldSave} />}
<EditableField label="Misura Scarpe" name="shoe_size" value={celebrity.shoe_size} type="number" onSave={handleFieldSave} />
<EditableField label="Sistema Scarpe" name="shoe_size_system" value={celebrity.shoe_size_system} type="select" options={shoeSystemOptions} onSave={handleFieldSave} />
</div>
</details>
{celebrity.gender !== 'male' && (
<details>
<summary><h3>Reggiseno</h3></summary>
<div className="profile-grid">
<EditableField label="Misura Fascia" name="bra_band_size" value={celebrity.bra_band_size} type="number" onSave={handleFieldSave} />
<EditableField label="Coppa" name="bra_cup_size" value={celebrity.bra_cup_size} onSave={handleFieldSave} />
<EditableField label="Sistema" name="bra_size_system" value={celebrity.bra_size_system} type="select" options={braSystemOptions} onSave={handleFieldSave} />
<EditableField label="Seno Naturale?" name="boobs_are_natural" value={String(celebrity.boobs_are_natural)} type="select" options={booleanOptions} onSave={handleFieldSave} />
</div>
</details>
)}
<details>
<summary><h3>Biografia</h3></summary>
<EditableField label="Note biografiche" name="biography" value={celebrity.biography} type="textarea" onSave={handleFieldSave} />
</details>
<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 multiple />
{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>
</div>
</div>
</div>
</details>
</main>
</div>
);
}
export default CelebrityProfile;

View File

@@ -0,0 +1,86 @@
/* packages/frontend/src/components/EditableField.css */
.editable-field-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
position: relative;
}
.editable-field-container label {
font-size: 0.85rem;
font-weight: 500;
color: var(--pico-secondary);
}
/* Modalità visualizzazione */
.display-mode {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 40px; /* Stessa altezza dell'input */
padding: 0.375rem 0.75rem;
border: 1px solid transparent; /* Per mantenere l'allineamento */
border-radius: var(--border-radius);
transition: background-color 0.2s ease-in-out;
}
.display-mode:hover {
background-color: var(--pico-muted-background-color);
}
.display-mode .display-value {
flex-grow: 1;
word-break: break-word;
}
.display-mode em {
color: var(--pico-secondary);
font-style: normal;
}
.display-mode .button-icon {
visibility: hidden; /* Nascosto di default */
opacity: 0;
transition: opacity 0.2s ease-in-out;
margin-left: 0.5rem;
}
.display-mode:hover .button-icon {
visibility: visible; /* Visibile solo in hover */
opacity: 1;
}
/* Modalità modifica */
.edit-mode {
display: flex;
flex-direction: column;
}
.edit-mode input,
.edit-mode select,
.edit-mode textarea {
width: 100%;
}
.edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
justify-content: flex-start;
}
/* Pulsanti piccoli per le azioni */
.button-icon {
padding: 0.3rem 0.5rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.error-text {
color: var(--pico-color-red-500);
font-size: 0.8rem;
margin-top: 0.25rem;
}

View File

@@ -0,0 +1,101 @@
// packages/frontend/src/components/EditableField.jsx
import React, { useState, useEffect } from 'react';
import './EditableField.css'; // Importiamo il nuovo CSS
// Piccole icone SVG per i pulsanti
const EditIcon = () => <svg xmlns="http://www.w3.org/2000/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 SaveIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>;
const CancelIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>;
function EditableField({ label, name, value, onSave, type = 'text', options = [] }) {
const [isEditing, setIsEditing] = useState(false);
const [currentValue, setCurrentValue] = useState(value);
const [status, setStatus] = useState('idle'); // 'idle', 'saving', 'error'
const [error, setError] = useState('');
useEffect(() => {
setCurrentValue(value);
}, [value]);
const handleSave = async () => {
if (currentValue === value) {
setIsEditing(false);
return;
}
setStatus('saving');
setError('');
try {
// La funzione onSave ora deve essere una Promise
await onSave(name, currentValue);
setIsEditing(false);
setStatus('idle');
} catch (err) {
setStatus('error');
setError(err.message || 'Salvataggio fallito');
}
};
const handleCancel = () => {
setCurrentValue(value);
setIsEditing(false);
setStatus('idle');
setError('');
};
const renderInputField = () => {
const commonProps = {
name,
value: currentValue || '',
onChange: (e) => setCurrentValue(e.target.value),
autoFocus: true,
};
if (type === 'select') {
return <select {...commonProps}>{options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}</select>;
}
if (type === 'textarea') {
return <textarea {...commonProps} rows="5" />;
}
return <input type={type} {...commonProps} />;
};
const renderDisplayValue = () => {
if (type === 'url' && value) {
return <a href={value} target="_blank" rel="noopener noreferrer">{value}</a>;
}
return <span className="display-value">{value || <em>N/A</em>}</span>;
};
return (
<div className="editable-field-container">
<label>{label}</label>
{isEditing ? (
<div className="edit-mode">
{renderInputField()}
<div className="edit-actions">
<button onClick={handleSave} disabled={status === 'saving'} className="button-icon">
{status === 'saving' ? '...' : <SaveIcon />}
</button>
<button onClick={handleCancel} disabled={status === 'saving'} className="button-icon secondary">
<CancelIcon />
</button>
</div>
{status === 'error' && <small className="error-text">{error}</small>}
</div>
) : (
<div className="display-mode">
{renderDisplayValue()}
<button onClick={() => setIsEditing(true)} className="button-icon outline">
<EditIcon />
</button>
</div>
)}
</div>
);
}
export default EditableField;

View File

@@ -1,68 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import './index.css' import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx' // 2. Rimosso l'import di index.css, ora gestito da Pico
import App from './App.jsx';
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<BrowserRouter>
<App /> <App />
</StrictMode>, </BrowserRouter>
) </StrictMode>
);

View File

@@ -0,0 +1,121 @@
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');
}
return response.json();
};
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);
};
export const getCelebrityById = async (id) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${id}`);
return handleResponse(response);
};
export const createCelebrity = async (celebrityData) => {
const response = await fetch(`${API_BASE_URL}/celebrities/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(celebrityData),
});
return handleResponse(response);
};
export const updateCelebrity = async (id, celebrityData) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(celebrityData),
});
return handleResponse(response);
};
export const deleteCelebrity = async (id) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Errore durante l\'eliminazione');
}
return response.json(); // O un messaggio di successo
};
// --- NUOVE FUNZIONI PER GLI ALIAS ---
export const addAlias = async (celebrityId, aliasName) => {
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/aliases`, {
method: 'POST',
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();
files.forEach(file => {
formData.append('files', file);
});
const response = await fetch(`${API_BASE_URL}/celebrities/${celebrityId}/images`, {
method: 'POST',
body: formData,
});
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.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

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: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB