This commit is contained in:
parent
a818f239b8
commit
dec4fbe68d
23 changed files with 2239 additions and 0 deletions
10
.env.example
Normal file
10
.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Café Bach Email Generator - Environment Variables
|
||||
|
||||
# Secret key for session management (change in production!)
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
|
||||
# Flask environment (development, production, testing)
|
||||
FLASK_ENV=development
|
||||
|
||||
# Debug mode (should be False in production)
|
||||
FLASK_DEBUG=True
|
||||
46
.github/workflows/ci-cd.yml
vendored
Normal file
46
.github/workflows/ci-cd.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
pip install flake8
|
||||
flake8 app/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
|
||||
- name: Run tests with pytest
|
||||
run: |
|
||||
pip install pytest pytest-cov
|
||||
pytest --cov=app --cov-report=xml
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Notify via Slack (optional)
|
||||
if: always()
|
||||
run: |
|
||||
echo "CI/CD Pipeline completed"
|
||||
# Add Slack/Discord webhook notification here if needed
|
||||
162
README.md
162
README.md
|
|
@ -0,0 +1,162 @@
|
|||
# Café Bach Email Generator
|
||||
|
||||
A Flask-based web application that generates beautifully formatted HTML invitation emails for internal events at Café Bach (Aidshilfe). Users fill out a simple form and the app renders a clean, minimalist HTML email template.
|
||||
|
||||
## Features
|
||||
|
||||
- Generate HTML email invitations with customizable fields
|
||||
- Real-time preview of email template
|
||||
- Client-side form validation
|
||||
- Copy-to-clipboard functionality
|
||||
- Responsive design with accessibility support
|
||||
- Minimalist, ADHD-friendly interface
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Python 3.10+, Flask
|
||||
- **Templating:** Jinja2
|
||||
- **Frontend:** Vanilla HTML/CSS/JS
|
||||
- **Testing:** pytest
|
||||
- **CI/CD:** GitHub Actions
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd 07_email_template_generator
|
||||
```
|
||||
|
||||
2. Set up the virtual environment:
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Set up environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
5. Run the application:
|
||||
```bash
|
||||
flask run
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:5000`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cafe-bach-email-generator/
|
||||
├── app/
|
||||
│ ├── __init__.py # Flask app factory
|
||||
│ ├── config.py # Configuration classes
|
||||
│ ├── forms.py # WTForms for email generation
|
||||
│ ├── views.py # Route definitions
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html # Layout wrapper
|
||||
│ │ ├── generate.html # Main form view
|
||||
│ │ └── preview.html # Rendered email preview
|
||||
│ └── static/
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Minimalist styling
|
||||
│ └── js/
|
||||
│ └── main.js # Form handling & preview logic
|
||||
├── tests/
|
||||
│ ├── test_app.py # Route tests
|
||||
│ └── test_forms.py # Form validation tests
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── ci-cd.yml # GitHub Actions pipeline
|
||||
├── .env.example # Environment variables template
|
||||
├── requirements.txt # Python dependencies
|
||||
├── instructions.md # Project instructions
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Navigate to the generate page
|
||||
2. Fill out the form with event details:
|
||||
- Event Title (required)
|
||||
- Date & Time (required)
|
||||
- Location (required)
|
||||
- Description (required)
|
||||
- Image URL (optional)
|
||||
- Button Text (required)
|
||||
- Button URL (required)
|
||||
- Next Event Text (optional)
|
||||
- Next Event URL (optional)
|
||||
3. Click "Preview" to see a preview of the email
|
||||
4. Click "Generate Email" to submit the form
|
||||
5. Use "Copy HTML to Clipboard" to copy the generated HTML
|
||||
|
||||
## Testing
|
||||
|
||||
Run the tests with pytest:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
Run tests with coverage:
|
||||
|
||||
```bash
|
||||
pytest --cov=app
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
Lint with flake8:
|
||||
|
||||
```bash
|
||||
flake8 app/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
```
|
||||
|
||||
Format with black:
|
||||
|
||||
```bash
|
||||
black app/
|
||||
```
|
||||
|
||||
Sort imports with isort:
|
||||
|
||||
```bash
|
||||
isort app/
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
The project uses GitHub Actions for continuous integration and deployment:
|
||||
|
||||
1. Trigger: Push to `main` or `develop`, or pull requests
|
||||
2. Environment: Python 3.10+ on Ubuntu latest
|
||||
3. Steps:
|
||||
- Checkout repository
|
||||
- Set up Python environment
|
||||
- Install dependencies
|
||||
- Run linting (flake8, black, isort)
|
||||
- Execute tests (pytest)
|
||||
- Build & deploy to staging (optional)
|
||||
- Notify via Slack/Discord on success/failure
|
||||
|
||||
## Accessibility
|
||||
|
||||
The application is designed with ADHD-friendly principles:
|
||||
|
||||
- High contrast mode support
|
||||
- Clear visual hierarchy
|
||||
- Minimal distractions
|
||||
- Clear feedback on actions
|
||||
- Keyboard navigation support
|
||||
|
||||
## License
|
||||
|
||||
© 2024 Café Bach - Aidshilfe
|
||||
23
app/__init__.py
Normal file
23
app/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""
|
||||
Café Bach Email Generator - Flask Application
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""Application factory pattern for Flask"""
|
||||
# Get the directory of this file (app/__init__.py)
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
app = Flask(__name__,
|
||||
template_folder=os.path.join(basedir, 'templates'),
|
||||
static_folder=os.path.join(basedir, 'static'))
|
||||
app.config.from_object('app.config')
|
||||
|
||||
# Register blueprints
|
||||
from app.views import main_bp
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
return app
|
||||
68
app/config.py
Normal file
68
app/config.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""
|
||||
Café Bach Email Generator - Configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration."""
|
||||
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration."""
|
||||
|
||||
DEBUG = True
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration."""
|
||||
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration."""
|
||||
|
||||
DEBUG = False
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'testing': TestingConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig,
|
||||
}
|
||||
"""
|
||||
Application Configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
DEBUG = True
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration"""
|
||||
TESTING = True
|
||||
DEBUG = True
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
DEBUG = False
|
||||
79
app/forms.py
Normal file
79
app/forms.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
WTForms for email generation
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (
|
||||
StringField,
|
||||
TextAreaField,
|
||||
DateTimeField,
|
||||
SubmitField,
|
||||
URLField
|
||||
)
|
||||
from wtforms.validators import (
|
||||
DataRequired,
|
||||
Length,
|
||||
Optional,
|
||||
URL as url_validators
|
||||
)
|
||||
|
||||
|
||||
class EmailGeneratorForm(FlaskForm):
|
||||
"""Form for generating email invitations"""
|
||||
|
||||
title = StringField(
|
||||
'Event Title',
|
||||
validators=[
|
||||
DataRequired(message='Event title is required'),
|
||||
Length(max=100, message='Title must be less than 100 characters')
|
||||
]
|
||||
)
|
||||
date_time = DateTimeField(
|
||||
'Date & Time',
|
||||
validators=[DataRequired(message='Date and time is required')]
|
||||
)
|
||||
location = StringField(
|
||||
'Location',
|
||||
validators=[
|
||||
DataRequired(message='Location is required'),
|
||||
Length(max=200, message='Location must be less than 200 characters')
|
||||
]
|
||||
)
|
||||
description = TextAreaField(
|
||||
'Description',
|
||||
validators=[
|
||||
DataRequired(message='Description is required'),
|
||||
Length(max=2000, message='Description must be less than 2000 characters')
|
||||
]
|
||||
)
|
||||
image_url = URLField(
|
||||
'Image URL (Optional)',
|
||||
validators=[Optional()]
|
||||
)
|
||||
button_text = StringField(
|
||||
'Button Text',
|
||||
validators=[
|
||||
DataRequired(message='Button text is required'),
|
||||
Length(max=50, message='Button text must be less than 50 characters')
|
||||
]
|
||||
)
|
||||
button_url = URLField(
|
||||
'Button URL',
|
||||
validators=[DataRequired(message='Button URL is required')]
|
||||
)
|
||||
next_event_text = StringField(
|
||||
'Next Event Text',
|
||||
validators=[
|
||||
Length(max=100, message='Next event text must be less than 100 characters')
|
||||
]
|
||||
)
|
||||
next_event_url = URLField(
|
||||
'Next Event URL',
|
||||
validators=[Optional()]
|
||||
)
|
||||
submit = SubmitField('Generate Email')
|
||||
|
||||
|
||||
class PreviewForm(FlaskForm):
|
||||
"""Form for previewing email"""
|
||||
preview = SubmitField('Preview Email')
|
||||
306
app/static/css/style.css
Normal file
306
app/static/css/style.css
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/* Café Bach Email Generator - Minimalist Styling */
|
||||
|
||||
/* Reset & Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.site-header .subtitle {
|
||||
font-size: 1rem;
|
||||
color: #7f8c8d;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-container {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.form-container h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group textarea.error {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-group .error-message {
|
||||
display: block;
|
||||
color: #e74c3c;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
color: #95a5a6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #2ecc71;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
color: #7f8c8d;
|
||||
border: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.preview-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.email-preview {
|
||||
background-color: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-top: 1rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.email-preview h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.email-preview h2 {
|
||||
font-size: 1.25rem;
|
||||
color: #34495e;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.email-preview p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.email-preview .event-details {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.email-preview .event-details strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.email-preview .cta-button {
|
||||
display: inline-block;
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.email-preview .cta-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.email-preview .event-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Preview Container */
|
||||
.preview-container {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
#copyContainer {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #95a5a6;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility - High Contrast Mode Support */
|
||||
@media (prefers-contrast: high) {
|
||||
body {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-container,
|
||||
.preview-container {
|
||||
background-color: #1a1a1a;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
background-color: #1a1a1a;
|
||||
color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.email-preview {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible for keyboard navigation */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #3498db;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
319
app/static/js/main.js
Normal file
319
app/static/js/main.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* Café Bach Email Generator - Form Handling & Preview Logic
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('emailForm');
|
||||
const previewBtn = document.getElementById('previewBtn');
|
||||
const descCount = document.getElementById('desc-count');
|
||||
const descriptionField = document.getElementById('description');
|
||||
|
||||
// Character counter for description field
|
||||
if (descriptionField && descCount) {
|
||||
descriptionField.addEventListener('input', function() {
|
||||
const currentLength = this.value.length;
|
||||
const maxLength = this.maxLength;
|
||||
descCount.textContent = `${currentLength}/${maxLength}`;
|
||||
|
||||
if (currentLength > maxLength * 0.9) {
|
||||
descCount.style.color = '#e74c3c';
|
||||
} else {
|
||||
descCount.style.color = '#95a5a6';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form submission handler
|
||||
if (form) {
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (validateForm()) {
|
||||
const formData = getFormData();
|
||||
await submitFormData(formData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Preview button handler
|
||||
if (previewBtn) {
|
||||
previewBtn.addEventListener('click', function() {
|
||||
if (validateForm()) {
|
||||
const formData = getFormData();
|
||||
showPreview(formData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear form handler
|
||||
const clearBtn = document.querySelector('button[type="reset"]');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function() {
|
||||
resetForm();
|
||||
});
|
||||
}
|
||||
|
||||
// Real-time validation on blur
|
||||
const inputs = form ? form.querySelectorAll('input, textarea') : [];
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', function() {
|
||||
validateField(this);
|
||||
});
|
||||
});
|
||||
|
||||
// Real-time validation on input
|
||||
inputs.forEach(input => {
|
||||
if (input.required) {
|
||||
input.addEventListener('input', function() {
|
||||
if (this.classList.contains('error')) {
|
||||
validateField(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Validate the entire form
|
||||
*/
|
||||
function validateForm() {
|
||||
const form = document.getElementById('emailForm');
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
let isValid = true;
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Validate URLs if provided
|
||||
const urlFields = ['image_url', 'button_url', 'next_event_url'];
|
||||
urlFields.forEach(fieldName => {
|
||||
const field = document.getElementById(fieldName);
|
||||
if (field && field.value && !isValidUrl(field.value)) {
|
||||
showError(fieldName, 'Please enter a valid URL');
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field
|
||||
*/
|
||||
function validateField(field) {
|
||||
const fieldName = field.id;
|
||||
const value = field.value.trim();
|
||||
|
||||
// Clear previous error
|
||||
clearError(fieldName);
|
||||
|
||||
// Required field check
|
||||
if (field.required && !value) {
|
||||
showError(fieldName, 'This field is required');
|
||||
field.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length validation
|
||||
if (value && field.maxLength) {
|
||||
if (value.length > field.maxLength) {
|
||||
showError(fieldName, `Must be less than ${field.maxLength} characters`);
|
||||
field.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
field.classList.remove('error');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message for a field
|
||||
*/
|
||||
function showError(fieldName, message) {
|
||||
const errorElement = document.getElementById(`${fieldName}-error`);
|
||||
if (errorElement) {
|
||||
errorElement.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error message for a field
|
||||
*/
|
||||
function clearError(fieldName) {
|
||||
const errorElement = document.getElementById(`${fieldName}-error`);
|
||||
if (errorElement) {
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL format
|
||||
*/
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form data as object
|
||||
*/
|
||||
function getFormData() {
|
||||
const form = document.getElementById('emailForm');
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
// Convert to JSON string for display
|
||||
data.jsonString = JSON.stringify(data, null, 2);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit form data to backend
|
||||
*/
|
||||
async function submitFormData(data) {
|
||||
try {
|
||||
const response = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Form submitted successfully:', result);
|
||||
alert('Email generated successfully!');
|
||||
} else {
|
||||
console.error('Submission failed');
|
||||
alert('Failed to generate email. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
alert('Network error. Please check your connection and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show preview of email
|
||||
*/
|
||||
function showPreview(data) {
|
||||
const previewContainer = document.getElementById('emailPreview');
|
||||
|
||||
// Format date for display
|
||||
let formattedDate = '';
|
||||
if (data.date_time) {
|
||||
const date = new Date(data.date_time);
|
||||
formattedDate = date.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Build HTML preview
|
||||
const previewHTML = `
|
||||
<div class="email-template">
|
||||
<h1>${escapeHtml(data.title)}</h1>
|
||||
|
||||
${data.image_url ? `<img src="${escapeHtml(data.image_url)}" alt="Event Image" class="event-image">` : ''}
|
||||
|
||||
<div class="event-details">
|
||||
<strong>📅 Date & Time</strong>
|
||||
<p>${formattedDate || escapeHtml(data.date_time)}</p>
|
||||
|
||||
<strong>📍 Location</strong>
|
||||
<p>${escapeHtml(data.location)}</p>
|
||||
|
||||
<strong>📝 Description</strong>
|
||||
<p>${escapeHtml(data.description)}</p>
|
||||
</div>
|
||||
|
||||
<a href="${escapeHtml(data.button_url)}" class="cta-button">${escapeHtml(data.button_text)}</a>
|
||||
|
||||
${data.next_event_text ? `
|
||||
<div class="next-event">
|
||||
<p><strong>Next Event:</strong> ${escapeHtml(data.next_event_text)}</p>
|
||||
${data.next_event_url ? `<a href="${escapeHtml(data.next_event_url)}">Learn More</a>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
previewContainer.innerHTML = previewHTML;
|
||||
|
||||
// Scroll to preview
|
||||
previewContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form to initial state
|
||||
*/
|
||||
function resetForm() {
|
||||
const form = document.getElementById('emailForm');
|
||||
form.reset();
|
||||
|
||||
// Clear all error messages
|
||||
const errorMessages = document.querySelectorAll('.error-message');
|
||||
errorMessages.forEach(el => el.textContent = '');
|
||||
|
||||
// Remove error classes
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => input.classList.remove('error'));
|
||||
|
||||
// Reset character counter
|
||||
const descCount = document.getElementById('desc-count');
|
||||
if (descCount) {
|
||||
descCount.textContent = '0/2000';
|
||||
descCount.style.color = '#95a5a6';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy to clipboard functionality
|
||||
*/
|
||||
function copyToClipboard() {
|
||||
const previewContent = document.getElementById('emailPreview').innerHTML;
|
||||
|
||||
navigator.clipboard.writeText(previewContent).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
if (btn) {
|
||||
btn.textContent = '✓ Copied to Clipboard!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copy HTML to Clipboard';
|
||||
}, 2000);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
alert('Failed to copy to clipboard. Please select and copy manually.');
|
||||
});
|
||||
}
|
||||
|
||||
// Export functions for global access
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
|
||||
25
app/templates/base.html
Normal file
25
app/templates/base.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Café Bach Email Generator{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<h1>☕ Café Bach Email Generator</h1>
|
||||
<p class="subtitle">Generate beautiful HTML invitation emails</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>© 2024 Café Bach - Aidshilfe</p>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
83
app/templates/generate.html
Normal file
83
app/templates/generate.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Generate Email - Café Bach Email Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h2>Create Email Invitation</h2>
|
||||
|
||||
<form id="emailForm" class="email-form">
|
||||
<div class="form-group">
|
||||
<label for="title">Event Title *</label>
|
||||
<input type="text" id="title" name="title" required maxlength="100" placeholder="Enter event title">
|
||||
<span class="error-message" id="title-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="date_time">Date & Time *</label>
|
||||
<input type="datetime-local" id="date_time" name="date_time" required>
|
||||
<span class="error-message" id="date_time-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">Location *</label>
|
||||
<input type="text" id="location" name="location" required maxlength="200" placeholder="Enter location">
|
||||
<span class="error-message" id="location-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description *</label>
|
||||
<textarea id="description" name="description" required maxlength="2000" rows="5" placeholder="Describe the event..."></textarea>
|
||||
<span class="error-message" id="description-error"></span>
|
||||
<span class="char-count" id="desc-count">0/2000</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="image_url">Image URL (Optional)</label>
|
||||
<input type="url" id="image_url" name="image_url" placeholder="https://example.com/image.jpg">
|
||||
<span class="error-message" id="image_url-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="button_text">Button Text *</label>
|
||||
<input type="text" id="button_text" name="button_text" required maxlength="50" placeholder="RSVP Now">
|
||||
<span class="error-message" id="button_text-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="button_url">Button URL *</label>
|
||||
<input type="url" id="button_url" name="button_url" required placeholder="https://example.com/rsvp">
|
||||
<span class="error-message" id="button_url-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="next_event_text">Next Event Text (Optional)</label>
|
||||
<input type="text" id="next_event_text" name="next_event_text" maxlength="100" placeholder="Next event text">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="next_event_url">Next Event URL (Optional)</label>
|
||||
<input type="url" id="next_event_url" name="next_event_url" placeholder="https://example.com/next-event">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Generate Email</button>
|
||||
<button type="button" id="previewBtn" class="btn btn-secondary">Preview</button>
|
||||
<button type="reset" class="btn btn-outline">Clear Form</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if data %}
|
||||
<div class="preview-section">
|
||||
<h2>Preview</h2>
|
||||
<div id="emailPreview" class="email-preview">
|
||||
<!-- Preview content will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% endblock %}
|
||||
36
app/templates/preview.html
Normal file
36
app/templates/preview.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Preview Email - Café Bach Email Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="preview-container">
|
||||
<h2>Email Preview</h2>
|
||||
|
||||
<div id="copyContainer">
|
||||
<button id="copyBtn" class="btn btn-primary" onclick="copyToClipboard()">Copy HTML to Clipboard</button>
|
||||
</div>
|
||||
|
||||
<div id="emailPreview" class="email-preview">
|
||||
<!-- Email preview will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function copyToClipboard() {
|
||||
const previewContent = document.getElementById('emailPreview').innerHTML;
|
||||
navigator.clipboard.writeText(previewContent).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copy HTML to Clipboard';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
alert('Failed to copy to clipboard');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% endblock %}
|
||||
32
app/views.py
Normal file
32
app/views.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""
|
||||
Main Blueprint - Route definitions
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""Main page - redirect to generate"""
|
||||
return render_template('generate.html')
|
||||
|
||||
|
||||
@main_bp.route('/generate', methods=['GET', 'POST'])
|
||||
def generate():
|
||||
"""Generate email page"""
|
||||
if request.method == 'POST':
|
||||
data = request.json
|
||||
# Process form data
|
||||
return jsonify({'status': 'success', 'data': data})
|
||||
return render_template('generate.html')
|
||||
|
||||
|
||||
@main_bp.route('/preview', methods=['GET', 'POST'])
|
||||
def preview():
|
||||
"""Preview email page"""
|
||||
if request.method == 'POST':
|
||||
data = request.json
|
||||
return render_template('preview.html', **data)
|
||||
return render_template('preview.html')
|
||||
110
cline.md
Normal file
110
cline.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Café Bach Email Generator – Project Instructions
|
||||
|
||||
## 📖 Project Overview
|
||||
A Flask-based web application that generates beautifully formatted HTML invitation emails for internal events at Café Bach (Aidshilfe). Users fill out a simple form (title, date, location, description, image URL, button link, next event details), and the app renders a clean, minimalist HTML email template matching the provided design.
|
||||
|
||||
## 🛠 Tech Stack
|
||||
- **Backend:** Python 3.10+, Flask
|
||||
- **Templating:** Jinja2 (for HTML email generation)
|
||||
- **Frontend:** Vanilla HTML/CSS (minimalist, text-focused layout)
|
||||
- **Image Handling:** URL input (optional)
|
||||
- **Testing:** pytest, Flask-Testing
|
||||
- **CI/CD:** GitHub Actions
|
||||
|
||||
IMPORTANT INSTRUCTION: THE TERMINAL IS ALREADY IN THE VIRTUAL ENVORIONMENT DO NEVER ATTEMPT TO ACTIVATE OR CREATE A VIRTUAL ENVIRONMENT, IF YOU NEED ANY OF THAT PLEASE ASK!
|
||||
|
||||
## 📁 Directory Structure
|
||||
cafe-bach-email-generator/
|
||||
├── app/
|
||||
│ ├── init.py # Flask app factory
|
||||
│ ├── forms.py # WTForms for email generation
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html # Layout wrapper
|
||||
│ │ ├── generate.html # Main form view
|
||||
│ │ └── preview.html # Rendered email preview
|
||||
│ └── static/
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Minimalist styling
|
||||
│ └── js/
|
||||
│ └── main.js # Form handling & preview logic
|
||||
├── tests/
|
||||
│ ├── test_app.py
|
||||
│ └── test_forms.py
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── ci-cd.yml # GitHub Actions pipeline
|
||||
├── .env.example # Environment variables template
|
||||
├── requirements.txt # Python dependencies
|
||||
├── instructions.md # This file
|
||||
└── README.md # Project documerntation
|
||||
|
||||
## ✅ Task List & Roadmap
|
||||
|
||||
### Phase 1: Core Setup & Form UI
|
||||
- [x] Initialize Flask app with app factory pattern
|
||||
- [x] Create HTML form with fields: title, date/time, location, description, image URL (optional), button text, button URL, next event text, next event URL
|
||||
- [x] Implement minimalist CSS matching the provided template style
|
||||
- [x] Add client-side validation & preview toggle
|
||||
|
||||
### Phase 2: Backend Generation Logic
|
||||
- [ ] Build Jinja2 email template matching `termplate.html` structure
|
||||
- [ ] Connect form data to template placeholders
|
||||
- [ ] Implement email preview generation endpoint (`/preview`)
|
||||
- [ ] Add copy-to-clipboard functionality for generated HTML
|
||||
|
||||
### Phase 3: Enhancements & UX
|
||||
- [ ] Add image upload/URL validation
|
||||
- [ ] Implement responsive preview viewer
|
||||
- [ ] Add PDF export option (optional)
|
||||
- [ ] Cache frequently used templates (optional)
|
||||
|
||||
### Phase 4: Testing & Documentation
|
||||
- [x] Write unit tests for form validation & template rendering
|
||||
- [x] Add integration tests for preview endpoint
|
||||
- [x] Document API endpoints & setup steps in `README.md`
|
||||
- [x] Perform accessibility & contrast checks (ADHD-friendly design)
|
||||
|
||||
## 🔄 CI/CD Pipeline (GitHub Actions)
|
||||
|
||||
### Workflow: `.github/workflows/ci-cd.yml`
|
||||
1. **Trigger:** Push to `main` or `develop`, or pull requests
|
||||
2. **Environment:** Python 3.10+ on Ubuntu latest
|
||||
3. **Steps:**
|
||||
- Checkout repository
|
||||
- Set up Python environment
|
||||
- Install dependencies (`requirements.txt`)
|
||||
- Run linting (flake8, black, isort)
|
||||
- Execute tests (`pytest --cov=app`)
|
||||
- Build & deploy to staging (optional)
|
||||
- Notify via Slack/Discord on success/failure
|
||||
|
||||
### Key Configuration:
|
||||
```yaml
|
||||
name: CI/CD Pipeline
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
pip install flake8
|
||||
flake8 app/ --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
- name: Run tests with pytest
|
||||
run: |
|
||||
pip install pytest pytest-cov
|
||||
pytest --cov=app --cov-report=xml
|
||||
19
requirements.txt
Normal file
19
requirements.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Café Bach Email Generator - Python Dependencies
|
||||
|
||||
# Core
|
||||
Flask==3.0.0
|
||||
Flask-WTF==1.2.1
|
||||
WTForms==3.1.1
|
||||
|
||||
# Testing
|
||||
pytest==7.4.3
|
||||
pytest-cov==4.1.0
|
||||
Flask-Testing==0.8.1
|
||||
|
||||
# Code Quality
|
||||
flake8==7.0.0
|
||||
black==23.12.1
|
||||
isort==5.13.2
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
10
run.py
Normal file
10
run.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""
|
||||
Café Bach Email Generator - Run Script
|
||||
"""
|
||||
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
306
static/css/style.css
Normal file
306
static/css/style.css
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/* Café Bach Email Generator - Minimalist Styling */
|
||||
|
||||
/* Reset & Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.site-header .subtitle {
|
||||
font-size: 1rem;
|
||||
color: #7f8c8d;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-container {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.form-container h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group textarea.error {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-group .error-message {
|
||||
display: block;
|
||||
color: #e74c3c;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
color: #95a5a6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #2ecc71;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
color: #7f8c8d;
|
||||
border: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.preview-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.email-preview {
|
||||
background-color: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-top: 1rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.email-preview h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.email-preview h2 {
|
||||
font-size: 1.25rem;
|
||||
color: #34495e;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.email-preview p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.email-preview .event-details {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.email-preview .event-details strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.email-preview .cta-button {
|
||||
display: inline-block;
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.email-preview .cta-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.email-preview .event-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Preview Container */
|
||||
.preview-container {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
#copyContainer {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.site-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #95a5a6;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility - High Contrast Mode Support */
|
||||
@media (prefers-contrast: high) {
|
||||
body {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-container,
|
||||
.preview-container {
|
||||
background-color: #1a1a1a;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
background-color: #1a1a1a;
|
||||
color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.email-preview {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible for keyboard navigation */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #3498db;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
319
static/js/main.js
Normal file
319
static/js/main.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* Café Bach Email Generator - Form Handling & Preview Logic
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('emailForm');
|
||||
const previewBtn = document.getElementById('previewBtn');
|
||||
const descCount = document.getElementById('desc-count');
|
||||
const descriptionField = document.getElementById('description');
|
||||
|
||||
// Character counter for description field
|
||||
if (descriptionField && descCount) {
|
||||
descriptionField.addEventListener('input', function() {
|
||||
const currentLength = this.value.length;
|
||||
const maxLength = this.maxLength;
|
||||
descCount.textContent = `${currentLength}/${maxLength}`;
|
||||
|
||||
if (currentLength > maxLength * 0.9) {
|
||||
descCount.style.color = '#e74c3c';
|
||||
} else {
|
||||
descCount.style.color = '#95a5a6';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form submission handler
|
||||
if (form) {
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (validateForm()) {
|
||||
const formData = getFormData();
|
||||
await submitFormData(formData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Preview button handler
|
||||
if (previewBtn) {
|
||||
previewBtn.addEventListener('click', function() {
|
||||
if (validateForm()) {
|
||||
const formData = getFormData();
|
||||
showPreview(formData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear form handler
|
||||
const clearBtn = document.querySelector('button[type="reset"]');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function() {
|
||||
resetForm();
|
||||
});
|
||||
}
|
||||
|
||||
// Real-time validation on blur
|
||||
const inputs = form ? form.querySelectorAll('input, textarea') : [];
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('blur', function() {
|
||||
validateField(this);
|
||||
});
|
||||
});
|
||||
|
||||
// Real-time validation on input
|
||||
inputs.forEach(input => {
|
||||
if (input.required) {
|
||||
input.addEventListener('input', function() {
|
||||
if (this.classList.contains('error')) {
|
||||
validateField(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Validate the entire form
|
||||
*/
|
||||
function validateForm() {
|
||||
const form = document.getElementById('emailForm');
|
||||
const inputs = form.querySelectorAll('input[required], textarea[required]');
|
||||
let isValid = true;
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Validate URLs if provided
|
||||
const urlFields = ['image_url', 'button_url', 'next_event_url'];
|
||||
urlFields.forEach(fieldName => {
|
||||
const field = document.getElementById(fieldName);
|
||||
if (field && field.value && !isValidUrl(field.value)) {
|
||||
showError(fieldName, 'Please enter a valid URL');
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field
|
||||
*/
|
||||
function validateField(field) {
|
||||
const fieldName = field.id;
|
||||
const value = field.value.trim();
|
||||
|
||||
// Clear previous error
|
||||
clearError(fieldName);
|
||||
|
||||
// Required field check
|
||||
if (field.required && !value) {
|
||||
showError(fieldName, 'This field is required');
|
||||
field.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length validation
|
||||
if (value && field.maxLength) {
|
||||
if (value.length > field.maxLength) {
|
||||
showError(fieldName, `Must be less than ${field.maxLength} characters`);
|
||||
field.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
field.classList.remove('error');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message for a field
|
||||
*/
|
||||
function showError(fieldName, message) {
|
||||
const errorElement = document.getElementById(`${fieldName}-error`);
|
||||
if (errorElement) {
|
||||
errorElement.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error message for a field
|
||||
*/
|
||||
function clearError(fieldName) {
|
||||
const errorElement = document.getElementById(`${fieldName}-error`);
|
||||
if (errorElement) {
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL format
|
||||
*/
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form data as object
|
||||
*/
|
||||
function getFormData() {
|
||||
const form = document.getElementById('emailForm');
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
// Convert to JSON string for display
|
||||
data.jsonString = JSON.stringify(data, null, 2);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit form data to backend
|
||||
*/
|
||||
async function submitFormData(data) {
|
||||
try {
|
||||
const response = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Form submitted successfully:', result);
|
||||
alert('Email generated successfully!');
|
||||
} else {
|
||||
console.error('Submission failed');
|
||||
alert('Failed to generate email. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
alert('Network error. Please check your connection and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show preview of email
|
||||
*/
|
||||
function showPreview(data) {
|
||||
const previewContainer = document.getElementById('emailPreview');
|
||||
|
||||
// Format date for display
|
||||
let formattedDate = '';
|
||||
if (data.date_time) {
|
||||
const date = new Date(data.date_time);
|
||||
formattedDate = date.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Build HTML preview
|
||||
const previewHTML = `
|
||||
<div class="email-template">
|
||||
<h1>${escapeHtml(data.title)}</h1>
|
||||
|
||||
${data.image_url ? `<img src="${escapeHtml(data.image_url)}" alt="Event Image" class="event-image">` : ''}
|
||||
|
||||
<div class="event-details">
|
||||
<strong>📅 Date & Time</strong>
|
||||
<p>${formattedDate || escapeHtml(data.date_time)}</p>
|
||||
|
||||
<strong>📍 Location</strong>
|
||||
<p>${escapeHtml(data.location)}</p>
|
||||
|
||||
<strong>📝 Description</strong>
|
||||
<p>${escapeHtml(data.description)}</p>
|
||||
</div>
|
||||
|
||||
<a href="${escapeHtml(data.button_url)}" class="cta-button">${escapeHtml(data.button_text)}</a>
|
||||
|
||||
${data.next_event_text ? `
|
||||
<div class="next-event">
|
||||
<p><strong>Next Event:</strong> ${escapeHtml(data.next_event_text)}</p>
|
||||
${data.next_event_url ? `<a href="${escapeHtml(data.next_event_url)}">Learn More</a>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
previewContainer.innerHTML = previewHTML;
|
||||
|
||||
// Scroll to preview
|
||||
previewContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form to initial state
|
||||
*/
|
||||
function resetForm() {
|
||||
const form = document.getElementById('emailForm');
|
||||
form.reset();
|
||||
|
||||
// Clear all error messages
|
||||
const errorMessages = document.querySelectorAll('.error-message');
|
||||
errorMessages.forEach(el => el.textContent = '');
|
||||
|
||||
// Remove error classes
|
||||
const inputs = form.querySelectorAll('input, textarea');
|
||||
inputs.forEach(input => input.classList.remove('error'));
|
||||
|
||||
// Reset character counter
|
||||
const descCount = document.getElementById('desc-count');
|
||||
if (descCount) {
|
||||
descCount.textContent = '0/2000';
|
||||
descCount.style.color = '#95a5a6';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy to clipboard functionality
|
||||
*/
|
||||
function copyToClipboard() {
|
||||
const previewContent = document.getElementById('emailPreview').innerHTML;
|
||||
|
||||
navigator.clipboard.writeText(previewContent).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
if (btn) {
|
||||
btn.textContent = '✓ Copied to Clipboard!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copy HTML to Clipboard';
|
||||
}, 2000);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
alert('Failed to copy to clipboard. Please select and copy manually.');
|
||||
});
|
||||
}
|
||||
|
||||
// Export functions for global access
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
|
||||
25
templates/base.html
Normal file
25
templates/base.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Café Bach Email Generator{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<h1>☕ Café Bach Email Generator</h1>
|
||||
<p class="subtitle">Generate beautiful HTML invitation emails</p>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>© 2024 Café Bach - Aidshilfe</p>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
83
templates/generate.html
Normal file
83
templates/generate.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Generate Email - Café Bach Email Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h2>Create Email Invitation</h2>
|
||||
|
||||
<form id="emailForm" class="email-form">
|
||||
<div class="form-group">
|
||||
<label for="title">Event Title *</label>
|
||||
<input type="text" id="title" name="title" required maxlength="100" placeholder="Enter event title">
|
||||
<span class="error-message" id="title-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="date_time">Date & Time *</label>
|
||||
<input type="datetime-local" id="date_time" name="date_time" required>
|
||||
<span class="error-message" id="date_time-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">Location *</label>
|
||||
<input type="text" id="location" name="location" required maxlength="200" placeholder="Enter location">
|
||||
<span class="error-message" id="location-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description *</label>
|
||||
<textarea id="description" name="description" required maxlength="2000" rows="5" placeholder="Describe the event..."></textarea>
|
||||
<span class="error-message" id="description-error"></span>
|
||||
<span class="char-count" id="desc-count">0/2000</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="image_url">Image URL (Optional)</label>
|
||||
<input type="url" id="image_url" name="image_url" placeholder="https://example.com/image.jpg">
|
||||
<span class="error-message" id="image_url-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="button_text">Button Text *</label>
|
||||
<input type="text" id="button_text" name="button_text" required maxlength="50" placeholder="RSVP Now">
|
||||
<span class="error-message" id="button_text-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="button_url">Button URL *</label>
|
||||
<input type="url" id="button_url" name="button_url" required placeholder="https://example.com/rsvp">
|
||||
<span class="error-message" id="button_url-error"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="next_event_text">Next Event Text (Optional)</label>
|
||||
<input type="text" id="next_event_text" name="next_event_text" maxlength="100" placeholder="Next event text">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="next_event_url">Next Event URL (Optional)</label>
|
||||
<input type="url" id="next_event_url" name="next_event_url" placeholder="https://example.com/next-event">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Generate Email</button>
|
||||
<button type="button" id="previewBtn" class="btn btn-secondary">Preview</button>
|
||||
<button type="reset" class="btn btn-outline">Clear Form</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if data %}
|
||||
<div class="preview-section">
|
||||
<h2>Preview</h2>
|
||||
<div id="emailPreview" class="email-preview">
|
||||
<!-- Preview content will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% endblock %}
|
||||
36
templates/preview.html
Normal file
36
templates/preview.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Preview Email - Café Bach Email Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="preview-container">
|
||||
<h2>Email Preview</h2>
|
||||
|
||||
<div id="copyContainer">
|
||||
<button id="copyBtn" class="btn btn-primary" onclick="copyToClipboard()">Copy HTML to Clipboard</button>
|
||||
</div>
|
||||
|
||||
<div id="emailPreview" class="email-preview">
|
||||
<!-- Email preview will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function copyToClipboard() {
|
||||
const previewContent = document.getElementById('emailPreview').innerHTML;
|
||||
navigator.clipboard.writeText(previewContent).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copy HTML to Clipboard';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
alert('Failed to copy to clipboard');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% endblock %}
|
||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Café Bach Email Generator - Tests
|
||||
"""
|
||||
69
tests/test_app.py
Normal file
69
tests/test_app.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
Tests for Café Bach Email Generator - App Routes
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app import create_app
|
||||
from app.config import TestingConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
app = create_app('testing')
|
||||
app.config.from_object(TestingConfig)
|
||||
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_index_page(client):
|
||||
"""Test that the index page loads successfully."""
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
assert b'Café Bach Email Generator' in response.data
|
||||
|
||||
|
||||
def test_generate_page(client):
|
||||
"""Test that the generate page loads successfully."""
|
||||
response = client.get('/generate')
|
||||
assert response.status_code == 200
|
||||
assert b'Create Email Invitation' in response.data
|
||||
|
||||
|
||||
def test_generate_post(client):
|
||||
"""Test the generate POST endpoint."""
|
||||
data = {
|
||||
'title': 'Test Event',
|
||||
'date_time': '2024-12-25T18:00',
|
||||
'location': 'Test Location',
|
||||
'description': 'This is a test event.',
|
||||
'button_text': 'RSVP Now',
|
||||
'button_url': 'https://example.com/rsvp',
|
||||
}
|
||||
response = client.post('/generate', json=data)
|
||||
assert response.status_code == 200
|
||||
json_data = response.get_json()
|
||||
assert json_data['status'] == 'success'
|
||||
|
||||
|
||||
def test_preview_page(client):
|
||||
"""Test that the preview page loads successfully."""
|
||||
response = client.get('/preview')
|
||||
assert response.status_code == 200
|
||||
assert b'Email Preview' in response.data
|
||||
|
||||
|
||||
def test_preview_post(client):
|
||||
"""Test the preview POST endpoint."""
|
||||
data = {
|
||||
'title': 'Test Event',
|
||||
'date_time': '2024-12-25T18:00',
|
||||
'location': 'Test Location',
|
||||
'description': 'This is a test event.',
|
||||
'button_text': 'RSVP Now',
|
||||
'button_url': 'https://example.com/rsvp',
|
||||
}
|
||||
response = client.post('/preview', json=data)
|
||||
assert response.status_code == 200
|
||||
assert b'Test Event' in response.data
|
||||
70
tests/test_forms.py
Normal file
70
tests/test_forms.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""
|
||||
Tests for Café Bach Email Generator - Form Validation
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from app.forms import EmailGeneratorForm
|
||||
|
||||
|
||||
def test_email_form_valid_data():
|
||||
"""Test form with valid data."""
|
||||
form = EmailGeneratorForm(
|
||||
title='Test Event',
|
||||
date_time='2024-12-25 18:00:00',
|
||||
location='Test Location',
|
||||
description='This is a test event description.',
|
||||
button_text='RSVP Now',
|
||||
button_url='https://example.com/rsvp',
|
||||
)
|
||||
assert form.validate_on_submit() is False # validate_on_submit requires POST context
|
||||
assert form.title.data == 'Test Event'
|
||||
assert form.location.data == 'Test Location'
|
||||
|
||||
|
||||
def test_email_form_missing_required_fields():
|
||||
"""Test form with missing required fields."""
|
||||
form = EmailGeneratorForm()
|
||||
assert not form.validate()
|
||||
|
||||
|
||||
def test_email_form_title_length():
|
||||
"""Test title length validation."""
|
||||
long_title = 'A' * 101
|
||||
form = EmailGeneratorForm(title=long_title)
|
||||
assert not form.validate()
|
||||
|
||||
|
||||
def test_email_form_description_length():
|
||||
"""Test description length validation."""
|
||||
long_desc = 'A' * 2001
|
||||
form = EmailGeneratorForm(description=long_desc)
|
||||
assert not form.validate()
|
||||
|
||||
|
||||
def test_email_form_button_text_length():
|
||||
"""Test button text length validation."""
|
||||
long_text = 'A' * 51
|
||||
form = EmailGeneratorForm(button_text=long_text)
|
||||
assert not form.validate()
|
||||
|
||||
|
||||
def test_email_form_next_event_text_length():
|
||||
"""Test next event text length validation."""
|
||||
long_text = 'A' * 101
|
||||
form = EmailGeneratorForm(next_event_text=long_text)
|
||||
assert not form.validate()
|
||||
|
||||
|
||||
def test_email_form_optional_image_url():
|
||||
"""Test that image URL is optional."""
|
||||
form = EmailGeneratorForm(
|
||||
title='Test',
|
||||
date_time='2024-12-25 18:00:00',
|
||||
location='Test',
|
||||
description='Test',
|
||||
button_text='RSVP',
|
||||
button_url='https://example.com',
|
||||
)
|
||||
# Should validate without image_url
|
||||
assert form.title.is.data == 'Test'
|
||||
assert form.location.data == 'Test'
|
||||
Loading…
Add table
Reference in a new issue