Migrate from Quart to Flask and add MariaDB dynamic database

Major Changes:
- Migrated web framework from Quart (async) to Flask (sync)
- Added MariaDB database integration with SQLAlchemy ORM
- Implemented dynamic content management for portfolio

New Features:
- Database models for Profile, Skills, Projects, ProjectTags, and SocialLinks
- RESTful API endpoints for CRUD operations on all entities
- Database initialization script (init_db.py) with sample data
- Docker Compose configuration with MariaDB service

Modified Files:
- app.py: Replaced Quart with Flask, added database initialization
- config.py: Added database configuration with environment variables
- routes/home.py: Converted async to sync, added database queries
- requirements.txt: Replaced Quart/Hypercorn with Flask/Gunicorn, added Flask-SQLAlchemy and PyMySQL
- docker-compose.yml: Added MariaDB service with health checks
- templates/: Updated all templates to use dynamic data from database with Jinja2
- .env.example: Added database configuration variables
- README.md: Complete rewrite with new setup instructions and API documentation

New Files:
- models.py: SQLAlchemy models for all database entities
- init_db.py: Database initialization script
- routes/api.py: REST API endpoints for content management

Benefits:
- Simplified architecture (sync vs async)
- Better ecosystem compatibility
- Dynamic content management via database
- Easy content updates through REST API
- Improved deployment with standard WSGI server (Gunicorn)
This commit is contained in:
Claude
2025-11-13 09:16:24 +00:00
parent 058f6205d7
commit c6425235a2
13 changed files with 1010 additions and 217 deletions

View File

@@ -1,4 +1,12 @@
# Flask Application Configuration
APP_HOST=127.0.0.1 APP_HOST=127.0.0.1
APP_PORT=5000 APP_PORT=5000
DEBUG=True DEBUG=True
SECRET_KEY=yoursecretkey SECRET_KEY=change_this_to_a_random_secret_key
# MariaDB Database Configuration
DB_HOST=localhost
DB_PORT=3306
DB_USER=portfolio_user
DB_PASSWORD=portfolio_password
DB_NAME=portfolio_db

228
README.md
View File

@@ -1,93 +1,183 @@
# Python Server - Hersel.it # Portfolio Dinamico - Hersel.it
Questo progetto è un'applicazione web sviluppata con **Quart** e configurata per essere eseguita tramite **Hypercorn** Portfolio personale sviluppato con **Flask** e **MariaDB**, con gestione dinamica dei contenuti tramite API REST.
## Requisiti ## 🚀 Caratteristiche
- **Framework**: Flask (migrato da Quart per semplificare l'architettura)
- **Database**: MariaDB per la gestione dinamica dei contenuti
- **ORM**: SQLAlchemy con Flask-SQLAlchemy
- **API REST**: Endpoint per gestire progetti, competenze, profilo e social links
- **Docker**: Configurazione completa con Docker Compose
- **Responsive**: Design responsive con Bootstrap 5
## 📋 Requisiti
- Python 3.10 o superiore - Python 3.10 o superiore
- MariaDB/MySQL 11.2 o superiore (o usa Docker Compose)
- Pip (gestore dei pacchetti Python) - Pip (gestore dei pacchetti Python)
# Installazione ## 🔧 Installazione Locale
1. Clona il repository: ### 1. Clona il repository
```bash ```bash
git clone https://github.com/BluLupo/hersel.it.git git clone https://github.com/BluLupo/hersel.it.git
cd hersel.it cd hersel.it
``` ```
2. Crea Ambiente Virtuale ### 2. Crea e attiva ambiente virtuale
```bash ```bash
python3 -m venv env python3 -m venv env
``` source env/bin/activate # Linux/Mac
# oppure
env\Scripts\activate # Windows
```
3. Attiva Ambiente Virtuale ### 3. Installa le dipendenze
```bash ```bash
source env/bin/activate pip install -r requirements.txt
``` ```
4. Installa Le Dipendenze ### 4. Configura le variabili d'ambiente
```bash ```bash
pip install -r requirements.txt cp .env.example .env
``` # Modifica .env con le tue credenziali del database
```
# Configurazione ### 5. Configura MariaDB
Modifica il file <b>hypercorn_config.toml</b> se necessario per adattarlo al tuo ambiente Crea il database e l'utente:
Esempio di configurazione predefinita (hypercorn_config.toml): ```sql
CREATE DATABASE portfolio_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'portfolio_user'@'localhost' IDENTIFIED BY 'portfolio_password';
GRANT ALL PRIVILEGES ON portfolio_db.* TO 'portfolio_user'@'localhost';
FLUSH PRIVILEGES;
```
```toml ### 6. Inizializza il database
bind = "0.0.0.0:5000" ```bash
workers = 1 python init_db.py
reload = true ```
```
# Avvio Applicazione
```bash
hypercorn -c hypercorn_config.toml app:app
```
### 7. Avvia l'applicazione
```bash
# Modalità sviluppo
python app.py
# 🚀 Avvio dell'applicazione con Docker # Modalità produzione con Gunicorn
Questa applicazione utilizza Quart come web framework asincrono e Hypercorn come ASGI server gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
⚙️ Requisiti ## 🐳 Installazione con Docker
### Requisiti
- Docker - Docker
- Docker Compose - Docker Compose
# 📄 Come avviare l'applicazione ### Avvio rapido
1 - Crea un nuovo file docker-compose.yml nella tua macchina, con il seguente contenuto (oppure copialo direttamente da <a href="https://github.com/BluLupo/hersel.it/blob/master/docker-compose.yml">Qui</a> ):
```yml
version: "3.9"
services:
quartapp:
image: python:3.10-slim
container_name: quartapp
working_dir: /app
ports:
- "127.0.0.1:5000:5000"
restart: always
command: >
sh -c "
apt-get update &&
apt-get install -y git &&
[ -d /app/.git ] || git clone https://github.com/BluLupo/hersel.it.git /app &&
pip install --no-cache-dir -r requirements.txt &&
hypercorn -c hypercorn_config.toml app:app
"
environment:
- PYTHONUNBUFFERED=1
```
2 - Esegui il servizio con Docker Compose:
```bash ```bash
docker-compose up docker-compose up -d
``` ```
# 🔗 Accesso all'applicazione L'applicazione sarà disponibile su `http://localhost:5000`
Una volta avviata, l'applicazione sarà accessibile da:
Docker Compose avvierà automaticamente:
- Container MariaDB sulla porta 3306
- Container Flask sulla porta 5000
- Inizializzazione automatica del database
## 📁 Struttura del Progetto
```
hersel.it/
├── app.py # Applicazione Flask principale
├── config.py # Configurazione
├── models.py # Modelli SQLAlchemy
├── init_db.py # Script inizializzazione database
├── requirements.txt # Dipendenze Python
├── docker-compose.yml # Configurazione Docker
├── .env.example # Esempio variabili d'ambiente
├── routes/
│ ├── home.py # Route homepage
│ └── api.py # API REST endpoints
├── templates/ # Template Jinja2
│ ├── index.html
│ ├── head.html
│ ├── navbar.html
│ └── content/
│ ├── about.html
│ ├── project.html
│ └── links.html
└── static/ # File statici (CSS, JS, immagini)
```
## 🔌 API REST Endpoints
### Profile
- `GET /api/profile` - Ottieni informazioni profilo
- `PUT /api/profile` - Aggiorna profilo
### Skills
- `GET /api/skills` - Lista competenze
- `POST /api/skills` - Crea competenza
- `PUT /api/skills/<id>` - Aggiorna competenza
- `DELETE /api/skills/<id>` - Elimina competenza
### Projects
- `GET /api/projects` - Lista progetti
- `POST /api/projects` - Crea progetto
- `PUT /api/projects/<id>` - Aggiorna progetto
- `DELETE /api/projects/<id>` - Elimina progetto
### Social Links
- `GET /api/social-links` - Lista link social
- `POST /api/social-links` - Crea link social
- `PUT /api/social-links/<id>` - Aggiorna link social
- `DELETE /api/social-links/<id>` - Elimina link social
## 📊 Schema Database
### Tabelle
- `profile` - Informazioni personali
- `skills` - Competenze tecnologiche
- `projects` - Portfolio progetti
- `project_tags` - Tag/badge progetti
- `social_links` - Link profili social
## 🔄 Migrazione da Quart a Flask
Questo progetto è stato migrato da Quart (framework asincrono) a Flask (framework sincrono) per:
- Semplificare l'architettura
- Migliorare la compatibilità con librerie esistenti
- Facilitare il deployment con server WSGI standard (Gunicorn)
- Ridurre la complessità per un portfolio che non richiede operazioni async intensive
## 🛠️ Sviluppo
### Aggiungere un nuovo progetto via API
```bash ```bash
http://127.0.0.1:5000 curl -X POST http://localhost:5000/api/projects \
``` -H "Content-Type: application/json" \
-d '{
"title": "Nuovo Progetto",
"description": "Descrizione del progetto",
"image_url": "img/project.webp",
"github_url": "https://github.com/username/repo",
"tags": [
{"name": "Python", "color_class": "bg-primary"},
{"name": "Flask", "color_class": "bg-info"}
]
}'
```
## 📝 Licenza
Copyright Hersel Giannella
## 🔗 Link Utili
- Portfolio Live: [https://hersel.it](https://hersel.it)
- GitHub: [https://github.com/BluLupo](https://github.com/BluLupo)
- LinkedIn: [https://linkedin.com/in/hersel](https://linkedin.com/in/hersel)

28
app.py
View File

@@ -3,31 +3,43 @@
# Copyright Hersel Giannella # Copyright Hersel Giannella
from quart import Quart, send_from_directory from flask import Flask, send_from_directory
from config import config from config import config
from models import db
from routes.home import route_home from routes.home import route_home
from routes.api import route_api
app = Quart( app = Flask(
__name__, __name__,
template_folder="templates", template_folder="templates",
static_folder="static", static_folder="static",
) )
# Load configuration
app.config['SECRET_KEY'] = config.SECRET_KEY
app.config['SQLALCHEMY_DATABASE_URI'] = config.SQLALCHEMY_DATABASE_URI
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = config.SQLALCHEMY_TRACK_MODIFICATIONS
app.config['SQLALCHEMY_ECHO'] = config.SQLALCHEMY_ECHO
# Initialize database
db.init_app(app)
# favicon.ico, sitemap.xml and robots.txt # favicon.ico, sitemap.xml and robots.txt
@app.route('/favicon.ico') @app.route('/favicon.ico')
async def favicon(): def favicon():
return await send_from_directory(app.static_folder, 'favicon.ico') return send_from_directory(app.static_folder, 'favicon.ico')
@app.route('/sitemap.xml') @app.route('/sitemap.xml')
async def sitemap(): def sitemap():
return await send_from_directory(app.static_folder, 'sitemap.xml') return send_from_directory(app.static_folder, 'sitemap.xml')
@app.route('/robots.txt') @app.route('/robots.txt')
async def robots(): def robots():
return await send_from_directory(app.static_folder, 'robots.txt') return send_from_directory(app.static_folder, 'robots.txt')
# BluePrint Routes # BluePrint Routes
app.register_blueprint(route_home) app.register_blueprint(route_home)
app.register_blueprint(route_api)
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=config.DEBUG, host=config.APP_HOST, port=config.APP_PORT) app.run(debug=config.DEBUG, host=config.APP_HOST, port=config.APP_PORT)

View File

@@ -11,6 +11,21 @@ class Config(BaseSettings):
DEBUG: bool = True DEBUG: bool = True
SECRET_KEY: str = "default_secret_key" SECRET_KEY: str = "default_secret_key"
# Database Configuration
DB_HOST: str = "localhost"
DB_PORT: int = 3306
DB_USER: str = "portfolio_user"
DB_PASSWORD: str = "portfolio_password"
DB_NAME: str = "portfolio_db"
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
"""Construct MariaDB connection string"""
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
SQLALCHEMY_ECHO: bool = False # Set to True for SQL query debugging
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -1,20 +1,51 @@
version: "3.9" version: "3.9"
services: services:
quartapp: mariadb:
image: mariadb:11.2
container_name: portfolio_mariadb
restart: always
environment:
MYSQL_ROOT_PASSWORD: root_password_change_me
MYSQL_DATABASE: portfolio_db
MYSQL_USER: portfolio_user
MYSQL_PASSWORD: portfolio_password
volumes:
- mariadb_data:/var/lib/mysql
ports:
- "127.0.0.1:3306:3306"
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
flaskapp:
image: python:3.10-slim image: python:3.10-slim
container_name: quartapp container_name: portfolio_flaskapp
working_dir: /app working_dir: /app
ports: ports:
- "127.0.0.1:5000:5000" - "127.0.0.1:5000:5000"
restart: always restart: always
depends_on:
mariadb:
condition: service_healthy
command: > command: >
sh -c " sh -c "
apt-get update && apt-get update &&
apt-get install -y git && apt-get install -y git default-libmysqlclient-dev build-essential pkg-config &&
[ -d /app/.git ] || git clone https://github.com/BluLupo/hersel.it.git /app && [ -d /app/.git ] || git clone https://github.com/BluLupo/hersel.it.git /app &&
pip install --no-cache-dir -r requirements.txt && pip install --no-cache-dir -r requirements.txt &&
hypercorn -c hypercorn_config.toml app:app python init_db.py &&
gunicorn -w 4 -b 0.0.0.0:5000 app:app
" "
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DB_HOST=mariadb
- DB_PORT=3306
- DB_USER=portfolio_user
- DB_PASSWORD=portfolio_password
- DB_NAME=portfolio_db
volumes:
mariadb_data:

180
init_db.py Normal file
View File

@@ -0,0 +1,180 @@
"""
Database initialization script
Populates the database with initial portfolio data
"""
from app import app
from models import db, Profile, Skill, Project, ProjectTag, SocialLink
def init_database():
"""Initialize database with portfolio data"""
with app.app_context():
# Drop all tables and recreate
print("Dropping all tables...")
db.drop_all()
print("Creating all tables...")
db.create_all()
# Create profile information
print("Adding profile information...")
profile = Profile(
title="Il ponte tra sistemi e sviluppo web",
lead_text="Con oltre 7 Anni di esperienza nello sviluppo di applicazioni web con Python Flask, offro soluzioni complete end-to-end.",
description_1="La mia doppia specializzazione mi permette di comprendere a fondo l'intero ciclo di vita delle applicazioni, dall'architettura del server fino all'implementazione e al deployment.",
description_2="Mi piace risolvere problemi complessi e creare soluzioni che siano robuste, scalabili e facili da mantenere.",
years_experience=7
)
db.session.add(profile)
# Create skills
print("Adding skills...")
skills_data = [
{"name": "Linux", "icon_class": "fab fa-linux", "category": "OS", "display_order": 1},
{"name": "Windows", "icon_class": "fab fa-windows", "category": "OS", "display_order": 2},
{"name": "Python", "icon_class": "fab fa-python", "category": "Language", "display_order": 3},
{"name": "Flask", "icon_class": "fas fa-flask", "category": "Framework", "display_order": 4},
{"name": "Database", "icon_class": "fas fa-database", "category": "Tool", "display_order": 5},
{"name": "Docker", "icon_class": "fab fa-docker", "category": "Tool", "display_order": 6},
{"name": "Server", "icon_class": "fas fa-server", "category": "Infrastructure", "display_order": 7},
{"name": "Networking", "icon_class": "fas fa-network-wired", "category": "Infrastructure", "display_order": 8},
]
for skill_data in skills_data:
skill = Skill(**skill_data)
db.session.add(skill)
# Create projects
print("Adding projects...")
# Project 1: Database Backup Script
project1 = Project(
title="Script di Backup Database (MariaDB/MySQL)",
description="Script in Bash per sistemi Linux che permette l'automazione dei backup database",
image_url="img/bash.webp",
github_url="https://github.com/BluLupo/server-script/tree/main/db_bash_backup-main",
display_order=1,
animation_delay="0s"
)
db.session.add(project1)
db.session.flush() # Get project1.id
# Project 1 tags
project1_tags = [
ProjectTag(project_id=project1.id, name="Bash", color_class="bg-primary", display_order=1),
ProjectTag(project_id=project1.id, name="Linux", color_class="bg-info", display_order=2),
]
db.session.add_all(project1_tags)
# Project 2: ByteStash
project2 = Project(
title="Personal ByteStash",
description="Ho realizzato un repository personale di snippet sfruttando Bytestash, ottimizzando la gestione del codice riutilizzabile e migliorando la produttività nello sviluppo di progetti software.",
image_url="img/byte.webp",
demo_url="https://bytestash.gwserver.it/public/snippets",
display_order=2,
animation_delay="0.2s"
)
db.session.add(project2)
db.session.flush()
# Project 2 tags
project2_tags = [
ProjectTag(project_id=project2.id, name="LXC", color_class="bg-warning text-dark", display_order=1),
ProjectTag(project_id=project2.id, name="Proxmox", color_class="bg-dark", display_order=2),
ProjectTag(project_id=project2.id, name="Nginx", color_class="bg-info", display_order=3),
ProjectTag(project_id=project2.id, name="Reverse Proxy", color_class="bg-secondary", display_order=4),
ProjectTag(project_id=project2.id, name="Linux", color_class="bg-primary", display_order=5),
ProjectTag(project_id=project2.id, name="Self-hosted", color_class="bg-primary", display_order=6),
]
db.session.add_all(project2_tags)
# Project 3: Nextcloud
project3 = Project(
title="Nextcloud Personale",
description="Installazione di Nextcloud su container LXC con database PostgreSQL e caching Redis, integrato in una rete privata con gestione IP tramite server DHCP.",
image_url="img/next.webp",
demo_url="https://cloud.gwserver.it",
display_order=3,
animation_delay="0.4s"
)
db.session.add(project3)
db.session.flush()
# Project 3 tags
project3_tags = [
ProjectTag(project_id=project3.id, name="Nextcloud", color_class="bg-primary", display_order=1),
ProjectTag(project_id=project3.id, name="PostgreSQL", color_class="bg-secondary", display_order=2),
ProjectTag(project_id=project3.id, name="Redis", color_class="bg-info", display_order=3),
ProjectTag(project_id=project3.id, name="LXC", color_class="bg-warning text-dark", display_order=4),
ProjectTag(project_id=project3.id, name="Proxmox", color_class="bg-dark", display_order=5),
ProjectTag(project_id=project3.id, name="Rete Privata", color_class="bg-success", display_order=6),
ProjectTag(project_id=project3.id, name="DHCP Server", color_class="bg-secondary", display_order=7),
]
db.session.add_all(project3_tags)
# Create social links
print("Adding social links...")
social_links_data = [
{
"platform_name": "LinkedIn",
"url": "https://linkedin.com/in/hersel",
"icon_class": "fab fa-linkedin",
"display_order": 1,
"animation_delay": "0.1s"
},
{
"platform_name": "GitHub",
"url": "https://github.com/blulupo",
"icon_class": "fab fa-github",
"display_order": 2,
"animation_delay": "0.2s"
},
{
"platform_name": "Stack Overflow",
"url": "https://stackoverflow.com/users/11765177/hersel-giannella",
"icon_class": "fab fa-stack-overflow",
"display_order": 3,
"animation_delay": "0.3s"
},
{
"platform_name": "CodeWars",
"url": "https://www.codewars.com/users/BluLupo",
"icon_class": "fas fa-code",
"display_order": 4,
"animation_delay": "0.4s"
},
{
"platform_name": "Blog",
"url": "https://blog.hersel.it",
"icon_class": "fas fa-blog",
"display_order": 5,
"animation_delay": "0.5s"
},
{
"platform_name": "Email",
"url": "mailto:info@hersel.it",
"icon_class": "fas fa-envelope",
"display_order": 6,
"animation_delay": "0.6s"
},
]
for link_data in social_links_data:
social_link = SocialLink(**link_data)
db.session.add(social_link)
# Commit all changes
print("Committing changes to database...")
db.session.commit()
print("\n✅ Database initialized successfully!")
print(f" - Profile: 1 record")
print(f" - Skills: {len(skills_data)} records")
print(f" - Projects: 3 records")
print(f" - Project Tags: {len(project1_tags) + len(project2_tags) + len(project3_tags)} records")
print(f" - Social Links: {len(social_links_data)} records")
if __name__ == '__main__':
init_database()

133
models.py Normal file
View File

@@ -0,0 +1,133 @@
"""
Database models for Portfolio Application
Uses SQLAlchemy ORM with MariaDB
"""
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
db = SQLAlchemy()
class Profile(db.Model):
"""Store personal profile information"""
__tablename__ = 'profile'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
lead_text = db.Column(db.Text, nullable=False)
description_1 = db.Column(db.Text)
description_2 = db.Column(db.Text)
years_experience = db.Column(db.Integer, default=7)
cv_url = db.Column(db.String(500))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'lead_text': self.lead_text,
'description_1': self.description_1,
'description_2': self.description_2,
'years_experience': self.years_experience,
'cv_url': self.cv_url
}
class Skill(db.Model):
"""Store technical skills/technologies"""
__tablename__ = 'skills'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
icon_class = db.Column(db.String(100), nullable=False)
category = db.Column(db.String(50)) # OS, Language, Framework, Tool, etc.
proficiency_level = db.Column(db.Integer) # 1-5 (optional)
display_order = db.Column(db.Integer, default=0)
is_active = db.Column(db.Boolean, default=True)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'icon_class': self.icon_class,
'category': self.category,
'proficiency_level': self.proficiency_level,
'display_order': self.display_order
}
class Project(db.Model):
"""Store portfolio projects"""
__tablename__ = 'projects'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=False)
image_url = db.Column(db.String(500))
demo_url = db.Column(db.String(500))
github_url = db.Column(db.String(500))
display_order = db.Column(db.Integer, default=0)
animation_delay = db.Column(db.String(10), default='0s') # e.g., '0.2s'
is_published = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
tags = db.relationship('ProjectTag', backref='project', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'description': self.description,
'image_url': self.image_url,
'demo_url': self.demo_url,
'github_url': self.github_url,
'display_order': self.display_order,
'animation_delay': self.animation_delay,
'is_published': self.is_published,
'tags': [tag.to_dict() for tag in self.tags]
}
class ProjectTag(db.Model):
"""Store tags/badges for projects"""
__tablename__ = 'project_tags'
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False)
name = db.Column(db.String(50), nullable=False)
color_class = db.Column(db.String(50), default='bg-primary') # Bootstrap badge classes
display_order = db.Column(db.Integer, default=0)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'color_class': self.color_class,
'display_order': self.display_order
}
class SocialLink(db.Model):
"""Store social media and profile links"""
__tablename__ = 'social_links'
id = db.Column(db.Integer, primary_key=True)
platform_name = db.Column(db.String(100), nullable=False)
url = db.Column(db.String(500), nullable=False)
icon_class = db.Column(db.String(100), nullable=False)
display_order = db.Column(db.Integer, default=0)
animation_delay = db.Column(db.String(10), default='0s') # e.g., '0.1s'
is_active = db.Column(db.Boolean, default=True)
def to_dict(self):
return {
'id': self.id,
'platform_name': self.platform_name,
'url': self.url,
'icon_class': self.icon_class,
'display_order': self.display_order,
'animation_delay': self.animation_delay
}

View File

@@ -1,24 +1,32 @@
aiofiles==24.1.0 # Core Flask Framework
annotated-types==0.7.0
blinker==1.9.0
click==8.1.8
Flask==3.1.0 Flask==3.1.0
h11==0.14.0
h2==4.1.0
hpack==4.0.0
Hypercorn==0.17.3
hyperframe==6.0.1
itsdangerous==2.2.0
Jinja2==3.1.5 Jinja2==3.1.5
Werkzeug==3.1.3
click==8.1.8
itsdangerous==2.2.0
MarkupSafe==3.0.2 MarkupSafe==3.0.2
priority==2.0.0 blinker==1.9.0
# Database - Flask-SQLAlchemy and MariaDB/MySQL driver
Flask-SQLAlchemy==3.1.1
SQLAlchemy==2.0.36
PyMySQL==1.1.1
cryptography==44.0.0
# Configuration Management
pydantic==2.10.4 pydantic==2.10.4
pydantic-settings==2.7.1 pydantic-settings==2.7.1
pydantic_core==2.27.2 pydantic_core==2.27.2
python-dotenv==1.0.1 python-dotenv==1.0.1
Quart==0.20.0 annotated-types==0.7.0
typing_extensions==4.12.2 typing_extensions==4.12.2
Werkzeug==3.1.3
wsproto==1.2.0 # Testing
httpx==0.27.0 httpx==0.27.0
pytest==8.3.4
# Documentation
Sphinx==8.2.3 Sphinx==8.2.3
# WSGI Server (Production alternative to Flask dev server)
gunicorn==23.0.0

239
routes/api.py Normal file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright Hersel Giannella
"""
API Routes for managing portfolio data dynamically
Provides REST endpoints for CRUD operations on Profile, Skills, Projects, and Social Links
"""
from flask import Blueprint, jsonify, request
from models import db, Profile, Skill, Project, ProjectTag, SocialLink
route_api = Blueprint('api', __name__, url_prefix='/api')
# ============================================================================
# PROFILE ENDPOINTS
# ============================================================================
@route_api.route('/profile', methods=['GET'])
def get_profile():
"""Get profile information"""
profile = Profile.query.first()
if profile:
return jsonify(profile.to_dict())
return jsonify({'message': 'Profile not found'}), 404
@route_api.route('/profile', methods=['PUT'])
def update_profile():
"""Update profile information"""
profile = Profile.query.first()
if not profile:
return jsonify({'message': 'Profile not found'}), 404
data = request.json
profile.title = data.get('title', profile.title)
profile.lead_text = data.get('lead_text', profile.lead_text)
profile.description_1 = data.get('description_1', profile.description_1)
profile.description_2 = data.get('description_2', profile.description_2)
profile.years_experience = data.get('years_experience', profile.years_experience)
profile.cv_url = data.get('cv_url', profile.cv_url)
db.session.commit()
return jsonify(profile.to_dict())
# ============================================================================
# SKILLS ENDPOINTS
# ============================================================================
@route_api.route('/skills', methods=['GET'])
def get_skills():
"""Get all skills"""
skills = Skill.query.order_by(Skill.display_order).all()
return jsonify([skill.to_dict() for skill in skills])
@route_api.route('/skills', methods=['POST'])
def create_skill():
"""Create a new skill"""
data = request.json
skill = Skill(
name=data['name'],
icon_class=data['icon_class'],
category=data.get('category'),
proficiency_level=data.get('proficiency_level'),
display_order=data.get('display_order', 0),
is_active=data.get('is_active', True)
)
db.session.add(skill)
db.session.commit()
return jsonify(skill.to_dict()), 201
@route_api.route('/skills/<int:skill_id>', methods=['PUT'])
def update_skill(skill_id):
"""Update a skill"""
skill = Skill.query.get_or_404(skill_id)
data = request.json
skill.name = data.get('name', skill.name)
skill.icon_class = data.get('icon_class', skill.icon_class)
skill.category = data.get('category', skill.category)
skill.proficiency_level = data.get('proficiency_level', skill.proficiency_level)
skill.display_order = data.get('display_order', skill.display_order)
skill.is_active = data.get('is_active', skill.is_active)
db.session.commit()
return jsonify(skill.to_dict())
@route_api.route('/skills/<int:skill_id>', methods=['DELETE'])
def delete_skill(skill_id):
"""Delete a skill"""
skill = Skill.query.get_or_404(skill_id)
db.session.delete(skill)
db.session.commit()
return jsonify({'message': 'Skill deleted successfully'})
# ============================================================================
# PROJECTS ENDPOINTS
# ============================================================================
@route_api.route('/projects', methods=['GET'])
def get_projects():
"""Get all projects"""
projects = Project.query.order_by(Project.display_order).all()
return jsonify([project.to_dict() for project in projects])
@route_api.route('/projects', methods=['POST'])
def create_project():
"""Create a new project"""
data = request.json
project = Project(
title=data['title'],
description=data['description'],
image_url=data.get('image_url'),
demo_url=data.get('demo_url'),
github_url=data.get('github_url'),
display_order=data.get('display_order', 0),
animation_delay=data.get('animation_delay', '0s'),
is_published=data.get('is_published', True)
)
db.session.add(project)
db.session.flush()
# Add tags
tags_data = data.get('tags', [])
for tag_data in tags_data:
tag = ProjectTag(
project_id=project.id,
name=tag_data['name'],
color_class=tag_data.get('color_class', 'bg-primary'),
display_order=tag_data.get('display_order', 0)
)
db.session.add(tag)
db.session.commit()
return jsonify(project.to_dict()), 201
@route_api.route('/projects/<int:project_id>', methods=['PUT'])
def update_project(project_id):
"""Update a project"""
project = Project.query.get_or_404(project_id)
data = request.json
project.title = data.get('title', project.title)
project.description = data.get('description', project.description)
project.image_url = data.get('image_url', project.image_url)
project.demo_url = data.get('demo_url', project.demo_url)
project.github_url = data.get('github_url', project.github_url)
project.display_order = data.get('display_order', project.display_order)
project.animation_delay = data.get('animation_delay', project.animation_delay)
project.is_published = data.get('is_published', project.is_published)
# Update tags if provided
if 'tags' in data:
# Remove old tags
ProjectTag.query.filter_by(project_id=project.id).delete()
# Add new tags
for tag_data in data['tags']:
tag = ProjectTag(
project_id=project.id,
name=tag_data['name'],
color_class=tag_data.get('color_class', 'bg-primary'),
display_order=tag_data.get('display_order', 0)
)
db.session.add(tag)
db.session.commit()
return jsonify(project.to_dict())
@route_api.route('/projects/<int:project_id>', methods=['DELETE'])
def delete_project(project_id):
"""Delete a project"""
project = Project.query.get_or_404(project_id)
db.session.delete(project)
db.session.commit()
return jsonify({'message': 'Project deleted successfully'})
# ============================================================================
# SOCIAL LINKS ENDPOINTS
# ============================================================================
@route_api.route('/social-links', methods=['GET'])
def get_social_links():
"""Get all social links"""
links = SocialLink.query.order_by(SocialLink.display_order).all()
return jsonify([link.to_dict() for link in links])
@route_api.route('/social-links', methods=['POST'])
def create_social_link():
"""Create a new social link"""
data = request.json
link = SocialLink(
platform_name=data['platform_name'],
url=data['url'],
icon_class=data['icon_class'],
display_order=data.get('display_order', 0),
animation_delay=data.get('animation_delay', '0s'),
is_active=data.get('is_active', True)
)
db.session.add(link)
db.session.commit()
return jsonify(link.to_dict()), 201
@route_api.route('/social-links/<int:link_id>', methods=['PUT'])
def update_social_link(link_id):
"""Update a social link"""
link = SocialLink.query.get_or_404(link_id)
data = request.json
link.platform_name = data.get('platform_name', link.platform_name)
link.url = data.get('url', link.url)
link.icon_class = data.get('icon_class', link.icon_class)
link.display_order = data.get('display_order', link.display_order)
link.animation_delay = data.get('animation_delay', link.animation_delay)
link.is_active = data.get('is_active', link.is_active)
db.session.commit()
return jsonify(link.to_dict())
@route_api.route('/social-links/<int:link_id>', methods=['DELETE'])
def delete_social_link(link_id):
"""Delete a social link"""
link = SocialLink.query.get_or_404(link_id)
db.session.delete(link)
db.session.commit()
return jsonify({'message': 'Social link deleted successfully'})

View File

@@ -3,10 +3,24 @@
# Copyright Hersel Giannella # Copyright Hersel Giannella
from quart import Blueprint, render_template from flask import Blueprint, render_template
from models import Profile, Skill, Project, SocialLink
route_home = Blueprint('route_home', __name__) route_home = Blueprint('route_home', __name__)
@route_home.route('/') @route_home.route('/')
async def home(): def home():
return await render_template('index.html') """Render home page with dynamic data from database"""
# Fetch all data from database
profile = Profile.query.first()
skills = Skill.query.filter_by(is_active=True).order_by(Skill.display_order).all()
projects = Project.query.filter_by(is_published=True).order_by(Project.display_order).all()
social_links = SocialLink.query.filter_by(is_active=True).order_by(SocialLink.display_order).all()
return render_template(
'index.html',
profile=profile,
skills=skills,
projects=projects,
social_links=social_links
)

View File

@@ -3,52 +3,77 @@
<h2 class="text-center section-title animate__animated animate__fadeIn">Chi Sono</h2> <h2 class="text-center section-title animate__animated animate__fadeIn">Chi Sono</h2>
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-lg-6 animate__animated animate__fadeInLeft"> <div class="col-lg-6 animate__animated animate__fadeInLeft">
{% if profile %}
<h3 class="mb-4">{{ profile.title }}</h3>
<p class="lead">{{ profile.lead_text }}</p>
{% if profile.description_1 %}
<p>{{ profile.description_1 }}</p>
{% endif %}
{% if profile.description_2 %}
<p>{{ profile.description_2 }}</p>
{% endif %}
<div class="mt-4">
{% if profile.cv_url %}
<a href="{{ profile.cv_url }}" class="btn btn-primary me-2">Scarica CV</a>
{% endif %}
<a href="#links" class="btn btn-outline-secondary">I Miei Profili</a>
</div>
{% else %}
<h3 class="mb-4">Il ponte tra sistemi e sviluppo web</h3> <h3 class="mb-4">Il ponte tra sistemi e sviluppo web</h3>
<p class="lead">Con oltre 7 Anni di esperienza nello sviluppo di applicazioni web con Python Flask, offro soluzioni complete end-to-end.</p> <p class="lead">Con oltre 7 Anni di esperienza nello sviluppo di applicazioni web con Python Flask, offro soluzioni complete end-to-end.</p>
<p>La mia doppia specializzazione mi permette di comprendere a fondo l'intero ciclo di vita delle applicazioni, dall'architettura del server fino all'implementazione e al deployment.</p> <p>La mia doppia specializzazione mi permette di comprendere a fondo l'intero ciclo di vita delle applicazioni, dall'architettura del server fino all'implementazione e al deployment.</p>
<p>Mi piace risolvere problemi complessi e creare soluzioni che siano robuste, scalabili e facili da mantenere.</p> <p>Mi piace risolvere problemi complessi e creare soluzioni che siano robuste, scalabili e facili da mantenere.</p>
<div class="mt-4"> <div class="mt-4">
<!--<a href="#" class="btn btn-primary me-2">Scarica CV</a>-->
<a href="#links" class="btn btn-outline-secondary">I Miei Profili</a> <a href="#links" class="btn btn-outline-secondary">I Miei Profili</a>
</div> </div>
{% endif %}
</div> </div>
<div class="col-lg-6 animate__animated animate__fadeInRight"> <div class="col-lg-6 animate__animated animate__fadeInRight">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-4"> <div class="card-body p-4">
<h4 class="mb-4 text-primary">Tecnologie che utilizzo</h4> <h4 class="mb-4 text-primary">Tecnologie che utilizzo</h4>
<div class="d-flex flex-wrap justify-content-center"> <div class="d-flex flex-wrap justify-content-center">
<div class="text-center m-3"> {% if skills %}
<i class="fab fa-linux tech-icon"></i> {% for skill in skills %}
<p>Linux</p> <div class="text-center m-3">
</div> <i class="{{ skill.icon_class }} tech-icon"></i>
<div class="text-center m-3"> <p>{{ skill.name }}</p>
<i class="fab fa-windows tech-icon"></i> </div>
<p>Windows</p> {% endfor %}
</div> {% else %}
<div class="text-center m-3"> <div class="text-center m-3">
<i class="fab fa-python tech-icon"></i> <i class="fab fa-linux tech-icon"></i>
<p>Python</p> <p>Linux</p>
</div> </div>
<div class="text-center m-3"> <div class="text-center m-3">
<i class="fas fa-flask tech-icon"></i> <i class="fab fa-windows tech-icon"></i>
<p>Flask</p> <p>Windows</p>
</div> </div>
<div class="text-center m-3"> <div class="text-center m-3">
<i class="fas fa-database tech-icon"></i> <i class="fab fa-python tech-icon"></i>
<p>Database</p> <p>Python</p>
</div> </div>
<div class="text-center m-3"> <div class="text-center m-3">
<i class="fab fa-docker tech-icon"></i> <i class="fas fa-flask tech-icon"></i>
<p>Docker</p> <p>Flask</p>
</div> </div>
<div class="text-center m-3"> <div class="text-center m-3">
<i class="fas fa-server tech-icon"></i> <i class="fas fa-database tech-icon"></i>
<p>Server</p> <p>Database</p>
</div> </div>
<div class="text-center m-3"> <div class="text-center m-3">
<i class="fas fa-network-wired tech-icon"></i> <i class="fab fa-docker tech-icon"></i>
<p>Networking</p> <p>Docker</p>
</div> </div>
<div class="text-center m-3">
<i class="fas fa-server tech-icon"></i>
<p>Server</p>
</div>
<div class="text-center m-3">
<i class="fas fa-network-wired tech-icon"></i>
<p>Networking</p>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,42 +6,53 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-5"> <div class="card-body p-5">
<div class="d-flex flex-wrap justify-content-center mb-4"> <div class="d-flex flex-wrap justify-content-center mb-4">
<a href="https://linkedin.com/in/hersel" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.1s"> {% if social_links %}
<div class="text-center"> {% for link in social_links %}
<i class="fab fa-linkedin social-icon"></i> <a href="{{ link.url }}" class="m-3 text-decoration-none animate__animated animate__bounceIn" {% if link.animation_delay != '0s' %}style="animation-delay: {{ link.animation_delay }}"{% endif %}>
<p class="mt-2 fs-5">LinkedIn</p> <div class="text-center">
</div> <i class="{{ link.icon_class }} social-icon"></i>
</a> <p class="mt-2 fs-5">{{ link.platform_name }}</p>
<a href="https://github.com/blulupo" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.2s"> </div>
<div class="text-center"> </a>
<i class="fab fa-github social-icon"></i> {% endfor %}
<p class="mt-2 fs-5">GitHub</p> {% else %}
</div> <a href="https://linkedin.com/in/hersel" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.1s">
</a> <div class="text-center">
<a href="https://stackoverflow.com/users/11765177/hersel-giannella" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.3s"> <i class="fab fa-linkedin social-icon"></i>
<div class="text-center"> <p class="mt-2 fs-5">LinkedIn</p>
<i class="fab fa-stack-overflow social-icon"></i> </div>
<p class="mt-2 fs-5">Stack Overflow</p> </a>
</div> <a href="https://github.com/blulupo" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.2s">
</a> <div class="text-center">
<a href="https://www.codewars.com/users/BluLupo" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.4s"> <i class="fab fa-github social-icon"></i>
<div class="text-center"> <p class="mt-2 fs-5">GitHub</p>
<i class="fas fa-code social-icon"></i> </div>
<p class="mt-2 fs-5">CodeWars</p> </a>
</div> <a href="https://stackoverflow.com/users/11765177/hersel-giannella" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.3s">
</a> <div class="text-center">
<a href="https://blog.hersel.it" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.5s"> <i class="fab fa-stack-overflow social-icon"></i>
<div class="text-center"> <p class="mt-2 fs-5">Stack Overflow</p>
<i class="fas fa-blog social-icon"></i> </div>
<p class="mt-2 fs-5">Blog</p> </a>
</div> <a href="https://www.codewars.com/users/BluLupo" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.4s">
</a> <div class="text-center">
<a href="mailto:info@hersel.it" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.6s"> <i class="fas fa-code social-icon"></i>
<div class="text-center"> <p class="mt-2 fs-5">CodeWars</p>
<i class="fas fa-envelope social-icon"></i> </div>
<p class="mt-2 fs-5">Email</p> </a>
</div> <a href="https://blog.hersel.it" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.5s">
</a> <div class="text-center">
<i class="fas fa-blog social-icon"></i>
<p class="mt-2 fs-5">Blog</p>
</div>
</a>
<a href="mailto:info@hersel.it" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.6s">
<div class="text-center">
<i class="fas fa-envelope social-icon"></i>
<p class="mt-2 fs-5">Email</p>
</div>
</a>
{% endif %}
</div> </div>
<p class="lead mt-4">Scopri di più sul mio lavoro e segui i miei progetti attraverso questi canali</p> <p class="lead mt-4">Scopri di più sul mio lavoro e segui i miei progetti attraverso questi canali</p>
</div> </div>

View File

@@ -2,68 +2,95 @@
<div class="container"> <div class="container">
<h2 class="text-center section-title animate__animated animate__fadeIn">I Miei Progetti</h2> <h2 class="text-center section-title animate__animated animate__fadeIn">I Miei Progetti</h2>
<div class="row"> <div class="row">
<!-- Progetto 1 --> {% if projects %}
<div class="col-lg-4 col-md-6 mb-4 animate__animated animate__fadeInUp"> {% for project in projects %}
<div class="card project-card shadow-sm"> <!-- {{ project.title }} -->
<img src="{{ url_for('static', filename='img/bash.webp')}}" class="card-img-top" alt="Progetto 1"> <div class="col-lg-4 col-md-6 mb-4 animate__animated animate__fadeInUp" {% if project.animation_delay != '0s' %}style="animation-delay: {{ project.animation_delay }}"{% endif %}>
<div class="card-body"> <div class="card project-card shadow-sm">
<h5 class="card-title">Script di Backup Database (MariaDB/MySQL)</h5> {% if project.image_url %}
<p class="card-text">Script in Bash per sistemi Linux che permette l'automazione dei backup database</p> <img src="{{ url_for('static', filename=project.image_url) }}" class="card-img-top" alt="{{ project.title }}">
<div class="d-flex justify-content-between align-items-center mt-3"> {% endif %}
<div> <div class="card-body">
<span class="badge bg-primary me-1">Bash</span> <h5 class="card-title">{{ project.title }}</h5>
<span class="badge bg-info me-1">Linux</span> <p class="card-text">{{ project.description }}</p>
<div class="d-flex justify-content-between align-items-center mt-3">
<div>
{% for tag in project.tags|sort(attribute='display_order') %}
<span class="badge {{ tag.color_class }} me-1">{{ tag.name }}</span>
{% endfor %}
</div>
{% if project.github_url or project.demo_url %}
<a href="{{ project.demo_url if project.demo_url else project.github_url }}" class="btn btn-sm btn-outline-primary">Dettagli</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<!-- Progetto 1 -->
<div class="col-lg-4 col-md-6 mb-4 animate__animated animate__fadeInUp">
<div class="card project-card shadow-sm">
<img src="{{ url_for('static', filename='img/bash.webp')}}" class="card-img-top" alt="Progetto 1">
<div class="card-body">
<h5 class="card-title">Script di Backup Database (MariaDB/MySQL)</h5>
<p class="card-text">Script in Bash per sistemi Linux che permette l'automazione dei backup database</p>
<div class="d-flex justify-content-between align-items-center mt-3">
<div>
<span class="badge bg-primary me-1">Bash</span>
<span class="badge bg-info me-1">Linux</span>
</div>
<a href="https://github.com/BluLupo/server-script/tree/main/db_bash_backup-main" class="btn btn-sm btn-outline-primary">Dettagli</a>
</div> </div>
<a href="https://github.com/BluLupo/server-script/tree/main/db_bash_backup-main" class="btn btn-sm btn-outline-primary">Dettagli</a>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Progetto 2 --> <!-- Progetto 2 -->
<div class="col-lg-4 col-md-6 mb-4 animate__animated animate__fadeInUp" style="animation-delay: 0.2s"> <div class="col-lg-4 col-md-6 mb-4 animate__animated animate__fadeInUp" style="animation-delay: 0.2s">
<div class="card project-card shadow-sm"> <div class="card project-card shadow-sm">
<img src="{{ url_for('static', filename='img/byte.webp')}}" class="card-img-top" alt="Progetto 2"> <img src="{{ url_for('static', filename='img/byte.webp')}}" class="card-img-top" alt="Progetto 2">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Personal ByteStash</h5> <h5 class="card-title">Personal ByteStash</h5>
<p class="card-text">Ho realizzato un repository personale di snippet sfruttando Bytestash, ottimizzando la gestione del codice riutilizzabile e migliorando la produttività nello sviluppo di progetti software.</p> <p class="card-text">Ho realizzato un repository personale di snippet sfruttando Bytestash, ottimizzando la gestione del codice riutilizzabile e migliorando la produttività nello sviluppo di progetti software.</p>
<div class="d-flex justify-content-between align-items-center mt-3"> <div class="d-flex justify-content-between align-items-center mt-3">
<div> <div>
<span class="badge bg-warning text-dark me-1">LXC</span> <span class="badge bg-warning text-dark me-1">LXC</span>
<span class="badge bg-dark me-1">Proxmox</span> <span class="badge bg-dark me-1">Proxmox</span>
<span class="badge bg-info me-1">Nginx</span> <span class="badge bg-info me-1">Nginx</span>
<span class="badge bg-secondary me-1">Reverse Proxy</span> <span class="badge bg-secondary me-1">Reverse Proxy</span>
<span class="badge bg-primary me-1">Linux</span> <span class="badge bg-primary me-1">Linux</span>
<span class="badge bg-primary me-1">Self-hosted</span> <span class="badge bg-primary me-1">Self-hosted</span>
</div>
<a href="https://bytestash.gwserver.it/public/snippets" class="btn btn-sm btn-outline-primary">Dettagli</a>
</div> </div>
<a href="https://bytestash.gwserver.it/public/snippets" class="btn btn-sm btn-outline-primary">Dettagli</a>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Progetto 3 --> <!-- Progetto 3 -->
<div class="col-lg-4 col-md-6 mb-4 animate__animated animate__fadeInUp" style="animation-delay: 0.4s"> <div class="col-lg-4 col-md-6 mb-4 animate__animated animate__fadeInUp" style="animation-delay: 0.4s">
<div class="card project-card shadow-sm"> <div class="card project-card shadow-sm">
<img src="{{ url_for('static', filename='img/next.webp')}}" class="card-img-top" alt="Nextcloud Personale"> <img src="{{ url_for('static', filename='img/next.webp')}}" class="card-img-top" alt="Nextcloud Personale">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Nextcloud Personale</h5> <h5 class="card-title">Nextcloud Personale</h5>
<p class="card-text">Installazione di Nextcloud su container LXC con database PostgreSQL e caching Redis, integrato in una rete privata con gestione IP tramite server DHCP.</p> <p class="card-text">Installazione di Nextcloud su container LXC con database PostgreSQL e caching Redis, integrato in una rete privata con gestione IP tramite server DHCP.</p>
<div class="d-flex justify-content-between align-items-center mt-3"> <div class="d-flex justify-content-between align-items-center mt-3">
<div> <div>
<span class="badge bg-primary me-1">Nextcloud</span> <span class="badge bg-primary me-1">Nextcloud</span>
<span class="badge bg-secondary me-1">PostgreSQL</span> <span class="badge bg-secondary me-1">PostgreSQL</span>
<span class="badge bg-info me-1">Redis</span> <span class="badge bg-info me-1">Redis</span>
<span class="badge bg-warning text-dark me-1">LXC</span> <span class="badge bg-warning text-dark me-1">LXC</span>
<span class="badge bg-dark me-1">Proxmox</span> <span class="badge bg-dark me-1">Proxmox</span>
<span class="badge bg-success me-1">Rete Privata</span> <span class="badge bg-success me-1">Rete Privata</span>
<span class="badge bg-secondary me-1">DHCP Server</span> <span class="badge bg-secondary me-1">DHCP Server</span>
</div>
<a href="https://cloud.gwserver.it" class="btn btn-sm btn-outline-primary">Dettagli</a>
</div> </div>
<a href="https://cloud.gwserver.it" class="btn btn-sm btn-outline-primary">Dettagli</a>
</div> </div>
</div> </div>
</div> </div>
</div> {% endif %}
<!-- <!--