7 Commits

Author SHA1 Message Date
121e9e03b0 Update README.md
Some checks failed
Test Quart Application / build (3.10) (push) Has been cancelled
Test Quart Application / build (3.11) (push) Has been cancelled
2026-02-17 05:56:19 -05:00
6f6a8f0c4a fix docker compose 2025-11-15 23:23:49 +01:00
b6de9d93f1 Merge pull request #3 from BluLupo/claude/migrate-quart-flask-mariadb-011CV5dCApJ51x9myS4yDNme
Migrate portfolio to Flask with MariaDB database
2025-11-13 17:20:34 +01:00
Claude
6845308a34 Add password change functionality and profile image upload
- Add profile_image field to Profile model with default value
- Update profile edit route to handle profile image file uploads
- Add password change route with validation in auth module
- Create change password template with form
- Update profile template to include image upload with preview
- Add password change link to admin sidebar
- Update homepage to use dynamic profile image from database
2025-11-13 15:58:51 +00:00
Claude
425e66a473 Add edit modals and image upload for admin dashboard
User Interface Improvements:
- Added edit modal for skills with activate/deactivate checkbox
- Added edit modal for social links with activate/deactivate checkbox
- Skills and social links now default to "active" when created
- Better UX with inline editing instead of separate pages

Image Upload Feature:
- Implemented file upload for project images
- Support for png, jpg, jpeg, gif, webp (max 16 MB)
- Automatic filename sanitization and timestamp prefixing
- Preview of current image in edit mode
- Option to upload file OR enter manual URL
- Files saved to static/img/ directory

Modified Files:
- app.py: Added upload configuration (MAX_CONTENT_LENGTH, UPLOAD_FOLDER, ALLOWED_EXTENSIONS)
- routes/admin.py: Added save_uploaded_file() helper and file handling in project routes
- templates/admin/skills.html: Added edit modal with is_active checkbox
- templates/admin/social_links.html: Added edit modal with is_active checkbox
- templates/admin/project_form.html: Added file upload input with preview

Benefits:
- No more "inactive" items when creating new entries
- Easy toggle of active/inactive state
- Professional image upload with validation
- Better user experience overall
2025-11-13 15:29:10 +00:00
Claude
aa2c704bfb Add authentication system and admin dashboard
Security Features:
- Added User model with bcrypt password hashing
- Implemented Flask-Login for session management
- Protected all API write operations with @login_required decorator
- Added authentication routes (login/logout)

Admin Dashboard:
- Created comprehensive admin dashboard with statistics
- Profile management interface
- Skills management (add/edit/delete)
- Projects management with full CRUD operations
- Social links management
- Modern responsive UI with Bootstrap 5

New Files:
- models.py: Added User model with bcrypt
- routes/auth.py: Login/logout functionality
- routes/admin.py: Complete admin dashboard with CRUD operations
- templates/auth/login.html: Login page
- templates/admin/base.html: Admin base template
- templates/admin/dashboard.html: Main dashboard
- templates/admin/profile.html: Profile editor
- templates/admin/skills.html: Skills manager
- templates/admin/projects.html: Projects list
- templates/admin/project_form.html: Project editor
- templates/admin/social_links.html: Social links manager

Modified Files:
- app.py: Integrated Flask-Login and bcrypt, registered new blueprints
- requirements.txt: Added Flask-Login, Flask-Bcrypt, bcrypt
- init_db.py: Creates default admin user (admin/admin123)
- routes/api.py: Protected all write operations with authentication

Default Credentials:
- Username: admin
- Password: admin123
- ⚠️ MUST be changed after first login!

Benefits:
- Secure API access with session-based authentication
- User-friendly admin interface for content management
- No need to edit code or database directly
- Bcrypt password hashing for security
- Protected against unauthorized access
2025-11-13 13:49:36 +00:00
Claude
c6425235a2 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)
2025-11-13 09:16:24 +00:00
49 changed files with 2457 additions and 3336 deletions

View File

@@ -1,33 +1,12 @@
# Flask/Quart Configuration
DEBUG=False
SECRET_KEY=your-super-secret-key-here-change-this
# Server Configuration
APP_HOST=0.0.0.0
# Flask Application Configuration
APP_HOST=127.0.0.1
APP_PORT=5000
DEBUG=True
SECRET_KEY=change_this_to_a_random_secret_key
# MySQL Database Configuration
# MariaDB Database Configuration
DB_HOST=localhost
DB_PORT=3306
DB_USER=hersel_user
DB_PASSWORD=your_secure_password_here
DB_NAME=hersel_portfolio
# Upload Configuration
UPLOAD_FOLDER=static/uploads
MAX_CONTENT_LENGTH=16777216
# Email Configuration (optional)
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=your-email@gmail.com
MAIL_PASSWORD=your-email-password
# Pagination
POSTS_PER_PAGE=10
PROJECTS_PER_PAGE=12
# Cache
CACHE_TYPE=simple
CACHE_DEFAULT_TIMEOUT=300
DB_USER=portfolio_user
DB_PASSWORD=portfolio_password
DB_NAME=portfolio_db

221
README.md
View File

@@ -1,93 +1,176 @@
# 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
- MariaDB/MySQL 11.2 o superiore (o usa Docker Compose)
- Pip (gestore dei pacchetti Python)
# Installazione
## 🔧 Installazione Locale
1. Clona il repository:
```bash
git clone https://github.com/BluLupo/hersel.it.git
cd hersel.it
```
### 1. Clona il repository
```bash
git clone https://github.com/BluLupo/hersel.it.git
cd hersel.it
```
2. Crea Ambiente Virtuale
```bash
python3 -m venv env
```
### 2. Crea e attiva ambiente virtuale
```bash
python3 -m venv env
source env/bin/activate # Linux/Mac
# oppure
env\Scripts\activate # Windows
```
3. Attiva Ambiente Virtuale
```bash
source env/bin/activate
```
### 3. Installa le dipendenze
```bash
pip install -r requirements.txt
```
4. Installa Le Dipendenze
```bash
pip install -r requirements.txt
```
### 4. Configura le variabili d'ambiente
```bash
cp .env.example .env
# Modifica .env con le tue credenziali del database
```
# Configurazione
Modifica il file <b>hypercorn_config.toml</b> se necessario per adattarlo al tuo ambiente
Esempio di configurazione predefinita (hypercorn_config.toml):
### 5. Configura MariaDB
Crea il database e l'utente:
```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
bind = "0.0.0.0:5000"
workers = 1
reload = true
```
# Avvio Applicazione
```bash
hypercorn -c hypercorn_config.toml app:app
```
### 6. Inizializza il database
```bash
python init_db.py
```
### 7. Avvia l'applicazione
```bash
# Modalità sviluppo
python app.py
# 🚀 Avvio dell'applicazione con Docker
Questa applicazione utilizza Quart come web framework asincrono e Hypercorn come ASGI server
# Modalità produzione con Gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
⚙️ Requisiti
## 🐳 Installazione con Docker
### Requisiti
- Docker
- Docker Compose
# 📄 Come avviare l'applicazione
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:
### Avvio rapido
```bash
docker-compose up
```
docker-compose up -d
```
# 🔗 Accesso all'applicazione
Una volta avviata, l'applicazione sarà accessibile da:
L'applicazione sarà disponibile su `http://localhost:5000`
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
## 🛠️ Sviluppo
### Aggiungere un nuovo progetto via API
```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)

119
app.py
View File

@@ -2,98 +2,67 @@
# -*- coding: utf-8 -*-
# Copyright Hersel Giannella
# Enhanced Quart Application with Database and Authentication
import asyncio
from quart import Quart, send_from_directory, session, g, render_template
from flask import Flask, send_from_directory
from flask_login import LoginManager
from config import config
from models.database import init_database, db_manager
from utils.helpers import get_flash_messages
from utils.auth import get_current_user
from models import db, bcrypt, User
from routes.home import route_home
from routes.api import route_api
from routes.auth import route_auth
from routes.admin import route_admin
# Import Blueprints
from routes.home import home_bp
from routes.auth import auth_bp
from routes.dashboard import dashboard_bp
app = Quart(
app = Flask(
__name__,
template_folder="templates",
static_folder="static",
)
# Configuration
app.config.from_object(config)
app.secret_key = config.SECRET_KEY
# 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
# Template globals
@app.template_global('get_flashed_messages')
def template_get_flashed_messages(with_categories=False):
return get_flash_messages()
# File upload configuration
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB max file size
app.config['UPLOAD_FOLDER'] = 'static/img'
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# Context processor for current user
@app.before_request
async def load_current_user():
g.current_user = await get_current_user()
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
@app.context_processor
def inject_user():
return {'current_user': getattr(g, 'current_user', None)}
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Per accedere a questa pagina devi effettuare il login.'
login_manager.login_message_category = 'warning'
# Static files routes
@login_manager.user_loader
def load_user(user_id):
"""Load user for Flask-Login"""
return User.query.get(int(user_id))
# favicon.ico, sitemap.xml and robots.txt
@app.route('/favicon.ico')
async def favicon():
return await send_from_directory(app.static_folder, 'favicon.ico')
def favicon():
return send_from_directory(app.static_folder, 'favicon.ico')
@app.route('/sitemap.xml')
async def sitemap():
return await send_from_directory(app.static_folder, 'sitemap.xml')
def sitemap():
return send_from_directory(app.static_folder, 'sitemap.xml')
@app.route('/robots.txt')
async def robots():
return await send_from_directory(app.static_folder, 'robots.txt')
def robots():
return send_from_directory(app.static_folder, 'robots.txt')
# Register Blueprints
app.register_blueprint(home_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
# Database initialization
@app.before_serving
async def initialize_app():
"""Initialize database and other services"""
print("🚀 Initializing Hersel.it application...")
try:
await init_database()
print("✅ Database initialized successfully")
except Exception as e:
print(f"❌ Error initializing database: {e}")
# Don't crash the app, but log the error
@app.after_serving
async def cleanup_app():
"""Cleanup resources"""
print("🔒 Shutting down Hersel.it application...")
await db_manager.close_pool()
print("✅ Application shutdown complete")
# Error handlers
@app.errorhandler(404)
async def not_found(error):
return await render_template('errors/404.html'), 404
@app.errorhandler(500)
async def internal_error(error):
return await render_template('errors/500.html'), 500
# Health check endpoint
@app.route('/health')
async def health_check():
return {'status': 'healthy', 'app': 'Hersel.it Portfolio'}
# BluePrint Routes
app.register_blueprint(route_home)
app.register_blueprint(route_api)
app.register_blueprint(route_auth)
app.register_blueprint(route_admin)
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

@@ -1,58 +1,32 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Enhanced Configuration with Database Settings
# Copyright Hersel Giannella
import os
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
# Load environment variables from .env file
load_dotenv()
class Config(BaseSettings):
APP_HOST: str = "127.0.0.1"
APP_PORT: int = 5000
DEBUG: bool = True
SECRET_KEY: str = "default_secret_key"
class Config:
# Flask/Quart Settings
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
# 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"
# Server Settings
APP_HOST = os.getenv('APP_HOST', '0.0.0.0')
APP_PORT = int(os.getenv('APP_PORT', '5000'))
@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}"
# Database Settings
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', '3306'))
DB_USER = os.getenv('DB_USER', 'hersel_user')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'your_password_here')
DB_NAME = os.getenv('DB_NAME', 'hersel_portfolio')
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
SQLALCHEMY_ECHO: bool = False # Set to True for SQL query debugging
# Session Settings
SESSION_PERMANENT = False
SESSION_TYPE = 'filesystem'
PERMANENT_SESSION_LIFETIME = 60 * 60 * 24 * 7 # 7 days
class Config:
env_file = ".env"
# Upload Settings
UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER', 'static/uploads')
MAX_CONTENT_LENGTH = int(os.getenv('MAX_CONTENT_LENGTH', '16777216')) # 16MB
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'txt', 'doc', 'docx'}
# Email Settings (for future use)
MAIL_SERVER = os.getenv('MAIL_SERVER', 'localhost')
MAIL_PORT = int(os.getenv('MAIL_PORT', '587'))
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'True').lower() == 'true'
MAIL_USERNAME = os.getenv('MAIL_USERNAME', '')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', '')
# Security Settings
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = None
# Pagination
POSTS_PER_PAGE = int(os.getenv('POSTS_PER_PAGE', '10'))
PROJECTS_PER_PAGE = int(os.getenv('PROJECTS_PER_PAGE', '12'))
# Cache Settings
CACHE_TYPE = os.getenv('CACHE_TYPE', 'simple')
CACHE_DEFAULT_TIMEOUT = int(os.getenv('CACHE_DEFAULT_TIMEOUT', '300'))
# Create config instance
config = Config()

View File

@@ -1,53 +1,23 @@
version: '3.9'
version: "3.9"
services:
# MySQL Database
mysql:
image: mysql:8.0
container_name: hersel_mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root_password_change_me
MYSQL_DATABASE: hersel_portfolio
MYSQL_USER: hersel_user
MYSQL_PASSWORD: secure_password_123
ports:
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
command: --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
# Quart Application
quartapp:
build: .
container_name: hersel_app
restart: always
flaskapp:
image: python:3.11-slim
container_name: portfolio_flaskapp
working_dir: /app
ports:
- "127.0.0.1:5000:5000"
restart: always
command: >
sh -c "
apt-get update &&
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 &&
pip install --no-cache-dir -r requirements.txt &&
python init_db.py &&
gunicorn -w 4 -b 0.0.0.0:5000 app:app
"
environment:
- DEBUG=False
- SECRET_KEY=super-secret-key-change-in-production-please
- DB_HOST=mysql
- DB_PORT=3306
- DB_USER=hersel_user
- DB_PASSWORD=secure_password_123
- DB_NAME=hersel_portfolio
- PYTHONUNBUFFERED=1
depends_on:
mysql:
condition: service_healthy
volumes:
- ./static/uploads:/app/static/uploads
volumes:
mysql_data:
driver: local
networks:
default:
name: hersel_network
env_file:
- .env

143
init.sql
View File

@@ -1,143 +0,0 @@
-- Initial database setup for Hersel.it Portfolio
-- This file is automatically executed when MySQL container starts
USE hersel_portfolio;
-- Enable UTF8MB4 charset for emoji and international characters
SET NAMES utf8mb4;
SET character_set_client = utf8mb4;
-- Create categories table
CREATE TABLE IF NOT EXISTS categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
color VARCHAR(7) DEFAULT '#007bff',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(50),
last_name VARCHAR(50),
role ENUM('admin', 'user') DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Create projects table
CREATE TABLE IF NOT EXISTS projects (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
description TEXT,
content LONGTEXT,
image_url VARCHAR(500),
github_url VARCHAR(500),
demo_url VARCHAR(500),
technologies JSON,
category_id INT,
is_featured BOOLEAN DEFAULT FALSE,
is_published BOOLEAN DEFAULT TRUE,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Create posts table (for future blog functionality)
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
excerpt TEXT,
content LONGTEXT,
featured_image VARCHAR(500),
category_id INT,
author_id INT,
is_published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Create settings table
CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value LONGTEXT,
description TEXT,
type ENUM('text', 'textarea', 'boolean', 'json') DEFAULT 'text',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Insert default categories
INSERT INTO categories (name, slug, description, color) VALUES
('Web Development', 'web-development', 'Progetti di sviluppo web', '#007bff'),
('Mobile Apps', 'mobile-apps', 'Applicazioni mobile', '#28a745'),
('Desktop Apps', 'desktop-apps', 'Applicazioni desktop', '#ffc107'),
('APIs', 'apis', 'API e servizi web', '#dc3545'),
('Tools & Utilities', 'tools-utilities', 'Strumenti e utility', '#6f42c1'),
('Open Source', 'open-source', 'Progetti open source', '#20c997')
ON DUPLICATE KEY UPDATE name=VALUES(name);
-- Insert default settings
INSERT INTO settings (setting_key, setting_value, description, type) VALUES
('site_name', 'Hersel.it', 'Nome del sito', 'text'),
('site_description', 'Portfolio personale di Hersel Giannella - Developer', 'Descrizione del sito', 'textarea'),
('admin_email', 'admin@hersel.it', 'Email amministratore', 'text'),
('site_logo', '/static/img/logo.png', 'URL del logo', 'text'),
('social_github', 'https://github.com/BluLupo', 'GitHub URL', 'text'),
('social_linkedin', '', 'LinkedIn URL', 'text'),
('social_twitter', '', 'Twitter URL', 'text'),
('site_maintenance', 'false', 'Modalità manutenzione', 'boolean'),
('analytics_code', '', 'Codice Analytics', 'textarea'),
('projects_per_page', '12', 'Progetti per pagina', 'text'),
('featured_projects_limit', '6', 'Limite progetti in evidenza', 'text')
ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value);
-- Create admin user (password: AdminPass123!)
-- This creates a default admin user for initial access
-- Password hash for 'AdminPass123!' generated with bcrypt
INSERT INTO users (username, email, password_hash, first_name, last_name, role) VALUES
('admin', 'admin@hersel.it', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqyaqr06eoAGNz9CpahtY1q', 'Admin', 'User', 'admin')
ON DUPLICATE KEY UPDATE role=VALUES(role);
-- Insert sample projects (optional)
INSERT INTO projects (title, slug, description, content, github_url, technologies, is_featured, is_published, created_by) VALUES
('Hersel.it Portfolio', 'hersel-it-portfolio', 'Portfolio dinamico sviluppato con Quart e MySQL',
'<h2>Portfolio Dinamico</h2><p>Questo portfolio è stato sviluppato utilizzando le seguenti tecnologie:</p><ul><li>Python con Quart framework</li><li>MySQL per il database</li><li>Bootstrap 5 per il frontend</li><li>Docker per il deployment</li></ul>',
'https://github.com/BluLupo/hersel.it',
'["Python", "Quart", "MySQL", "Bootstrap", "Docker"]',
TRUE, TRUE, 1),
('API REST con Quart', 'api-rest-quart', 'API RESTful asincrona per gestione dati',
'<h2>API REST</h2><p>API asincrona sviluppata con Quart per gestire operazioni CRUD su database MySQL.</p>',
'',
'["Python", "Quart", "MySQL", "REST API", "Async"]',
FALSE, TRUE, 1)
ON DUPLICATE KEY UPDATE title=VALUES(title);
-- Create indexes for better performance
CREATE INDEX idx_projects_published ON projects(is_published);
CREATE INDEX idx_projects_featured ON projects(is_featured);
CREATE INDEX idx_projects_category ON projects(category_id);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_posts_published ON posts(is_published);
-- Show confirmation message
SELECT 'Database initialized successfully!' as Status;
SELECT COUNT(*) as 'Total Categories' FROM categories;
SELECT COUNT(*) as 'Total Settings' FROM settings;
SELECT COUNT(*) as 'Admin Users' FROM users WHERE role='admin';
SELECT COUNT(*) as 'Sample Projects' FROM projects;

198
init_db.py Normal file
View File

@@ -0,0 +1,198 @@
"""
Database initialization script
Populates the database with initial portfolio data and creates default admin user
"""
from app import app
from models import db, User, 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 default admin user
print("Creating default admin user...")
admin = User(
username='admin',
email='admin@hersel.it'
)
admin.set_password('admin123') # CHANGE THIS PASSWORD AFTER FIRST LOGIN!
db.session.add(admin)
# 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,
profile_image='img/personal.webp'
)
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" - Admin User: 1 record")
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")
print("\n" + "="*60)
print("🔐 DEFAULT ADMIN CREDENTIALS")
print("="*60)
print(f" Username: admin")
print(f" Password: admin123")
print(f" ⚠️ CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN!")
print("="*60)
if __name__ == '__main__':
init_database()

169
models.py Normal file
View File

@@ -0,0 +1,169 @@
"""
Database models for Portfolio Application
Uses SQLAlchemy ORM with MariaDB
"""
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from flask_bcrypt import Bcrypt
from datetime import datetime
db = SQLAlchemy()
bcrypt = Bcrypt()
class User(UserMixin, db.Model):
"""Store admin users for authentication"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
def set_password(self, password):
"""Hash password using bcrypt"""
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
"""Verify password against hash"""
return bcrypt.check_password_hash(self.password_hash, password)
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None
}
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))
profile_image = db.Column(db.String(500), default='img/personal.webp')
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,
'profile_image': self.profile_image
}
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,8 +0,0 @@
# Database Models Package
from .user import User
from .post import Post
from .project import Project
from .category import Category
from .settings import Settings
__all__ = ['User', 'Post', 'Project', 'Category', 'Settings']

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Category Model
from typing import Optional, Dict, List
from .database import db_manager
from datetime import datetime
class Category:
def __init__(self, **kwargs):
self.id = kwargs.get('id')
self.name = kwargs.get('name')
self.slug = kwargs.get('slug')
self.description = kwargs.get('description')
self.color = kwargs.get('color', '#007bff')
self.created_at = kwargs.get('created_at')
async def save(self) -> int:
"""Save category to database"""
if self.id:
# Update existing category
query = """
UPDATE categories SET
name=%s, slug=%s, description=%s, color=%s
WHERE id=%s
"""
params = (self.name, self.slug, self.description, self.color, self.id)
await db_manager.execute_update(query, params)
return self.id
else:
# Insert new category
query = """
INSERT INTO categories (name, slug, description, color)
VALUES (%s, %s, %s, %s)
"""
params = (self.name, self.slug, self.description, self.color)
category_id = await db_manager.execute_insert(query, params)
self.id = category_id
return category_id
@classmethod
async def find_by_id(cls, category_id: int) -> Optional['Category']:
"""Find category by ID"""
query = "SELECT * FROM categories WHERE id = %s"
results = await db_manager.execute_query(query, (category_id,))
if results:
return cls(**results[0])
return None
@classmethod
async def find_by_slug(cls, slug: str) -> Optional['Category']:
"""Find category by slug"""
query = "SELECT * FROM categories WHERE slug = %s"
results = await db_manager.execute_query(query, (slug,))
if results:
return cls(**results[0])
return None
@classmethod
async def get_all(cls, limit: int = 50) -> List['Category']:
"""Get all categories"""
query = "SELECT * FROM categories ORDER BY name ASC LIMIT %s"
results = await db_manager.execute_query(query, (limit,))
return [cls(**row) for row in results]
@classmethod
async def count(cls) -> int:
"""Count total categories"""
query = "SELECT COUNT(*) as count FROM categories"
results = await db_manager.execute_query(query)
return results[0]['count'] if results else 0
async def delete(self) -> bool:
"""Delete category"""
query = "DELETE FROM categories WHERE id = %s"
affected = await db_manager.execute_update(query, (self.id,))
return affected > 0
def to_dict(self) -> Dict:
"""Convert category to dictionary"""
return {
'id': self.id,
'name': self.name,
'slug': self.slug,
'description': self.description,
'color': self.color,
'created_at': self.created_at.isoformat() if self.created_at else None
}
@staticmethod
def generate_slug(name: str) -> str:
"""Generate URL-friendly slug from name"""
import re
slug = re.sub(r'[^\w\s-]', '', name).strip().lower()
slug = re.sub(r'[\s_-]+', '-', slug)
return slug

View File

@@ -1,194 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Database Configuration and Connection Manager
import asyncio
import aiomysql
from typing import Optional
from config import config
class DatabaseManager:
def __init__(self):
self.pool: Optional[aiomysql.Pool] = None
async def init_pool(self):
"""Initialize connection pool"""
try:
self.pool = await aiomysql.create_pool(
host=config.DB_HOST,
port=config.DB_PORT,
user=config.DB_USER,
password=config.DB_PASSWORD,
db=config.DB_NAME,
minsize=1,
maxsize=10,
autocommit=True,
charset='utf8mb4'
)
print("✅ Database connection pool initialized")
except Exception as e:
print(f"❌ Error initializing database pool: {e}")
raise
async def close_pool(self):
"""Close connection pool"""
if self.pool:
self.pool.close()
await self.pool.wait_closed()
print("🔒 Database connection pool closed")
async def get_connection(self):
"""Get connection from pool"""
if not self.pool:
await self.init_pool()
return self.pool.acquire()
async def execute_query(self, query: str, params: tuple = None):
"""Execute a query and return results"""
async with self.pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(query, params)
return await cursor.fetchall()
async def execute_insert(self, query: str, params: tuple = None):
"""Execute insert query and return last insert id"""
async with self.pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(query, params)
return cursor.lastrowid
async def execute_update(self, query: str, params: tuple = None):
"""Execute update/delete query and return affected rows"""
async with self.pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(query, params)
return cursor.rowcount
# Global database manager instance
db_manager = DatabaseManager()
async def init_database():
"""Initialize database and create tables"""
await db_manager.init_pool()
await create_tables()
async def create_tables():
"""Create database tables if they don't exist"""
tables = [
# Users table
"""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(50),
last_name VARCHAR(50),
role ENUM('admin', 'user') DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""",
# Categories table
"""
CREATE TABLE IF NOT EXISTS categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
color VARCHAR(7) DEFAULT '#007bff',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""",
# Projects table
"""
CREATE TABLE IF NOT EXISTS projects (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
description TEXT,
content LONGTEXT,
image_url VARCHAR(500),
github_url VARCHAR(500),
demo_url VARCHAR(500),
technologies JSON,
category_id INT,
is_featured BOOLEAN DEFAULT FALSE,
is_published BOOLEAN DEFAULT TRUE,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""",
# Posts table (for blog functionality)
"""
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
excerpt TEXT,
content LONGTEXT,
featured_image VARCHAR(500),
category_id INT,
author_id INT,
is_published BOOLEAN DEFAULT FALSE,
published_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""",
# Settings table
"""
CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value LONGTEXT,
description TEXT,
type ENUM('text', 'textarea', 'boolean', 'json') DEFAULT 'text',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""
]
for table_sql in tables:
try:
await db_manager.execute_update(table_sql)
print(f"✅ Table created/verified")
except Exception as e:
print(f"❌ Error creating table: {e}")
# Insert default settings
await insert_default_settings()
async def insert_default_settings():
"""Insert default settings if they don't exist"""
default_settings = [
('site_name', 'Hersel.it', 'Nome del sito', 'text'),
('site_description', 'Portfolio personale di Hersel Giannella', 'Descrizione del sito', 'textarea'),
('admin_email', 'admin@hersel.it', 'Email amministratore', 'text'),
('site_logo', '/static/img/logo.png', 'URL del logo', 'text'),
('social_github', 'https://github.com/BluLupo', 'GitHub URL', 'text'),
('social_linkedin', '', 'LinkedIn URL', 'text'),
('social_twitter', '', 'Twitter URL', 'text'),
('site_maintenance', 'false', 'Modalità manutenzione', 'boolean'),
('analytics_code', '', 'Codice Analytics', 'textarea')
]
for key, value, desc, type_val in default_settings:
try:
await db_manager.execute_insert(
"INSERT IGNORE INTO settings (setting_key, setting_value, description, type) VALUES (%s, %s, %s, %s)",
(key, value, desc, type_val)
)
except Exception as e:
print(f"Warning: Could not insert setting {key}: {e}")

View File

@@ -1,116 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Post Model (for future blog functionality)
from typing import Optional, Dict, List
from .database import db_manager
from datetime import datetime
class Post:
def __init__(self, **kwargs):
self.id = kwargs.get('id')
self.title = kwargs.get('title')
self.slug = kwargs.get('slug')
self.excerpt = kwargs.get('excerpt')
self.content = kwargs.get('content')
self.featured_image = kwargs.get('featured_image')
self.category_id = kwargs.get('category_id')
self.author_id = kwargs.get('author_id')
self.is_published = kwargs.get('is_published', False)
self.published_at = kwargs.get('published_at')
self.created_at = kwargs.get('created_at')
self.updated_at = kwargs.get('updated_at')
async def save(self) -> int:
"""Save post to database"""
if self.id:
# Update existing post
query = """
UPDATE posts SET
title=%s, slug=%s, excerpt=%s, content=%s, featured_image=%s,
category_id=%s, is_published=%s, published_at=%s, updated_at=NOW()
WHERE id=%s
"""
params = (self.title, self.slug, self.excerpt, self.content, self.featured_image,
self.category_id, self.is_published, self.published_at, self.id)
await db_manager.execute_update(query, params)
return self.id
else:
# Insert new post
query = """
INSERT INTO posts (title, slug, excerpt, content, featured_image, category_id,
author_id, is_published, published_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
params = (self.title, self.slug, self.excerpt, self.content, self.featured_image,
self.category_id, self.author_id, self.is_published, self.published_at)
post_id = await db_manager.execute_insert(query, params)
self.id = post_id
return post_id
@classmethod
async def find_by_id(cls, post_id: int) -> Optional['Post']:
"""Find post by ID"""
query = "SELECT * FROM posts WHERE id = %s"
results = await db_manager.execute_query(query, (post_id,))
if results:
return cls(**results[0])
return None
@classmethod
async def find_by_slug(cls, slug: str) -> Optional['Post']:
"""Find post by slug"""
query = "SELECT * FROM posts WHERE slug = %s AND is_published = TRUE"
results = await db_manager.execute_query(query, (slug,))
if results:
return cls(**results[0])
return None
@classmethod
async def get_all(cls, published_only: bool = True, limit: int = 50, offset: int = 0) -> List['Post']:
"""Get all posts with pagination"""
query = "SELECT * FROM posts"
params = []
if published_only:
query += " WHERE is_published = TRUE"
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
results = await db_manager.execute_query(query, tuple(params))
return [cls(**row) for row in results]
@classmethod
async def count(cls, published_only: bool = True) -> int:
"""Count total posts"""
query = "SELECT COUNT(*) as count FROM posts"
if published_only:
query += " WHERE is_published = TRUE"
results = await db_manager.execute_query(query)
return results[0]['count'] if results else 0
async def delete(self) -> bool:
"""Delete post"""
query = "DELETE FROM posts WHERE id = %s"
affected = await db_manager.execute_update(query, (self.id,))
return affected > 0
def to_dict(self) -> Dict:
"""Convert post to dictionary"""
return {
'id': self.id,
'title': self.title,
'slug': self.slug,
'excerpt': self.excerpt,
'content': self.content,
'featured_image': self.featured_image,
'category_id': self.category_id,
'author_id': self.author_id,
'is_published': self.is_published,
'published_at': self.published_at.isoformat() if self.published_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}

View File

@@ -1,183 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Project Model
import json
from typing import Optional, Dict, List
from .database import db_manager
from datetime import datetime
class Project:
def __init__(self, **kwargs):
self.id = kwargs.get('id')
self.title = kwargs.get('title')
self.slug = kwargs.get('slug')
self.description = kwargs.get('description')
self.content = kwargs.get('content')
self.image_url = kwargs.get('image_url')
self.github_url = kwargs.get('github_url')
self.demo_url = kwargs.get('demo_url')
self.technologies = kwargs.get('technologies', [])
self.category_id = kwargs.get('category_id')
self.is_featured = kwargs.get('is_featured', False)
self.is_published = kwargs.get('is_published', True)
self.created_by = kwargs.get('created_by')
self.created_at = kwargs.get('created_at')
self.updated_at = kwargs.get('updated_at')
# Handle JSON technologies field
if isinstance(self.technologies, str):
try:
self.technologies = json.loads(self.technologies)
except:
self.technologies = []
@property
def technologies_json(self) -> str:
"""Get technologies as JSON string"""
return json.dumps(self.technologies) if self.technologies else '[]'
async def save(self) -> int:
"""Save project to database"""
if self.id:
# Update existing project
query = """
UPDATE projects SET
title=%s, slug=%s, description=%s, content=%s, image_url=%s,
github_url=%s, demo_url=%s, technologies=%s, category_id=%s,
is_featured=%s, is_published=%s, updated_at=NOW()
WHERE id=%s
"""
params = (self.title, self.slug, self.description, self.content, self.image_url,
self.github_url, self.demo_url, self.technologies_json, self.category_id,
self.is_featured, self.is_published, self.id)
await db_manager.execute_update(query, params)
return self.id
else:
# Insert new project
query = """
INSERT INTO projects (title, slug, description, content, image_url, github_url, demo_url,
technologies, category_id, is_featured, is_published, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
params = (self.title, self.slug, self.description, self.content, self.image_url,
self.github_url, self.demo_url, self.technologies_json, self.category_id,
self.is_featured, self.is_published, self.created_by)
project_id = await db_manager.execute_insert(query, params)
self.id = project_id
return project_id
@classmethod
async def find_by_id(cls, project_id: int) -> Optional['Project']:
"""Find project by ID"""
query = "SELECT * FROM projects WHERE id = %s"
results = await db_manager.execute_query(query, (project_id,))
if results:
return cls(**results[0])
return None
@classmethod
async def find_by_slug(cls, slug: str) -> Optional['Project']:
"""Find project by slug"""
query = "SELECT * FROM projects WHERE slug = %s AND is_published = TRUE"
results = await db_manager.execute_query(query, (slug,))
if results:
return cls(**results[0])
return None
@classmethod
async def get_all(cls, published_only: bool = True, limit: int = 50, offset: int = 0) -> List['Project']:
"""Get all projects with pagination"""
query = "SELECT * FROM projects"
params = []
if published_only:
query += " WHERE is_published = TRUE"
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
results = await db_manager.execute_query(query, tuple(params))
return [cls(**row) for row in results]
@classmethod
async def get_featured(cls, limit: int = 6) -> List['Project']:
"""Get featured projects"""
query = """
SELECT * FROM projects
WHERE is_published = TRUE AND is_featured = TRUE
ORDER BY created_at DESC
LIMIT %s
"""
results = await db_manager.execute_query(query, (limit,))
return [cls(**row) for row in results]
@classmethod
async def get_by_category(cls, category_id: int, limit: int = 20) -> List['Project']:
"""Get projects by category"""
query = """
SELECT * FROM projects
WHERE category_id = %s AND is_published = TRUE
ORDER BY created_at DESC
LIMIT %s
"""
results = await db_manager.execute_query(query, (category_id, limit))
return [cls(**row) for row in results]
@classmethod
async def search(cls, search_term: str, limit: int = 20) -> List['Project']:
"""Search projects by title and description"""
query = """
SELECT * FROM projects
WHERE (title LIKE %s OR description LIKE %s) AND is_published = TRUE
ORDER BY created_at DESC
LIMIT %s
"""
search_pattern = f"%{search_term}%"
results = await db_manager.execute_query(query, (search_pattern, search_pattern, limit))
return [cls(**row) for row in results]
@classmethod
async def count(cls, published_only: bool = True) -> int:
"""Count total projects"""
query = "SELECT COUNT(*) as count FROM projects"
if published_only:
query += " WHERE is_published = TRUE"
results = await db_manager.execute_query(query)
return results[0]['count'] if results else 0
async def delete(self) -> bool:
"""Delete project"""
query = "DELETE FROM projects WHERE id = %s"
affected = await db_manager.execute_update(query, (self.id,))
return affected > 0
def to_dict(self) -> Dict:
"""Convert project to dictionary"""
return {
'id': self.id,
'title': self.title,
'slug': self.slug,
'description': self.description,
'content': self.content,
'image_url': self.image_url,
'github_url': self.github_url,
'demo_url': self.demo_url,
'technologies': self.technologies,
'category_id': self.category_id,
'is_featured': self.is_featured,
'is_published': self.is_published,
'created_by': self.created_by,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
@staticmethod
def generate_slug(title: str) -> str:
"""Generate URL-friendly slug from title"""
import re
slug = re.sub(r'[^\w\s-]', '', title).strip().lower()
slug = re.sub(r'[\s_-]+', '-', slug)
return slug

View File

@@ -1,127 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Settings Model
import json
from typing import Optional, Dict, List, Any
from .database import db_manager
class Settings:
def __init__(self, **kwargs):
self.id = kwargs.get('id')
self.setting_key = kwargs.get('setting_key')
self.setting_value = kwargs.get('setting_value')
self.description = kwargs.get('description')
self.type = kwargs.get('type', 'text')
self.updated_at = kwargs.get('updated_at')
@property
def parsed_value(self) -> Any:
"""Parse setting value based on type"""
if not self.setting_value:
return None
if self.type == 'boolean':
return self.setting_value.lower() in ('true', '1', 'yes', 'on')
elif self.type == 'json':
try:
return json.loads(self.setting_value)
except:
return {}
else:
return self.setting_value
async def save(self) -> int:
"""Save setting to database"""
if self.id:
# Update existing setting
query = """
UPDATE settings SET
setting_key=%s, setting_value=%s, description=%s, type=%s, updated_at=NOW()
WHERE id=%s
"""
params = (self.setting_key, self.setting_value, self.description, self.type, self.id)
await db_manager.execute_update(query, params)
return self.id
else:
# Insert new setting
query = """
INSERT INTO settings (setting_key, setting_value, description, type)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
setting_value=VALUES(setting_value),
description=VALUES(description),
type=VALUES(type),
updated_at=NOW()
"""
params = (self.setting_key, self.setting_value, self.description, self.type)
setting_id = await db_manager.execute_insert(query, params)
self.id = setting_id or self.id
return self.id
@classmethod
async def get(cls, key: str, default: Any = None) -> Any:
"""Get setting value by key"""
query = "SELECT * FROM settings WHERE setting_key = %s"
results = await db_manager.execute_query(query, (key,))
if results:
setting = cls(**results[0])
return setting.parsed_value
return default
@classmethod
async def set(cls, key: str, value: Any, description: str = '', setting_type: str = 'text') -> bool:
"""Set setting value"""
# Convert value to string based on type
if setting_type == 'boolean':
str_value = 'true' if value else 'false'
elif setting_type == 'json':
str_value = json.dumps(value) if isinstance(value, (dict, list)) else str(value)
else:
str_value = str(value)
setting = cls(
setting_key=key,
setting_value=str_value,
description=description,
type=setting_type
)
try:
await setting.save()
return True
except:
return False
@classmethod
async def get_all(cls) -> List['Settings']:
"""Get all settings"""
query = "SELECT * FROM settings ORDER BY setting_key ASC"
results = await db_manager.execute_query(query)
return [cls(**row) for row in results]
@classmethod
async def get_by_prefix(cls, prefix: str) -> List['Settings']:
"""Get settings by key prefix"""
query = "SELECT * FROM settings WHERE setting_key LIKE %s ORDER BY setting_key ASC"
results = await db_manager.execute_query(query, (f"{prefix}%",))
return [cls(**row) for row in results]
async def delete(self) -> bool:
"""Delete setting"""
query = "DELETE FROM settings WHERE id = %s"
affected = await db_manager.execute_update(query, (self.id,))
return affected > 0
def to_dict(self) -> Dict:
"""Convert setting to dictionary"""
return {
'id': self.id,
'setting_key': self.setting_key,
'setting_value': self.setting_value,
'parsed_value': self.parsed_value,
'description': self.description,
'type': self.type,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# User Model
import bcrypt
from typing import Optional, Dict, List
from .database import db_manager
from datetime import datetime
class User:
def __init__(self, **kwargs):
self.id = kwargs.get('id')
self.username = kwargs.get('username')
self.email = kwargs.get('email')
self.password_hash = kwargs.get('password_hash')
self.first_name = kwargs.get('first_name')
self.last_name = kwargs.get('last_name')
self.role = kwargs.get('role', 'user')
self.is_active = kwargs.get('is_active', True)
self.created_at = kwargs.get('created_at')
self.updated_at = kwargs.get('updated_at')
@property
def full_name(self) -> str:
"""Get user's full name"""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.username
@property
def is_admin(self) -> bool:
"""Check if user is admin"""
return self.role == 'admin'
def set_password(self, password: str) -> None:
"""Hash and set user password"""
salt = bcrypt.gensalt()
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def check_password(self, password: str) -> bool:
"""Check if provided password matches hash"""
if not self.password_hash:
return False
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
async def save(self) -> int:
"""Save user to database"""
if self.id:
# Update existing user
query = """
UPDATE users SET
username=%s, email=%s, first_name=%s, last_name=%s,
role=%s, is_active=%s, updated_at=NOW()
WHERE id=%s
"""
params = (self.username, self.email, self.first_name, self.last_name,
self.role, self.is_active, self.id)
await db_manager.execute_update(query, params)
return self.id
else:
# Insert new user
query = """
INSERT INTO users (username, email, password_hash, first_name, last_name, role, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
params = (self.username, self.email, self.password_hash, self.first_name,
self.last_name, self.role, self.is_active)
user_id = await db_manager.execute_insert(query, params)
self.id = user_id
return user_id
@classmethod
async def find_by_id(cls, user_id: int) -> Optional['User']:
"""Find user by ID"""
query = "SELECT * FROM users WHERE id = %s AND is_active = TRUE"
results = await db_manager.execute_query(query, (user_id,))
if results:
return cls(**results[0])
return None
@classmethod
async def find_by_username(cls, username: str) -> Optional['User']:
"""Find user by username"""
query = "SELECT * FROM users WHERE username = %s AND is_active = TRUE"
results = await db_manager.execute_query(query, (username,))
if results:
return cls(**results[0])
return None
@classmethod
async def find_by_email(cls, email: str) -> Optional['User']:
"""Find user by email"""
query = "SELECT * FROM users WHERE email = %s AND is_active = TRUE"
results = await db_manager.execute_query(query, (email,))
if results:
return cls(**results[0])
return None
@classmethod
async def authenticate(cls, username_or_email: str, password: str) -> Optional['User']:
"""Authenticate user with username/email and password"""
# Try to find by username first, then by email
user = await cls.find_by_username(username_or_email)
if not user:
user = await cls.find_by_email(username_or_email)
if user and user.check_password(password):
return user
return None
@classmethod
async def get_all(cls, limit: int = 50, offset: int = 0) -> List['User']:
"""Get all users with pagination"""
query = """
SELECT * FROM users
ORDER BY created_at DESC
LIMIT %s OFFSET %s
"""
results = await db_manager.execute_query(query, (limit, offset))
return [cls(**row) for row in results]
@classmethod
async def count(cls) -> int:
"""Count total users"""
query = "SELECT COUNT(*) as count FROM users WHERE is_active = TRUE"
results = await db_manager.execute_query(query)
return results[0]['count'] if results else 0
async def delete(self) -> bool:
"""Soft delete user (mark as inactive)"""
query = "UPDATE users SET is_active = FALSE WHERE id = %s"
affected = await db_manager.execute_update(query, (self.id,))
return affected > 0
def to_dict(self, include_sensitive: bool = False) -> Dict:
"""Convert user to dictionary"""
data = {
'id': self.id,
'username': self.username,
'email': self.email,
'first_name': self.first_name,
'last_name': self.last_name,
'full_name': self.full_name,
'role': self.role,
'is_active': self.is_active,
'is_admin': self.is_admin,
'created_at': self.created_at.isoformat() if self.created_at else None
}
if include_sensitive:
data['password_hash'] = self.password_hash
return data

View File

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

351
routes/admin.py Normal file
View File

@@ -0,0 +1,351 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright Hersel Giannella
"""
Admin dashboard routes for managing portfolio content
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from models import db, Profile, Skill, Project, ProjectTag, SocialLink
import os
route_admin = Blueprint('admin', __name__, url_prefix='/admin')
def allowed_file(filename):
"""Check if file extension is allowed"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
def save_uploaded_file(file):
"""Save uploaded file and return the relative path"""
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
# Add timestamp to avoid collisions
from datetime import datetime
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_')
filename = timestamp + filename
# Create upload folder if it doesn't exist
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
filepath = os.path.join(upload_folder, filename)
file.save(filepath)
# Return relative path from static folder
return f"img/{filename}"
return None
@route_admin.route('/')
@login_required
def dashboard():
"""Admin dashboard home"""
# Statistiche
stats = {
'projects': Project.query.count(),
'skills': Skill.query.count(),
'social_links': SocialLink.query.count(),
'published_projects': Project.query.filter_by(is_published=True).count()
}
return render_template('admin/dashboard.html', stats=stats)
# ============================================================================
# PROFILE MANAGEMENT
# ============================================================================
@route_admin.route('/profile')
@login_required
def profile_manage():
"""Manage profile information"""
profile = Profile.query.first()
return render_template('admin/profile.html', profile=profile)
@route_admin.route('/profile/edit', methods=['POST'])
@login_required
def profile_edit():
"""Edit profile information"""
profile = Profile.query.first()
if not profile:
flash('Profilo non trovato.', 'danger')
return redirect(url_for('admin.profile_manage'))
# Handle profile image upload
profile_image_url = request.form.get('profile_image', profile.profile_image)
if 'profile_image_file' in request.files:
file = request.files['profile_image_file']
if file.filename:
uploaded_path = save_uploaded_file(file)
if uploaded_path:
profile_image_url = uploaded_path
else:
flash('Formato immagine non valido. Usa: png, jpg, jpeg, gif, webp', 'danger')
return redirect(url_for('admin.profile_manage'))
profile.title = request.form.get('title', profile.title)
profile.lead_text = request.form.get('lead_text', profile.lead_text)
profile.description_1 = request.form.get('description_1', profile.description_1)
profile.description_2 = request.form.get('description_2', profile.description_2)
profile.years_experience = int(request.form.get('years_experience', profile.years_experience))
profile.cv_url = request.form.get('cv_url', profile.cv_url)
profile.profile_image = profile_image_url
db.session.commit()
flash('Profilo aggiornato con successo!', 'success')
return redirect(url_for('admin.profile_manage'))
# ============================================================================
# SKILLS MANAGEMENT
# ============================================================================
@route_admin.route('/skills')
@login_required
def skills_manage():
"""Manage skills"""
skills = Skill.query.order_by(Skill.display_order).all()
return render_template('admin/skills.html', skills=skills)
@route_admin.route('/skills/add', methods=['POST'])
@login_required
def skill_add():
"""Add new skill"""
skill = Skill(
name=request.form.get('name'),
icon_class=request.form.get('icon_class'),
category=request.form.get('category'),
display_order=int(request.form.get('display_order', 0)),
is_active=request.form.get('is_active') == 'on'
)
db.session.add(skill)
db.session.commit()
flash('Skill aggiunta con successo!', 'success')
return redirect(url_for('admin.skills_manage'))
@route_admin.route('/skills/<int:skill_id>/edit', methods=['POST'])
@login_required
def skill_edit(skill_id):
"""Edit skill"""
skill = Skill.query.get_or_404(skill_id)
skill.name = request.form.get('name', skill.name)
skill.icon_class = request.form.get('icon_class', skill.icon_class)
skill.category = request.form.get('category', skill.category)
skill.display_order = int(request.form.get('display_order', skill.display_order))
skill.is_active = request.form.get('is_active') == 'on'
db.session.commit()
flash('Skill aggiornata con successo!', 'success')
return redirect(url_for('admin.skills_manage'))
@route_admin.route('/skills/<int:skill_id>/delete', methods=['POST'])
@login_required
def skill_delete(skill_id):
"""Delete skill"""
skill = Skill.query.get_or_404(skill_id)
db.session.delete(skill)
db.session.commit()
flash('Skill eliminata con successo!', 'success')
return redirect(url_for('admin.skills_manage'))
# ============================================================================
# PROJECTS MANAGEMENT
# ============================================================================
@route_admin.route('/projects')
@login_required
def projects_manage():
"""Manage projects"""
projects = Project.query.order_by(Project.display_order).all()
return render_template('admin/projects.html', projects=projects)
@route_admin.route('/projects/add', methods=['GET', 'POST'])
@login_required
def project_add():
"""Add new project"""
if request.method == 'POST':
# Handle image upload
image_url = request.form.get('image_url') # Manual URL input
if 'image_file' in request.files:
file = request.files['image_file']
if file.filename: # Se è stato selezionato un file
uploaded_path = save_uploaded_file(file)
if uploaded_path:
image_url = uploaded_path
else:
flash('Formato immagine non valido. Usa: png, jpg, jpeg, gif, webp', 'danger')
return redirect(url_for('admin.project_add'))
project = Project(
title=request.form.get('title'),
description=request.form.get('description'),
image_url=image_url,
demo_url=request.form.get('demo_url'),
github_url=request.form.get('github_url'),
display_order=int(request.form.get('display_order', 0)),
animation_delay=request.form.get('animation_delay', '0s'),
is_published=request.form.get('is_published') == 'on'
)
db.session.add(project)
db.session.flush()
# Aggiungi tags
tags_input = request.form.get('tags', '')
if tags_input:
tags_list = [tag.strip() for tag in tags_input.split(',') if tag.strip()]
for idx, tag_name in enumerate(tags_list):
# Estrai colore se specificato (formato: "Python:bg-primary")
if ':' in tag_name:
tag_name, color = tag_name.split(':', 1)
else:
color = 'bg-primary'
tag = ProjectTag(
project_id=project.id,
name=tag_name.strip(),
color_class=color.strip(),
display_order=idx
)
db.session.add(tag)
db.session.commit()
flash('Progetto aggiunto con successo!', 'success')
return redirect(url_for('admin.projects_manage'))
return render_template('admin/project_form.html', project=None)
@route_admin.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
@login_required
def project_edit(project_id):
"""Edit project"""
project = Project.query.get_or_404(project_id)
if request.method == 'POST':
# Handle image upload
image_url = request.form.get('image_url', project.image_url)
if 'image_file' in request.files:
file = request.files['image_file']
if file.filename: # Se è stato selezionato un file
uploaded_path = save_uploaded_file(file)
if uploaded_path:
image_url = uploaded_path
else:
flash('Formato immagine non valido. Usa: png, jpg, jpeg, gif, webp', 'danger')
return redirect(url_for('admin.project_edit', project_id=project_id))
project.title = request.form.get('title', project.title)
project.description = request.form.get('description', project.description)
project.image_url = image_url
project.demo_url = request.form.get('demo_url', project.demo_url)
project.github_url = request.form.get('github_url', project.github_url)
project.display_order = int(request.form.get('display_order', project.display_order))
project.animation_delay = request.form.get('animation_delay', project.animation_delay)
project.is_published = request.form.get('is_published') == 'on'
# Aggiorna tags
ProjectTag.query.filter_by(project_id=project.id).delete()
tags_input = request.form.get('tags', '')
if tags_input:
tags_list = [tag.strip() for tag in tags_input.split(',') if tag.strip()]
for idx, tag_name in enumerate(tags_list):
if ':' in tag_name:
tag_name, color = tag_name.split(':', 1)
else:
color = 'bg-primary'
tag = ProjectTag(
project_id=project.id,
name=tag_name.strip(),
color_class=color.strip(),
display_order=idx
)
db.session.add(tag)
db.session.commit()
flash('Progetto aggiornato con successo!', 'success')
return redirect(url_for('admin.projects_manage'))
return render_template('admin/project_form.html', project=project)
@route_admin.route('/projects/<int:project_id>/delete', methods=['POST'])
@login_required
def project_delete(project_id):
"""Delete project"""
project = Project.query.get_or_404(project_id)
db.session.delete(project)
db.session.commit()
flash('Progetto eliminato con successo!', 'success')
return redirect(url_for('admin.projects_manage'))
# ============================================================================
# SOCIAL LINKS MANAGEMENT
# ============================================================================
@route_admin.route('/social-links')
@login_required
def social_links_manage():
"""Manage social links"""
social_links = SocialLink.query.order_by(SocialLink.display_order).all()
return render_template('admin/social_links.html', social_links=social_links)
@route_admin.route('/social-links/add', methods=['POST'])
@login_required
def social_link_add():
"""Add new social link"""
link = SocialLink(
platform_name=request.form.get('platform_name'),
url=request.form.get('url'),
icon_class=request.form.get('icon_class'),
display_order=int(request.form.get('display_order', 0)),
animation_delay=request.form.get('animation_delay', '0s'),
is_active=request.form.get('is_active') == 'on'
)
db.session.add(link)
db.session.commit()
flash('Link social aggiunto con successo!', 'success')
return redirect(url_for('admin.social_links_manage'))
@route_admin.route('/social-links/<int:link_id>/edit', methods=['POST'])
@login_required
def social_link_edit(link_id):
"""Edit social link"""
link = SocialLink.query.get_or_404(link_id)
link.platform_name = request.form.get('platform_name', link.platform_name)
link.url = request.form.get('url', link.url)
link.icon_class = request.form.get('icon_class', link.icon_class)
link.display_order = int(request.form.get('display_order', link.display_order))
link.animation_delay = request.form.get('animation_delay', link.animation_delay)
link.is_active = request.form.get('is_active') == 'on'
db.session.commit()
flash('Link social aggiornato con successo!', 'success')
return redirect(url_for('admin.social_links_manage'))
@route_admin.route('/social-links/<int:link_id>/delete', methods=['POST'])
@login_required
def social_link_delete(link_id):
"""Delete social link"""
link = SocialLink.query.get_or_404(link_id)
db.session.delete(link)
db.session.commit()
flash('Link social eliminato con successo!', 'success')
return redirect(url_for('admin.social_links_manage'))

251
routes/api.py Normal file
View File

@@ -0,0 +1,251 @@
#!/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
All write operations (POST, PUT, DELETE) require authentication
"""
from flask import Blueprint, jsonify, request
from flask_login import login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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'])
@login_required
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

@@ -1,123 +1,101 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Authentication Routes
# Copyright Hersel Giannella
from quart import Blueprint, request, render_template, redirect, url_for, session, flash
from models.user import User
from utils.auth import login_user, logout_user, get_current_user
from utils.validators import validate_email, validate_password, validate_username
from utils.helpers import flash_message
"""
Authentication routes for login/logout
"""
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, current_user, login_required
from models import User, db
from datetime import datetime
@auth_bp.route('/login', methods=['GET', 'POST'])
async def login():
route_auth = Blueprint('auth', __name__, url_prefix='/auth')
@route_auth.route('/login', methods=['GET', 'POST'])
def login():
"""Login page"""
if request.method == 'GET':
return await render_template('auth/login.html')
# Se l'utente è già autenticato, reindirizza alla dashboard
if current_user.is_authenticated:
return redirect(url_for('admin.dashboard'))
form_data = await request.form
username_or_email = form_data.get('username', '').strip()
password = form_data.get('password', '')
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember', False)
if not username_or_email or not password:
flash_message('Username/Email e password sono richiesti', 'error')
return await render_template('auth/login.html')
if not username or not password:
flash('Per favore inserisci username e password.', 'danger')
return render_template('auth/login.html')
# Authenticate user
user = await User.authenticate(username_or_email, password)
user = User.query.filter_by(username=username).first()
if user:
login_user(user)
flash_message(f'Benvenuto, {user.full_name}!', 'success')
if user and user.check_password(password):
if not user.is_active:
flash('Il tuo account è stato disabilitato.', 'danger')
return render_template('auth/login.html')
# Redirect to dashboard if admin, home otherwise
if user.is_admin:
return redirect(url_for('dashboard.index'))
# Aggiorna last_login
user.last_login = datetime.utcnow()
db.session.commit()
login_user(user, remember=remember)
flash(f'Benvenuto {user.username}!', 'success')
# Redirect alla pagina richiesta o alla dashboard
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('admin.dashboard'))
else:
return redirect(url_for('home.index'))
else:
flash_message('Credenziali non valide', 'error')
return await render_template('auth/login.html')
flash('Username o password non corretti.', 'danger')
@auth_bp.route('/register', methods=['GET', 'POST'])
async def register():
"""Registration page"""
if request.method == 'GET':
return await render_template('auth/register.html')
return render_template('auth/login.html')
form_data = await request.form
username = form_data.get('username', '').strip()
email = form_data.get('email', '').strip()
password = form_data.get('password', '')
confirm_password = form_data.get('confirm_password', '')
first_name = form_data.get('first_name', '').strip()
last_name = form_data.get('last_name', '').strip()
errors = {}
# Validate inputs
is_valid, error = validate_username(username)
if not is_valid:
errors['username'] = error
is_valid, error = validate_email(email)
if not is_valid:
errors['email'] = error
is_valid, error = validate_password(password)
if not is_valid:
errors['password'] = error
if password != confirm_password:
errors['confirm_password'] = 'Le password non coincidono'
# Check if user already exists
if not errors:
existing_user = await User.find_by_username(username)
if existing_user:
errors['username'] = 'Username già in uso'
existing_email = await User.find_by_email(email)
if existing_email:
errors['email'] = 'Email già registrata'
if errors:
for field, error in errors.items():
flash_message(error, 'error')
return await render_template('auth/register.html')
# Create new user
user = User(
username=username,
email=email,
first_name=first_name,
last_name=last_name,
role='user' # First user can be manually promoted to admin
)
user.set_password(password)
try:
await user.save()
flash_message('Registrazione completata! Ora puoi effettuare il login.', 'success')
return redirect(url_for('auth.login'))
except Exception as e:
flash_message('Errore durante la registrazione. Riprova.', 'error')
return await render_template('auth/register.html')
@auth_bp.route('/logout')
async def logout():
@route_auth.route('/logout')
def logout():
"""Logout user"""
logout_user()
flash_message('Logout effettuato con successo', 'success')
return redirect(url_for('home.index'))
flash('Logout effettuato con successo.', 'info')
return redirect(url_for('route_home.home'))
@auth_bp.route('/profile')
async def profile():
"""User profile page"""
user = await get_current_user()
if not user:
return redirect(url_for('auth.login'))
return await render_template('auth/profile.html', user=user)
@route_auth.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
"""Change password page"""
if request.method == 'POST':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
# Validation
if not current_password or not new_password or not confirm_password:
flash('Tutti i campi sono obbligatori.', 'danger')
return render_template('auth/change_password.html')
# Check current password
if not current_user.check_password(current_password):
flash('La password attuale non è corretta.', 'danger')
return render_template('auth/change_password.html')
# Check if new passwords match
if new_password != confirm_password:
flash('Le nuove password non corrispondono.', 'danger')
return render_template('auth/change_password.html')
# Check password length
if len(new_password) < 6:
flash('La nuova password deve essere di almeno 6 caratteri.', 'danger')
return render_template('auth/change_password.html')
# Update password
current_user.set_password(new_password)
db.session.commit()
flash('Password modificata con successo!', 'success')
return redirect(url_for('admin.dashboard'))
return render_template('auth/change_password.html')

View File

@@ -1,218 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Dashboard Routes (Admin)
from quart import Blueprint, request, render_template, redirect, url_for, jsonify
from models.user import User
from models.project import Project
from models.category import Category
from utils.auth import admin_required, get_current_user
from utils.helpers import flash_message, generate_slug, calculate_pagination
from utils.validators import validate_project_data
dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/dashboard')
@dashboard_bp.route('/')
@admin_required
async def index():
"""Dashboard home"""
current_user = await get_current_user()
# Get statistics
stats = {
'total_users': await User.count(),
'total_projects': await Project.count(published_only=False),
'published_projects': await Project.count(published_only=True),
'featured_projects': len(await Project.get_featured())
}
# Get recent projects
recent_projects = await Project.get_all(published_only=False, limit=5)
return await render_template('dashboard/index.html',
user=current_user,
stats=stats,
recent_projects=recent_projects)
@dashboard_bp.route('/projects')
@admin_required
async def projects():
"""Projects management"""
page = int(request.args.get('page', 1))
per_page = 10
# Get projects with pagination
projects = await Project.get_all(published_only=False, limit=per_page, offset=(page-1)*per_page)
total_projects = await Project.count(published_only=False)
pagination = calculate_pagination(total_projects, page, per_page)
return await render_template('dashboard/projects.html',
projects=projects,
pagination=pagination)
@dashboard_bp.route('/projects/new', methods=['GET', 'POST'])
@admin_required
async def new_project():
"""Create new project"""
if request.method == 'GET':
categories = await Category.get_all()
return await render_template('dashboard/project_form.html',
project=None,
categories=categories,
action='create')
form_data = await request.form
data = {
'title': form_data.get('title', '').strip(),
'description': form_data.get('description', '').strip(),
'content': form_data.get('content', '').strip(),
'github_url': form_data.get('github_url', '').strip(),
'demo_url': form_data.get('demo_url', '').strip(),
'image_url': form_data.get('image_url', '').strip(),
'technologies': form_data.getlist('technologies'),
'category_id': int(form_data.get('category_id')) if form_data.get('category_id') else None,
'is_featured': bool(form_data.get('is_featured')),
'is_published': bool(form_data.get('is_published'))
}
# Validate data
is_valid, errors = validate_project_data(data)
if not is_valid:
for field, error in errors.items():
flash_message(error, 'error')
categories = await Category.get_all()
return await render_template('dashboard/project_form.html',
project=data,
categories=categories,
action='create')
# Create project
current_user = await get_current_user()
project = Project(
title=data['title'],
slug=generate_slug(data['title']),
description=data['description'],
content=data['content'],
github_url=data['github_url'],
demo_url=data['demo_url'],
image_url=data['image_url'],
technologies=data['technologies'],
category_id=data['category_id'],
is_featured=data['is_featured'],
is_published=data['is_published'],
created_by=current_user.id
)
try:
await project.save()
flash_message('Progetto creato con successo!', 'success')
return redirect(url_for('dashboard.projects'))
except Exception as e:
flash_message('Errore durante la creazione del progetto', 'error')
categories = await Category.get_all()
return await render_template('dashboard/project_form.html',
project=data,
categories=categories,
action='create')
@dashboard_bp.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
@admin_required
async def edit_project(project_id):
"""Edit project"""
project = await Project.find_by_id(project_id)
if not project:
flash_message('Progetto non trovato', 'error')
return redirect(url_for('dashboard.projects'))
if request.method == 'GET':
categories = await Category.get_all()
return await render_template('dashboard/project_form.html',
project=project,
categories=categories,
action='edit')
form_data = await request.form
data = {
'title': form_data.get('title', '').strip(),
'description': form_data.get('description', '').strip(),
'content': form_data.get('content', '').strip(),
'github_url': form_data.get('github_url', '').strip(),
'demo_url': form_data.get('demo_url', '').strip(),
'image_url': form_data.get('image_url', '').strip(),
'technologies': form_data.getlist('technologies'),
'category_id': int(form_data.get('category_id')) if form_data.get('category_id') else None,
'is_featured': bool(form_data.get('is_featured')),
'is_published': bool(form_data.get('is_published'))
}
# Validate data
is_valid, errors = validate_project_data(data)
if not is_valid:
for field, error in errors.items():
flash_message(error, 'error')
categories = await Category.get_all()
return await render_template('dashboard/project_form.html',
project=project,
categories=categories,
action='edit')
# Update project
project.title = data['title']
project.slug = generate_slug(data['title'])
project.description = data['description']
project.content = data['content']
project.github_url = data['github_url']
project.demo_url = data['demo_url']
project.image_url = data['image_url']
project.technologies = data['technologies']
project.category_id = data['category_id']
project.is_featured = data['is_featured']
project.is_published = data['is_published']
try:
await project.save()
flash_message('Progetto aggiornato con successo!', 'success')
return redirect(url_for('dashboard.projects'))
except Exception as e:
flash_message('Errore durante l\'aggiornamento del progetto', 'error')
categories = await Category.get_all()
return await render_template('dashboard/project_form.html',
project=project,
categories=categories,
action='edit')
@dashboard_bp.route('/projects/<int:project_id>/delete', methods=['POST'])
@admin_required
async def delete_project(project_id):
"""Delete project"""
project = await Project.find_by_id(project_id)
if not project:
return jsonify({'error': 'Progetto non trovato'}), 404
try:
await project.delete()
flash_message('Progetto eliminato con successo!', 'success')
return redirect(url_for('dashboard.projects'))
except Exception as e:
flash_message('Errore durante l\'eliminazione del progetto', 'error')
return redirect(url_for('dashboard.projects'))
@dashboard_bp.route('/users')
@admin_required
async def users():
"""Users management"""
page = int(request.args.get('page', 1))
per_page = 10
users = await User.get_all(limit=per_page, offset=(page-1)*per_page)
total_users = await User.count()
pagination = calculate_pagination(total_users, page, per_page)
return await render_template('dashboard/users.html',
users=users,
pagination=pagination)

View File

@@ -2,56 +2,25 @@
# -*- coding: utf-8 -*-
# Copyright Hersel Giannella
# Home Routes
from quart import Blueprint, render_template
from models.project import Project
from models.settings import Settings
from flask import Blueprint, render_template
from models import Profile, Skill, Project, SocialLink
# Blueprint with correct name
home_bp = Blueprint('home', __name__)
route_home = Blueprint('route_home', __name__)
@home_bp.route('/')
async def index():
"""Homepage with featured projects"""
# Get featured projects
featured_projects = await Project.get_featured(limit=6)
@route_home.route('/')
def home():
"""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()
# Get site settings
site_name = await Settings.get('site_name', 'Hersel.it')
site_description = await Settings.get('site_description', 'Portfolio personale di Hersel Giannella')
return await render_template('home/index.html',
featured_projects=featured_projects,
site_name=site_name,
site_description=site_description)
@home_bp.route('/progetti')
async def projects():
"""Projects page"""
# Get all published projects
projects = await Project.get_all(published_only=True, limit=50)
return await render_template('home/projects.html', projects=projects)
@home_bp.route('/progetto/<slug>')
async def project_detail(slug):
"""Single project page"""
project = await Project.find_by_slug(slug)
if not project:
return await render_template('errors/404.html'), 404
return await render_template('home/project_detail.html', project=project)
@home_bp.route('/about')
async def about():
"""About page"""
return await render_template('home/about.html')
@home_bp.route('/contatti')
async def contact():
"""Contact page"""
return await render_template('home/contact.html')
# Keep backward compatibility
route_home = home_bp
return render_template(
'index.html',
profile=profile,
skills=skills,
projects=projects,
social_links=social_links
)

143
templates/admin/base.html Normal file
View File

@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Dashboard{% endblock %} - Portfolio</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--sidebar-width: 250px;
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: var(--sidebar-width);
background: var(--primary-gradient);
color: white;
padding: 0;
overflow-y: auto;
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.sidebar-menu {
padding: 1rem 0;
}
.sidebar-menu a {
color: rgba(255,255,255,0.8);
text-decoration: none;
padding: 0.75rem 1.5rem;
display: block;
transition: all 0.3s;
}
.sidebar-menu a:hover,
.sidebar-menu a.active {
background: rgba(255,255,255,0.1);
color: white;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2rem;
}
.top-bar {
background: white;
padding: 1rem 2rem;
margin: -2rem -2rem 2rem -2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.stat-card {
background: white;
border-radius: 10px;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
}
.btn-gradient {
background: var(--primary-gradient);
border: none;
color: white;
}
.btn-gradient:hover {
opacity: 0.9;
color: white;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h4 class="mb-0"><i class="fas fa-briefcase me-2"></i>Portfolio Admin</h4>
<small class="text-white-50">Ciao, {{ current_user.username }}</small>
</div>
<nav class="sidebar-menu">
<a href="{{ url_for('admin.dashboard') }}" class="{% if request.endpoint == 'admin.dashboard' %}active{% endif %}">
<i class="fas fa-th-large me-2"></i>Dashboard
</a>
<a href="{{ url_for('admin.profile_manage') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}">
<i class="fas fa-user me-2"></i>Profilo
</a>
<a href="{{ url_for('admin.skills_manage') }}" class="{% if 'skills' in request.endpoint %}active{% endif %}">
<i class="fas fa-code me-2"></i>Competenze
</a>
<a href="{{ url_for('admin.projects_manage') }}" class="{% if 'projects' in request.endpoint %}active{% endif %}">
<i class="fas fa-folder-open me-2"></i>Progetti
</a>
<a href="{{ url_for('admin.social_links_manage') }}" class="{% if 'social' in request.endpoint %}active{% endif %}">
<i class="fas fa-share-alt me-2"></i>Link Social
</a>
<hr class="border-white my-3 mx-3">
<a href="{{ url_for('auth.change_password') }}" class="{% if request.endpoint == 'auth.change_password' %}active{% endif %}">
<i class="fas fa-key me-2"></i>Cambia Password
</a>
<a href="{{ url_for('route_home.home') }}" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>Visualizza Sito
</a>
<a href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-2"></i>Logout
</a>
</nav>
</div>
<!-- Main Content -->
<div class="main-content">
<div class="top-bar">
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0">{% block page_title %}Dashboard{% endblock %}</h3>
<div>
<span class="badge bg-success"><i class="fas fa-check-circle me-1"></i>Online</span>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,126 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block content %}
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Progetti</h6>
<h2 class="mb-0">{{ stats.projects }}</h2>
</div>
<div class="fs-1 text-primary">
<i class="fas fa-folder-open"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Competenze</h6>
<h2 class="mb-0">{{ stats.skills }}</h2>
</div>
<div class="fs-1 text-success">
<i class="fas fa-code"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Link Social</h6>
<h2 class="mb-0">{{ stats.social_links }}</h2>
</div>
<div class="fs-1 text-info">
<i class="fas fa-share-alt"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-2">Pubblicati</h6>
<h2 class="mb-0">{{ stats.published_projects }}</h2>
</div>
<div class="fs-1 text-warning">
<i class="fas fa-eye"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="fas fa-chart-bar me-2"></i>Panoramica</h5>
</div>
<div class="card-body">
<h6>Benvenuto nella Dashboard Admin</h6>
<p class="text-muted">
Da qui puoi gestire tutti i contenuti del tuo portfolio. Usa il menu a sinistra per navigare tra le diverse sezioni.
</p>
<div class="mt-4">
<h6>Azioni Rapide</h6>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('admin.projects_manage') }}" class="btn btn-gradient">
<i class="fas fa-plus me-2"></i>Nuovo Progetto
</a>
<a href="{{ url_for('admin.skills_manage') }}" class="btn btn-outline-primary">
<i class="fas fa-plus me-2"></i>Nuova Skill
</a>
<a href="{{ url_for('route_home.home') }}" class="btn btn-outline-secondary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>Visualizza Sito
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="fas fa-info-circle me-2"></i>Info Sistema</h5>
</div>
<div class="card-body">
<div class="mb-3">
<small class="text-muted">Utente</small>
<p class="mb-0"><strong>{{ current_user.username }}</strong></p>
</div>
<div class="mb-3">
<small class="text-muted">Email</small>
<p class="mb-0">{{ current_user.email }}</p>
</div>
<div class="mb-3">
<small class="text-muted">Ultimo Accesso</small>
<p class="mb-0">
{% if current_user.last_login %}
{{ current_user.last_login.strftime('%d/%m/%Y %H:%M') }}
{% else %}
Primo accesso
{% endif %}
</p>
</div>
<hr>
<div class="d-grid">
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger">
<i class="fas fa-sign-out-alt me-2"></i>Logout
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Profilo{% endblock %}
{% block page_title %}Gestione Profilo{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form method="POST" action="{{ url_for('admin.profile_edit') }}" enctype="multipart/form-data">
<div class="mb-3">
<label for="title" class="form-label">Titolo</label>
<input type="text" class="form-control" id="title" name="title"
value="{{ profile.title if profile else '' }}" required>
</div>
<div class="mb-3">
<label for="lead_text" class="form-label">Testo Principale</label>
<textarea class="form-control" id="lead_text" name="lead_text" rows="3" required>{{ profile.lead_text if profile else '' }}</textarea>
</div>
<div class="mb-3">
<label for="description_1" class="form-label">Descrizione 1</label>
<textarea class="form-control" id="description_1" name="description_1" rows="3">{{ profile.description_1 if profile else '' }}</textarea>
</div>
<div class="mb-3">
<label for="description_2" class="form-label">Descrizione 2</label>
<textarea class="form-control" id="description_2" name="description_2" rows="3">{{ profile.description_2 if profile else '' }}</textarea>
</div>
<div class="mb-3">
<label for="years_experience" class="form-label">Anni di Esperienza</label>
<input type="number" class="form-control" id="years_experience" name="years_experience"
value="{{ profile.years_experience if profile else 0 }}">
</div>
<div class="mb-3">
<label for="cv_url" class="form-label">URL CV (opzionale)</label>
<input type="url" class="form-control" id="cv_url" name="cv_url"
value="{{ profile.cv_url if profile else '' }}">
</div>
<div class="mb-3">
<label class="form-label">Immagine di Profilo</label>
<div class="row">
<div class="col-md-6">
<label for="profile_image_file" class="form-label text-muted small">Upload Immagine</label>
<input type="file" class="form-control" id="profile_image_file" name="profile_image_file" accept="image/*">
<small class="text-muted">Formati supportati: png, jpg, jpeg, gif, webp (max 16 MB)</small>
</div>
<div class="col-md-6">
<label for="profile_image" class="form-label text-muted small">Oppure inserisci URL manualmente</label>
<input type="text" class="form-control" id="profile_image" name="profile_image"
value="{{ profile.profile_image if profile else '' }}" placeholder="img/personal.webp">
<small class="text-muted">Percorso relativo alla cartella static/</small>
</div>
</div>
{% if profile and profile.profile_image %}
<div class="mt-2">
<small class="text-muted">Immagine attuale:</small><br>
<img src="{{ url_for('static', filename=profile.profile_image) }}" alt="Profile" class="img-thumbnail rounded-circle" style="max-width: 200px; max-height: 200px;">
</div>
{% endif %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-gradient">
<i class="fas fa-save me-2"></i>Salva Modifiche
</button>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Annulla
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,101 @@
{% extends "admin/base.html" %}
{% block title %}{{ 'Modifica' if project else 'Nuovo' }} Progetto{% endblock %}
{% block page_title %}{{ 'Modifica' if project else 'Nuovo' }} Progetto{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="title" class="form-label">Titolo *</label>
<input type="text" class="form-control" id="title" name="title"
value="{{ project.title if project else '' }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Descrizione *</label>
<textarea class="form-control" id="description" name="description" rows="4" required>{{ project.description if project else '' }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Immagine del Progetto</label>
<div class="row">
<div class="col-md-6">
<label for="image_file" class="form-label text-muted small">Upload Immagine</label>
<input type="file" class="form-control" id="image_file" name="image_file" accept="image/*">
<small class="text-muted">Formati supportati: png, jpg, jpeg, gif, webp (max 16 MB)</small>
</div>
<div class="col-md-6">
<label for="image_url" class="form-label text-muted small">Oppure inserisci URL manualmente</label>
<input type="text" class="form-control" id="image_url" name="image_url"
value="{{ project.image_url if project else '' }}" placeholder="img/project.webp">
<small class="text-muted">Percorso relativo alla cartella static/</small>
</div>
</div>
{% if project and project.image_url %}
<div class="mt-2">
<small class="text-muted">Immagine attuale:</small><br>
<img src="{{ url_for('static', filename=project.image_url) }}" alt="{{ project.title }}" style="max-width: 200px; max-height: 150px;" class="img-thumbnail">
</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="github_url" class="form-label">URL GitHub</label>
<input type="url" class="form-control" id="github_url" name="github_url"
value="{{ project.github_url if project else '' }}">
</div>
<div class="col-md-6 mb-3">
<label for="demo_url" class="form-label">URL Demo</label>
<input type="url" class="form-control" id="demo_url" name="demo_url"
value="{{ project.demo_url if project else '' }}">
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="display_order" class="form-label">Ordine Visualizzazione</label>
<input type="number" class="form-control" id="display_order" name="display_order"
value="{{ project.display_order if project else 0 }}">
</div>
<div class="col-md-4 mb-3">
<label for="animation_delay" class="form-label">Delay Animazione</label>
<input type="text" class="form-control" id="animation_delay" name="animation_delay"
value="{{ project.animation_delay if project else '0s' }}" placeholder="0.2s">
</div>
<div class="col-md-4 mb-3">
<div class="form-check mt-4">
<input type="checkbox" class="form-check-input" id="is_published" name="is_published"
{% if not project or project.is_published %}checked{% endif %}>
<label class="form-check-label" for="is_published">
Pubblica il progetto
</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags (separati da virgola)</label>
<input type="text" class="form-control" id="tags" name="tags"
value="{% if project %}{% for tag in project.tags|sort(attribute='display_order') %}{{ tag.name }}:{{ tag.color_class }}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}"
placeholder="Python:bg-primary, Flask:bg-info, Docker:bg-success">
<small class="text-muted">Formato: Nome:colore, Nome:colore (es: Python:bg-primary, Flask:bg-info)</small>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-gradient">
<i class="fas fa-save me-2"></i>Salva
</button>
<a href="{{ url_for('admin.projects_manage') }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Annulla
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Progetti{% endblock %}
{% block page_title %}Gestione Progetti{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('admin.project_add') }}" class="btn btn-gradient">
<i class="fas fa-plus me-2"></i>Nuovo Progetto
</a>
</div>
<div class="row g-4">
{% for project in projects %}
<div class="col-md-4">
<div class="card h-100">
{% if project.image_url %}
<img src="{{ url_for('static', filename=project.image_url) }}" class="card-img-top" alt="{{ project.title }}">
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ project.title }}</h5>
<p class="card-text text-muted small">{{ project.description[:100] }}...</p>
<div class="mb-2">
{% for tag in project.tags %}
<span class="badge {{ tag.color_class }} me-1">{{ tag.name }}</span>
{% endfor %}
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{% if project.is_published %}
<i class="fas fa-eye text-success"></i> Pubblicato
{% else %}
<i class="fas fa-eye-slash text-danger"></i> Bozza
{% endif %}
</small>
<div>
<a href="{{ url_for('admin.project_edit', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit"></i>
</a>
<form method="POST" action="{{ url_for('admin.project_delete', project_id=project.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Sicuro di voler eliminare?')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

132
templates/admin/skills.html Normal file
View File

@@ -0,0 +1,132 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Competenze{% endblock %}
{% block page_title %}Gestione Competenze{% endblock %}
{% block content %}
<div class="card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">Aggiungi Nuova Competenza</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.skill_add') }}" class="row g-3">
<div class="col-md-3">
<input type="text" class="form-control" name="name" placeholder="Nome (es. Python)" required>
</div>
<div class="col-md-3">
<input type="text" class="form-control" name="icon_class" placeholder="Icona (es. fab fa-python)" required>
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="category" placeholder="Categoria">
</div>
<div class="col-md-1">
<input type="number" class="form-control" name="display_order" placeholder="Ordine" value="0">
</div>
<div class="col-md-1">
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" name="is_active" id="is_active_new" checked>
<label class="form-check-label" for="is_active_new">
Attiva
</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-gradient w-100">
<i class="fas fa-plus me-2"></i>Aggiungi
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">Lista Competenze</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Icona</th>
<th>Nome</th>
<th>Categoria</th>
<th>Ordine</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
{% for skill in skills %}
<tr>
<td><i class="{{ skill.icon_class }} fa-2x text-primary"></i></td>
<td>{{ skill.name }}</td>
<td><span class="badge bg-secondary">{{ skill.category or '-' }}</span></td>
<td>{{ skill.display_order }}</td>
<td>
{% if skill.is_active %}
<span class="badge bg-success">Attiva</span>
{% else %}
<span class="badge bg-danger">Disattiva</span>
{% endif %}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-primary me-1" data-bs-toggle="modal" data-bs-target="#editModal{{ skill.id }}">
<i class="fas fa-edit"></i>
</button>
<form method="POST" action="{{ url_for('admin.skill_delete', skill_id=skill.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Sicuro di voler eliminare?')">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<!-- Edit Modal -->
<div class="modal fade" id="editModal{{ skill.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modifica Competenza</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('admin.skill_edit', skill_id=skill.id) }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nome</label>
<input type="text" class="form-control" name="name" value="{{ skill.name }}" required>
</div>
<div class="mb-3">
<label class="form-label">Icona (Font Awesome class)</label>
<input type="text" class="form-control" name="icon_class" value="{{ skill.icon_class }}" required>
</div>
<div class="mb-3">
<label class="form-label">Categoria</label>
<input type="text" class="form-control" name="category" value="{{ skill.category or '' }}">
</div>
<div class="mb-3">
<label class="form-label">Ordine di Visualizzazione</label>
<input type="number" class="form-control" name="display_order" value="{{ skill.display_order }}">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="is_active" id="is_active_{{ skill.id }}" {% if skill.is_active %}checked{% endif %}>
<label class="form-check-label" for="is_active_{{ skill.id }}">
Competenza Attiva
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-gradient">Salva Modifiche</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,133 @@
{% extends "admin/base.html" %}
{% block title %}Gestione Link Social{% endblock %}
{% block page_title %}Gestione Link Social{% endblock %}
{% block content %}
<div class="card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">Aggiungi Nuovo Link</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.social_link_add') }}" class="row g-3">
<div class="col-md-2">
<input type="text" class="form-control" name="platform_name" placeholder="Nome (es. LinkedIn)" required>
</div>
<div class="col-md-3">
<input type="url" class="form-control" name="url" placeholder="URL completo" required>
</div>
<div class="col-md-2">
<input type="text" class="form-control" name="icon_class" placeholder="Icona" required>
</div>
<div class="col-md-1">
<input type="number" class="form-control" name="display_order" placeholder="Ordine" value="0">
</div>
<div class="col-md-2">
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" name="is_active" id="is_active_new" checked>
<label class="form-check-label" for="is_active_new">
Attivo
</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-gradient w-100">
<i class="fas fa-plus me-2"></i>Aggiungi
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Icona</th>
<th>Piattaforma</th>
<th>URL</th>
<th>Ordine</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
{% for link in social_links %}
<tr>
<td><i class="{{ link.icon_class }} fa-2x text-primary"></i></td>
<td>{{ link.platform_name }}</td>
<td><a href="{{ link.url }}" target="_blank" class="text-truncate d-inline-block" style="max-width: 200px;">{{ link.url }}</a></td>
<td>{{ link.display_order }}</td>
<td>
{% if link.is_active %}
<span class="badge bg-success">Attivo</span>
{% else %}
<span class="badge bg-danger">Disattivo</span>
{% endif %}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-primary me-1" data-bs-toggle="modal" data-bs-target="#editModal{{ link.id }}">
<i class="fas fa-edit"></i>
</button>
<form method="POST" action="{{ url_for('admin.social_link_delete', link_id=link.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Sicuro di voler eliminare?')">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
<!-- Edit Modal -->
<div class="modal fade" id="editModal{{ link.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modifica Link Social</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('admin.social_link_edit', link_id=link.id) }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nome Piattaforma</label>
<input type="text" class="form-control" name="platform_name" value="{{ link.platform_name }}" required>
</div>
<div class="mb-3">
<label class="form-label">URL</label>
<input type="url" class="form-control" name="url" value="{{ link.url }}" required>
</div>
<div class="mb-3">
<label class="form-label">Icona (Font Awesome class)</label>
<input type="text" class="form-control" name="icon_class" value="{{ link.icon_class }}" required>
</div>
<div class="mb-3">
<label class="form-label">Ordine di Visualizzazione</label>
<input type="number" class="form-control" name="display_order" value="{{ link.display_order }}">
</div>
<div class="mb-3">
<label class="form-label">Delay Animazione (es. 0.1s)</label>
<input type="text" class="form-control" name="animation_delay" value="{{ link.animation_delay }}">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" name="is_active" id="is_active_{{ link.id }}" {% if link.is_active %}checked{% endif %}>
<label class="form-check-label" for="is_active_{{ link.id }}">
Link Attivo
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-gradient">Salva Modifiche</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "admin/base.html" %}
{% block title %}Cambia Password{% endblock %}
{% block page_title %}Cambia Password{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<div class="text-center mb-4">
<i class="fas fa-key fa-3x text-primary mb-3"></i>
<p class="text-muted">Inserisci la tua password attuale e la nuova password</p>
</div>
<form method="POST" action="{{ url_for('auth.change_password') }}">
<div class="mb-3">
<label for="current_password" class="form-label">
<i class="fas fa-lock me-2"></i>Password Attuale *
</label>
<input type="password" class="form-control" id="current_password" name="current_password" required autofocus>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">
<i class="fas fa-key me-2"></i>Nuova Password *
</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6">
<small class="text-muted">Minimo 6 caratteri</small>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">
<i class="fas fa-check-circle me-2"></i>Conferma Nuova Password *
</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="6">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-gradient">
<i class="fas fa-save me-2"></i>Cambia Password
</button>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Annulla
</a>
</div>
</form>
</div>
</div>
<div class="alert alert-info mt-3">
<i class="fas fa-info-circle me-2"></i>
<strong>Nota:</strong> Dopo aver cambiato la password, verrai reindirizzato alla dashboard.
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,38 +1,84 @@
{% extends "base.html" %}
{% block title %}Login - Hersel.it{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<h2 class="card-title">Accedi</h2>
<p class="text-muted">Benvenuto su Hersel.it</p>
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Portfolio Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
max-width: 450px;
width: 100%;
}
.login-card {
background: white;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
padding: 2.5rem;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header i {
font-size: 3rem;
color: #667eea;
margin-bottom: 1rem;
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
padding: 0.75rem;
font-weight: 600;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<i class="fas fa-lock"></i>
<h2>Admin Login</h2>
<p class="text-muted">Accedi per gestire il tuo portfolio</p>
</div>
<form method="POST">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username o Email</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<label for="username" class="form-label">
<i class="fas fa-user me-2"></i>Username
</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-lock"></i>
</span>
<label for="password" class="form-label">
<i class="fas fa-key me-2"></i>Password
</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
@@ -41,22 +87,26 @@
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right"></i> Accedi
<button type="submit" class="btn btn-primary btn-login w-100">
<i class="fas fa-sign-in-alt me-2"></i>Accedi
</button>
</div>
</form>
<div class="text-center mt-4">
<p class="mb-0">
Non hai un account?
<a href="{{ url_for('auth.register') }}" class="text-decoration-none">Registrati qui</a>
</p>
<a href="{{ url_for('route_home.home') }}" class="text-decoration-none">
<i class="fas fa-arrow-left me-2"></i>Torna al Portfolio
</a>
</div>
</div>
<div class="text-center mt-3 text-white">
<small>
<i class="fas fa-info-circle me-1"></i>
Credenziali di default: admin / admin123
</small>
</div>
</div>
</div>
</div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -1,100 +0,0 @@
{% extends "base.html" %}
{% block title %}Il Mio Profilo - Hersel.it{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row">
<div class="col-lg-4">
<div class="card">
<div class="card-body text-center">
<div class="bg-primary bg-opacity-10 rounded-circle p-4 d-inline-block mb-3">
<i class="bi bi-person-circle display-3 text-primary"></i>
</div>
<h4>{{ user.full_name }}</h4>
<p class="text-muted">@{{ user.username }}</p>
{% if user.is_admin %}
<span class="badge bg-danger">Amministratore</span>
{% else %}
<span class="badge bg-primary">Utente</span>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Informazioni Profilo</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-3">
<strong>Username:</strong>
</div>
<div class="col-sm-9">
{{ user.username }}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3">
<strong>Email:</strong>
</div>
<div class="col-sm-9">
{{ user.email }}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3">
<strong>Nome Completo:</strong>
</div>
<div class="col-sm-9">
{{ user.full_name }}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3">
<strong>Ruolo:</strong>
</div>
<div class="col-sm-9">
{% if user.is_admin %}
<span class="badge bg-danger">Amministratore</span>
{% else %}
<span class="badge bg-primary">Utente</span>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3">
<strong>Registrato il:</strong>
</div>
<div class="col-sm-9">
{{ user.created_at.strftime('%d/%m/%Y alle %H:%M') if user.created_at else 'N/D' }}
</div>
</div>
<hr>
<div class="d-flex gap-2">
{% if user.is_admin %}
<a href="{{ url_for('dashboard.index') }}" class="btn btn-primary">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
{% endif %}
<button class="btn btn-outline-primary" onclick="alert('Funzione in sviluppo')">
<i class="bi bi-pencil"></i> Modifica Profilo
</button>
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-danger">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,99 +0,0 @@
{% extends "base.html" %}
{% block title %}Registrazione - Hersel.it{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<h2 class="card-title">Registrati</h2>
<p class="text-muted">Crea il tuo account su Hersel.it</p>
</div>
<form method="POST">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="first_name" class="form-label">Nome</label>
<input type="text" class="form-control" id="first_name" name="first_name">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="last_name" class="form-label">Cognome</label>
<input type="text" class="form-control" id="last_name" name="last_name">
</div>
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="form-text">Minimo 3 caratteri, solo lettere, numeri e underscore</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-envelope"></i>
</span>
<input type="email" class="form-control" id="email" name="email" required>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-lock"></i>
</span>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="form-text">Minimo 8 caratteri con maiuscola, minuscola e numero</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Conferma Password</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-lock-fill"></i>
</span>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="terms" name="terms" required>
<label class="form-check-label" for="terms">
Accetto i <a href="#" class="text-decoration-none">Termini e Condizioni</a>
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-person-plus"></i> Registrati
</button>
</div>
</form>
<div class="text-center mt-4">
<p class="mb-0">
Hai già un account?
<a href="{{ url_for('auth.login') }}" class="text-decoration-none">Accedi qui</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,121 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Hersel.it - Portfolio{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ url_for('home.index') }}">
<strong>Hersel.it</strong>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('home.index') }}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#progetti">Progetti</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#contatti">Contatti</a>
</li>
</ul>
<ul class="navbar-nav">
{% if session.get('user_id') %}
{% if session.get('is_admin') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
{% endif %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ session.get('username') }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">Profilo</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">Registrati</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container mt-3">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content -->
<main>
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-dark text-light py-4 mt-5">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>Hersel Giannella</h5>
<p>Developer & Portfolio</p>
</div>
<div class="col-md-6 text-end">
<a href="https://github.com/BluLupo" class="text-light me-3">
<i class="bi bi-github"></i> GitHub
</a>
<a href="mailto:info@hersel.it" class="text-light">
<i class="bi bi-envelope"></i> Email
</a>
</div>
</div>
<hr>
<div class="row">
<div class="col text-center">
<small>&copy; 2024 Hersel.it - Tutti i diritti riservati</small>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -3,20 +3,44 @@
<h2 class="text-center section-title animate__animated animate__fadeIn">Chi Sono</h2>
<div class="row align-items-center">
<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>
<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>Mi piace risolvere problemi complessi e creare soluzioni che siano robuste, scalabili e facili da mantenere.</p>
<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>
</div>
{% endif %}
</div>
<div class="col-lg-6 animate__animated animate__fadeInRight">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<h4 class="mb-4 text-primary">Tecnologie che utilizzo</h4>
<div class="d-flex flex-wrap justify-content-center">
{% if skills %}
{% for skill in skills %}
<div class="text-center m-3">
<i class="{{ skill.icon_class }} tech-icon"></i>
<p>{{ skill.name }}</p>
</div>
{% endfor %}
{% else %}
<div class="text-center m-3">
<i class="fab fa-linux tech-icon"></i>
<p>Linux</p>
@@ -49,6 +73,7 @@
<i class="fas fa-network-wired tech-icon"></i>
<p>Networking</p>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -6,6 +6,16 @@
<div class="card border-0 shadow-sm">
<div class="card-body p-5">
<div class="d-flex flex-wrap justify-content-center mb-4">
{% if social_links %}
{% for link in social_links %}
<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 %}>
<div class="text-center">
<i class="{{ link.icon_class }} social-icon"></i>
<p class="mt-2 fs-5">{{ link.platform_name }}</p>
</div>
</a>
{% endfor %}
{% else %}
<a href="https://linkedin.com/in/hersel" class="m-3 text-decoration-none animate__animated animate__bounceIn" style="animation-delay: 0.1s">
<div class="text-center">
<i class="fab fa-linkedin social-icon"></i>
@@ -42,6 +52,7 @@
<p class="mt-2 fs-5">Email</p>
</div>
</a>
{% endif %}
</div>
<p class="lead mt-4">Scopri di più sul mio lavoro e segui i miei progetti attraverso questi canali</p>
</div>

View File

@@ -2,6 +2,32 @@
<div class="container">
<h2 class="text-center section-title animate__animated animate__fadeIn">I Miei Progetti</h2>
<div class="row">
{% if projects %}
{% for project in projects %}
<!-- {{ project.title }} -->
<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 project-card shadow-sm">
{% if project.image_url %}
<img src="{{ url_for('static', filename=project.image_url) }}" class="card-img-top" alt="{{ project.title }}">
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ project.title }}</h5>
<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">
@@ -64,6 +90,7 @@
</div>
</div>
</div>
{% endif %}
<!--

View File

@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Dashboard - Hersel.it{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
.sidebar {
min-height: 100vh;
background-color: #f8f9fa;
}
.sidebar .nav-link {
color: #333;
}
.sidebar .nav-link:hover {
background-color: #e9ecef;
}
.sidebar .nav-link.active {
background-color: #0d6efd;
color: white;
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-3 col-lg-2 d-md-block sidebar collapse">
<div class="position-sticky pt-3">
<div class="text-center mb-4">
<a href="{{ url_for('home.index') }}" class="text-decoration-none">
<h4 class="text-primary">Hersel.it</h4>
</a>
<small class="text-muted">Dashboard Admin</small>
</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'dashboard.index' }}" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-speedometer2"></i> Overview
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if 'project' in request.endpoint }}" href="{{ url_for('dashboard.projects') }}">
<i class="bi bi-folder"></i> Progetti
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'dashboard.users' }}" href="{{ url_for('dashboard.users') }}">
<i class="bi bi-people"></i> Utenti
</a>
</li>
</ul>
<hr>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('home.index') }}">
<i class="bi bi-house"></i> Vai al Sito
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content -->
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{% block page_title %}Dashboard{% endblock %}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
{% block page_actions %}{% endblock %}
</div>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,159 +0,0 @@
{% extends "dashboard/base.html" %}
{% block page_title %}Dashboard{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-12">
<div class="mb-4">
<h3>Benvenuto, {{ user.full_name }}!</h3>
<p class="text-muted">Ecco una panoramica del tuo portfolio</p>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4>{{ stats.total_projects }}</h4>
<p class="mb-0">Progetti Totali</p>
</div>
<div class="align-self-center">
<i class="bi bi-folder fs-1"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4>{{ stats.published_projects }}</h4>
<p class="mb-0">Pubblicati</p>
</div>
<div class="align-self-center">
<i class="bi bi-check-circle fs-1"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4>{{ stats.featured_projects }}</h4>
<p class="mb-0">In Evidenza</p>
</div>
<div class="align-self-center">
<i class="bi bi-star fs-1"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h4>{{ stats.total_users }}</h4>
<p class="mb-0">Utenti</p>
</div>
<div class="align-self-center">
<i class="bi bi-people fs-1"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Projects -->
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Progetti Recenti</h5>
</div>
<div class="card-body">
{% if recent_projects %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Titolo</th>
<th>Stato</th>
<th>Creato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
{% for project in recent_projects %}
<tr>
<td>
<strong>{{ project.title }}</strong>
{% if project.is_featured %}
<span class="badge bg-warning ms-1">Featured</span>
{% endif %}
</td>
<td>
{% if project.is_published %}
<span class="badge bg-success">Pubblicato</span>
{% else %}
<span class="badge bg-secondary">Bozza</span>
{% endif %}
</td>
<td>{{ project.created_at.strftime('%d/%m/%Y') if project.created_at else 'N/D' }}</td>
<td>
<a href="{{ url_for('dashboard.edit_project', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Nessun progetto ancora creato.</p>
<a href="{{ url_for('dashboard.new_project') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Crea il primo progetto
</a>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Azioni Rapide</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('dashboard.new_project') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Nuovo Progetto
</a>
<a href="{{ url_for('dashboard.projects') }}" class="btn btn-outline-primary">
<i class="bi bi-folder"></i> Gestisci Progetti
</a>
<a href="{{ url_for('home.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-eye"></i> Visualizza Sito
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,149 +0,0 @@
{% extends "dashboard/base.html" %}
{% block page_title %}
{% if action == 'create' %}Nuovo Progetto{% else %}Modifica Progetto{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="title" class="form-label">Titolo *</label>
<input type="text" class="form-control" id="title" name="title"
value="{{ project.title if project else '' }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Descrizione Breve</label>
<textarea class="form-control" id="description" name="description" rows="3"
maxlength="1000">{{ project.description if project else '' }}</textarea>
<div class="form-text">Massimo 1000 caratteri</div>
</div>
<div class="mb-3">
<label for="content" class="form-label">Contenuto Completo</label>
<textarea class="form-control" id="content" name="content" rows="10">{{ project.content if project else '' }}</textarea>
<div class="form-text">Supporta HTML di base</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="github_url" class="form-label">URL GitHub</label>
<input type="url" class="form-control" id="github_url" name="github_url"
value="{{ project.github_url if project else '' }}"
placeholder="https://github.com/username/repo">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="demo_url" class="form-label">URL Demo/Live</label>
<input type="url" class="form-control" id="demo_url" name="demo_url"
value="{{ project.demo_url if project else '' }}"
placeholder="https://example.com">
</div>
</div>
</div>
<div class="mb-3">
<label for="image_url" class="form-label">URL Immagine</label>
<input type="url" class="form-control" id="image_url" name="image_url"
value="{{ project.image_url if project else '' }}"
placeholder="https://example.com/image.jpg">
</div>
<div class="mb-3">
<label for="technologies" class="form-label">Tecnologie</label>
<div class="row">
{% set common_techs = ['Python', 'JavaScript', 'HTML', 'CSS', 'React', 'Vue.js', 'Node.js', 'MySQL', 'PostgreSQL', 'MongoDB', 'Docker', 'Git', 'Bootstrap', 'jQuery'] %}
{% for tech in common_techs %}
<div class="col-md-3 col-sm-4 col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="technologies"
value="{{ tech }}" id="tech_{{ loop.index }}"
{% if project and tech in project.technologies %}checked{% endif %}>
<label class="form-check-label" for="tech_{{ loop.index }}">
{{ tech }}
</label>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="category_id" class="form-label">Categoria</label>
<select class="form-select" id="category_id" name="category_id">
<option value="">Seleziona categoria</option>
{% for category in categories %}
<option value="{{ category.id }}"
{% if project and project.category_id == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_featured" name="is_featured" value="1"
{% if project and project.is_featured %}checked{% endif %}>
<label class="form-check-label" for="is_featured">
Progetto in evidenza
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_published" name="is_published" value="1"
{% if not project or project.is_published %}checked{% endif %}>
<label class="form-check-label" for="is_published">
Pubblica progetto
</label>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('dashboard.projects') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Annulla
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check"></i>
{% if action == 'create' %}Crea Progetto{% else %}Aggiorna Progetto{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-body">
<h6 class="card-title">Suggerimenti</h6>
<ul class="list-unstyled small">
<li><i class="bi bi-lightbulb text-warning"></i> Un titolo chiaro e descrittivo attira più attenzione</li>
<li><i class="bi bi-lightbulb text-warning"></i> La descrizione breve appare nelle anteprime</li>
<li><i class="bi bi-lightbulb text-warning"></i> Usa il contenuto completo per dettagli tecnici</li>
<li><i class="bi bi-lightbulb text-warning"></i> I progetti "featured" appaiono in homepage</li>
</ul>
</div>
</div>
{% if project and project.image_url %}
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">Anteprima Immagine</h6>
<img src="{{ project.image_url }}" class="img-fluid rounded" alt="Project preview">
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,117 +0,0 @@
{% extends "dashboard/base.html" %}
{% block page_title %}Gestione Progetti{% endblock %}
{% block page_actions %}
<a href="{{ url_for('dashboard.new_project') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Nuovo Progetto
</a>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
{% if projects %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Titolo</th>
<th>Stato</th>
<th>Featured</th>
<th>Creato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<strong>{{ project.title }}</strong>
{% if project.description %}
<br><small class="text-muted">{{ project.description[:100] }}...</small>
{% endif %}
</td>
<td>
{% if project.is_published %}
<span class="badge bg-success">Pubblicato</span>
{% else %}
<span class="badge bg-secondary">Bozza</span>
{% endif %}
</td>
<td>
{% if project.is_featured %}
<span class="badge bg-warning">Featured</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ project.created_at.strftime('%d/%m/%Y') if project.created_at else 'N/D' }}</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('dashboard.edit_project', project_id=project.id) }}"
class="btn btn-sm btn-outline-primary" title="Modifica">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ url_for('dashboard.delete_project', project_id=project.id) }}"
style="display: inline;"
onsubmit="return confirm('Sei sicuro di voler eliminare questo progetto?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Elimina">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<nav aria-label="Pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="?page={{ pagination.prev_page }}">Precedente</a>
</li>
{% endif %}
{% for page_num in range(1, pagination.total_pages + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% elif page_num <= 3 or page_num > pagination.total_pages - 3 or (page_num >= pagination.page - 1 and page_num <= pagination.page + 1) %}
<li class="page-item">
<a class="page-link" href="?page={{ page_num }}">{{ page_num }}</a>
</li>
{% elif page_num == 4 or page_num == pagination.total_pages - 3 %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ pagination.next_page }}">Successivo</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-folder2-open display-1 text-muted"></i>
<h4 class="mt-3">Nessun progetto ancora</h4>
<p class="text-muted">Inizia creando il tuo primo progetto</p>
<a href="{{ url_for('dashboard.new_project') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Crea Primo Progetto
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,28 +0,0 @@
{% extends "base.html" %}
{% block title %}Pagina Non Trovata - Hersel.it{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 text-center py-5">
<div class="py-5">
<i class="bi bi-exclamation-triangle-fill display-1 text-warning"></i>
<h1 class="display-4 fw-bold mt-4">404</h1>
<h2 class="mb-4">Pagina Non Trovata</h2>
<p class="lead text-muted mb-4">
Spiacente, la pagina che stai cercando non esiste o è stata spostata.
</p>
<div class="d-flex gap-3 justify-content-center">
<a href="{{ url_for('home.index') }}" class="btn btn-primary">
<i class="bi bi-house"></i> Torna alla Home
</a>
<a href="{{ url_for('home.projects') }}" class="btn btn-outline-primary">
<i class="bi bi-folder"></i> Vedi i Progetti
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block title %}Errore del Server - Hersel.it{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 text-center py-5">
<div class="py-5">
<i class="bi bi-exclamation-octagon-fill display-1 text-danger"></i>
<h1 class="display-4 fw-bold mt-4">500</h1>
<h2 class="mb-4">Errore del Server</h2>
<p class="lead text-muted mb-4">
Si è verificato un errore interno del server.
Il problema è stato segnalato e verrà risolto al più presto.
</p>
<div class="d-flex gap-3 justify-content-center">
<a href="{{ url_for('home.index') }}" class="btn btn-primary">
<i class="bi bi-house"></i> Torna alla Home
</a>
<a href="javascript:history.back()" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Torna Indietro
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,213 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ site_name }} - {{ site_description }}{% endblock %}
{% block content %}
<!-- Hero Section -->
<section class="bg-primary text-white py-5">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-8">
<h1 class="display-4 fw-bold">Ciao, sono Hersel Giannella</h1>
<p class="lead mb-4">{{ site_description }}</p>
<div class="d-flex gap-3">
<a href="#progetti" class="btn btn-light btn-lg">
<i class="bi bi-folder"></i> I Miei Progetti
</a>
<a href="{{ url_for('home.contact') }}" class="btn btn-outline-light btn-lg">
<i class="bi bi-envelope"></i> Contattami
</a>
</div>
</div>
<div class="col-lg-4 text-center">
<div class="bg-white bg-opacity-10 rounded-circle p-4 d-inline-block">
<i class="bi bi-code-slash display-1"></i>
</div>
</div>
</div>
</div>
</section>
<!-- Featured Projects Section -->
<section id="progetti" class="py-5">
<div class="container">
<div class="text-center mb-5">
<h2 class="fw-bold">Progetti in Evidenza</h2>
<p class="text-muted">Alcuni dei miei lavori più interessanti</p>
</div>
{% if featured_projects %}
<div class="row g-4">
{% for project in featured_projects %}
<div class="col-lg-4 col-md-6">
<div class="card h-100 shadow-sm hover-card">
{% if project.image_url %}
<img src="{{ project.image_url }}" class="card-img-top" alt="{{ project.title }}" style="height: 200px; object-fit: cover;">
{% else %}
<div class="card-img-top bg-light d-flex align-items-center justify-content-center" style="height: 200px;">
<i class="bi bi-folder display-4 text-muted"></i>
</div>
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ project.title }}</h5>
<p class="card-text text-muted">{{ project.description[:100] }}{% if project.description|length > 100 %}...{% endif %}</p>
{% if project.technologies %}
<div class="mb-3">
{% for tech in project.technologies[:3] %}
<span class="badge bg-secondary me-1">{{ tech }}</span>
{% endfor %}
{% if project.technologies|length > 3 %}
<span class="badge bg-light text-dark">+{{ project.technologies|length - 3 }}</span>
{% endif %}
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group">
<a href="{{ url_for('home.project_detail', slug=project.slug) }}" class="btn btn-sm btn-primary">
<i class="bi bi-eye"></i> Dettagli
</a>
{% if project.github_url %}
<a href="{{ project.github_url }}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-github"></i> GitHub
</a>
{% endif %}
</div>
{% if project.demo_url %}
<a href="{{ project.demo_url }}" target="_blank" class="btn btn-sm btn-success">
<i class="bi bi-box-arrow-up-right"></i> Demo
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="text-center mt-5">
<a href="{{ url_for('home.projects') }}" class="btn btn-outline-primary btn-lg">
<i class="bi bi-folder"></i> Vedi Tutti i Progetti
</a>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-folder2-open display-1 text-muted"></i>
<h4 class="mt-3">Progetti in arrivo</h4>
<p class="text-muted">Sto lavorando su alcuni progetti interessanti!</p>
</div>
{% endif %}
</div>
</section>
<!-- Skills Section -->
<section class="bg-light py-5">
<div class="container">
<div class="text-center mb-5">
<h2 class="fw-bold">Le Mie Competenze</h2>
<p class="text-muted">Tecnologie e strumenti che utilizzo</p>
</div>
<div class="row g-4">
<div class="col-lg-3 col-md-6">
<div class="text-center">
<div class="bg-primary bg-opacity-10 rounded-circle p-3 d-inline-block mb-3">
<i class="bi bi-code-slash fs-1 text-primary"></i>
</div>
<h5>Backend Development</h5>
<p class="text-muted">Python, Quart, Flask, FastAPI</p>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="text-center">
<div class="bg-success bg-opacity-10 rounded-circle p-3 d-inline-block mb-3">
<i class="bi bi-palette fs-1 text-success"></i>
</div>
<h5>Frontend Development</h5>
<p class="text-muted">HTML, CSS, JavaScript, Bootstrap</p>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="text-center">
<div class="bg-warning bg-opacity-10 rounded-circle p-3 d-inline-block mb-3">
<i class="bi bi-database fs-1 text-warning"></i>
</div>
<h5>Database</h5>
<p class="text-muted">MySQL, PostgreSQL, MongoDB</p>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="text-center">
<div class="bg-info bg-opacity-10 rounded-circle p-3 d-inline-block mb-3">
<i class="bi bi-tools fs-1 text-info"></i>
</div>
<h5>DevOps & Tools</h5>
<p class="text-muted">Docker, Git, Linux, CI/CD</p>
</div>
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section id="contatti" class="py-5">
<div class="container">
<div class="text-center mb-5">
<h2 class="fw-bold">Parliamo del Tuo Progetto</h2>
<p class="text-muted">Sono sempre interessato a nuove opportunità e collaborazioni</p>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="row g-4">
<div class="col-md-4 text-center">
<div class="bg-primary bg-opacity-10 rounded-circle p-3 d-inline-block mb-3">
<i class="bi bi-envelope fs-2 text-primary"></i>
</div>
<h6>Email</h6>
<a href="mailto:info@hersel.it" class="text-decoration-none">info@hersel.it</a>
</div>
<div class="col-md-4 text-center">
<div class="bg-dark rounded-circle p-3 d-inline-block mb-3">
<i class="bi bi-github fs-2 text-white"></i>
</div>
<h6>GitHub</h6>
<a href="https://github.com/BluLupo" target="_blank" class="text-decoration-none">BluLupo</a>
</div>
<div class="col-md-4 text-center">
<div class="bg-info bg-opacity-10 rounded-circle p-3 d-inline-block mb-3">
<i class="bi bi-linkedin fs-2 text-info"></i>
</div>
<h6>LinkedIn</h6>
<a href="#" target="_blank" class="text-decoration-none">Hersel Giannella</a>
</div>
</div>
<div class="text-center mt-5">
<a href="{{ url_for('home.contact') }}" class="btn btn-primary btn-lg">
<i class="bi bi-chat-dots"></i> Inizia una Conversazione
</a>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block extra_head %}
<style>
.hover-card {
transition: transform 0.2s;
}
.hover-card:hover {
transform: translateY(-5px);
}
</style>
{% endblock %}

View File

@@ -1,75 +0,0 @@
{% extends "base.html" %}
{% block title %}Progetti - Hersel.it{% endblock %}
{% block content %}
<div class="container py-5">
<div class="text-center mb-5">
<h1 class="fw-bold">I Miei Progetti</h1>
<p class="lead text-muted">Una raccolta dei lavori che ho realizzato</p>
</div>
{% if projects %}
<div class="row g-4">
{% for project in projects %}
<div class="col-lg-4 col-md-6">
<div class="card h-100 shadow-sm">
{% if project.image_url %}
<img src="{{ project.image_url }}" class="card-img-top" alt="{{ project.title }}" style="height: 200px; object-fit: cover;">
{% else %}
<div class="card-img-top bg-light d-flex align-items-center justify-content-center" style="height: 200px;">
<i class="bi bi-folder display-4 text-muted"></i>
</div>
{% endif %}
<div class="card-body d-flex flex-column">
<div class="mb-auto">
<h5 class="card-title">{{ project.title }}</h5>
<p class="card-text text-muted">{{ project.description[:150] }}{% if project.description|length > 150 %}...{% endif %}</p>
{% if project.technologies %}
<div class="mb-3">
{% for tech in project.technologies[:4] %}
<span class="badge bg-secondary me-1 mb-1">{{ tech }}</span>
{% endfor %}
{% if project.technologies|length > 4 %}
<span class="badge bg-light text-dark">+{{ project.technologies|length - 4 }}</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="btn-group">
<a href="{{ url_for('home.project_detail', slug=project.slug) }}" class="btn btn-sm btn-primary">
<i class="bi bi-eye"></i> Dettagli
</a>
{% if project.github_url %}
<a href="{{ project.github_url }}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-github"></i> Codice
</a>
{% endif %}
</div>
{% if project.demo_url %}
<a href="{{ project.demo_url }}" target="_blank" class="btn btn-sm btn-success">
<i class="bi bi-box-arrow-up-right"></i> Demo Live
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-folder2-open display-1 text-muted"></i>
<h4 class="mt-3">Nessun progetto disponibile</h4>
<p class="text-muted">Sto lavorando su alcuni progetti interessanti!</p>
<a href="{{ url_for('home.index') }}" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> Torna alla Home
</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -19,7 +19,7 @@
</div>
<div class="col-lg-6 d-flex justify-content-center animate__animated animate__fadeInRight">
<div class="text-center">
<img src="{{ url_for('static', filename='img/personal.webp') }}" alt="Profile" class="img-fluid rounded-circle shadow" style="max-width: 350px;">
<img src="{{ url_for('static', filename=profile.profile_image if profile and profile.profile_image else 'img/personal.webp') }}" alt="Profile" class="img-fluid rounded-circle shadow" style="max-width: 350px;">
</div>
</div>
</div>

View File

@@ -1,10 +0,0 @@
# Utilities Package
from .auth import login_required, admin_required, get_current_user
from .helpers import flash_message, generate_slug, sanitize_html
from .validators import validate_email, validate_password
__all__ = [
'login_required', 'admin_required', 'get_current_user',
'flash_message', 'generate_slug', 'sanitize_html',
'validate_email', 'validate_password'
]

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Authentication Utilities
from functools import wraps
from quart import session, request, redirect, url_for, jsonify
from typing import Optional
from models.user import User
def login_required(f):
"""Decorator to require login for a route"""
@wraps(f)
async def decorated_function(*args, **kwargs):
if 'user_id' not in session:
if request.is_json:
return jsonify({'error': 'Login required'}), 401
return redirect(url_for('auth.login'))
return await f(*args, **kwargs)
return decorated_function
def admin_required(f):
"""Decorator to require admin role for a route"""
@wraps(f)
async def decorated_function(*args, **kwargs):
if 'user_id' not in session:
if request.is_json:
return jsonify({'error': 'Login required'}), 401
return redirect(url_for('auth.login'))
user = await get_current_user()
if not user or not user.is_admin:
if request.is_json:
return jsonify({'error': 'Admin access required'}), 403
return redirect(url_for('home.index'))
return await f(*args, **kwargs)
return decorated_function
async def get_current_user() -> Optional[User]:
"""Get the currently logged-in user"""
if 'user_id' not in session:
return None
try:
user_id = session['user_id']
return await User.find_by_id(user_id)
except:
# Clear invalid session
session.pop('user_id', None)
return None
def login_user(user: User):
"""Log in a user (set session)"""
session['user_id'] = user.id
session['username'] = user.username
session['is_admin'] = user.is_admin
def logout_user():
"""Log out the current user (clear session)"""
session.pop('user_id', None)
session.pop('username', None)
session.pop('is_admin', None)

View File

@@ -1,74 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Helper Utilities
import re
import html
from quart import session
from typing import List
def flash_message(message: str, category: str = 'info'):
"""Add a flash message to session"""
if 'flash_messages' not in session:
session['flash_messages'] = []
session['flash_messages'].append({'message': message, 'category': category})
def get_flash_messages() -> List[dict]:
"""Get and clear flash messages from session"""
messages = session.pop('flash_messages', [])
return messages
def generate_slug(text: str) -> str:
"""Generate URL-friendly slug from text"""
# Remove HTML tags
text = re.sub(r'<[^>]+>', '', text)
# Convert to lowercase and replace spaces/special chars with hyphens
slug = re.sub(r'[^\w\s-]', '', text).strip().lower()
slug = re.sub(r'[\s_-]+', '-', slug)
# Remove leading/trailing hyphens
slug = slug.strip('-')
return slug
def sanitize_html(text: str) -> str:
"""Basic HTML sanitization"""
if not text:
return ''
# Escape HTML entities
return html.escape(text)
def truncate_text(text: str, max_length: int = 150, suffix: str = '...') -> str:
"""Truncate text to specified length"""
if not text or len(text) <= max_length:
return text
return text[:max_length].rsplit(' ', 1)[0] + suffix
def format_date(date_obj, format_str: str = '%d/%m/%Y') -> str:
"""Format datetime object to string"""
if not date_obj:
return ''
return date_obj.strftime(format_str)
def paginate_query_params(page: int = 1, per_page: int = 10) -> tuple:
"""Generate LIMIT and OFFSET for pagination"""
if page < 1:
page = 1
offset = (page - 1) * per_page
return per_page, offset
def calculate_pagination(total_items: int, page: int, per_page: int) -> dict:
"""Calculate pagination information"""
total_pages = (total_items + per_page - 1) // per_page
has_prev = page > 1
has_next = page < total_pages
return {
'page': page,
'per_page': per_page,
'total_pages': total_pages,
'total_items': total_items,
'has_prev': has_prev,
'has_next': has_next,
'prev_page': page - 1 if has_prev else None,
'next_page': page + 1 if has_next else None
}

View File

@@ -1,113 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Validation Utilities
import re
from typing import Tuple, Optional
def validate_email(email: str) -> Tuple[bool, Optional[str]]:
"""Validate email format"""
if not email:
return False, "Email è richiesta"
# Basic email regex pattern
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
return False, "Formato email non valido"
if len(email) > 100:
return False, "Email troppo lunga (max 100 caratteri)"
return True, None
def validate_password(password: str) -> Tuple[bool, Optional[str]]:
"""Validate password strength"""
if not password:
return False, "Password è richiesta"
if len(password) < 8:
return False, "Password deve essere almeno 8 caratteri"
if len(password) > 128:
return False, "Password troppo lunga (max 128 caratteri)"
# Check for at least one uppercase letter
if not re.search(r'[A-Z]', password):
return False, "Password deve contenere almeno una lettera maiuscola"
# Check for at least one lowercase letter
if not re.search(r'[a-z]', password):
return False, "Password deve contenere almeno una lettera minuscola"
# Check for at least one digit
if not re.search(r'\d', password):
return False, "Password deve contenere almeno un numero"
return True, None
def validate_username(username: str) -> Tuple[bool, Optional[str]]:
"""Validate username format"""
if not username:
return False, "Username è richiesto"
if len(username) < 3:
return False, "Username deve essere almeno 3 caratteri"
if len(username) > 50:
return False, "Username troppo lungo (max 50 caratteri)"
# Check for valid characters (alphanumeric and underscore)
if not re.match(r'^[a-zA-Z0-9_]+$', username):
return False, "Username può contenere solo lettere, numeri e underscore"
return True, None
def validate_project_data(data: dict) -> Tuple[bool, dict]:
"""Validate project data"""
errors = {}
# Title validation
if not data.get('title', '').strip():
errors['title'] = 'Titolo è richiesto'
elif len(data['title']) > 200:
errors['title'] = 'Titolo troppo lungo (max 200 caratteri)'
# Description validation
if data.get('description') and len(data['description']) > 1000:
errors['description'] = 'Descrizione troppo lunga (max 1000 caratteri)'
# URL validations
url_pattern = r'^https?://[^\s]+$'
if data.get('github_url') and not re.match(url_pattern, data['github_url']):
errors['github_url'] = 'URL GitHub non valido'
if data.get('demo_url') and not re.match(url_pattern, data['demo_url']):
errors['demo_url'] = 'URL Demo non valido'
if data.get('image_url') and not re.match(url_pattern, data['image_url']):
errors['image_url'] = 'URL Immagine non valido'
return len(errors) == 0, errors
def validate_post_data(data: dict) -> Tuple[bool, dict]:
"""Validate blog post data"""
errors = {}
# Title validation
if not data.get('title', '').strip():
errors['title'] = 'Titolo è richiesto'
elif len(data['title']) > 200:
errors['title'] = 'Titolo troppo lungo (max 200 caratteri)'
# Content validation
if not data.get('content', '').strip():
errors['content'] = 'Contenuto è richiesto'
# Excerpt validation
if data.get('excerpt') and len(data['excerpt']) > 500:
errors['excerpt'] = 'Riassunto troppo lungo (max 500 caratteri)'
return len(errors) == 0, errors