From dec4fbe68df360318c2b8dbc942db0c3c5efd8b9 Mon Sep 17 00:00:00 2001 From: Patsy Date: Fri, 7 Aug 2026 16:46:44 +0200 Subject: [PATCH] Phase 1 done --- .env.example | 10 ++ .github/workflows/ci-cd.yml | 46 ++++++ README.md | 162 ++++++++++++++++++ app/__init__.py | 23 +++ app/config.py | 68 ++++++++ app/forms.py | 79 +++++++++ app/static/css/style.css | 306 ++++++++++++++++++++++++++++++++++ app/static/js/main.js | 319 ++++++++++++++++++++++++++++++++++++ app/templates/base.html | 25 +++ app/templates/generate.html | 83 ++++++++++ app/templates/preview.html | 36 ++++ app/views.py | 32 ++++ cline.md | 110 +++++++++++++ requirements.txt | 19 +++ run.py | 10 ++ static/css/style.css | 306 ++++++++++++++++++++++++++++++++++ static/js/main.js | 319 ++++++++++++++++++++++++++++++++++++ templates/base.html | 25 +++ templates/generate.html | 83 ++++++++++ templates/preview.html | 36 ++++ tests/__init__.py | 3 + tests/test_app.py | 69 ++++++++ tests/test_forms.py | 70 ++++++++ 23 files changed, 2239 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci-cd.yml create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/forms.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/main.js create mode 100644 app/templates/base.html create mode 100644 app/templates/generate.html create mode 100644 app/templates/preview.html create mode 100644 app/views.py create mode 100644 cline.md create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 static/css/style.css create mode 100644 static/js/main.js create mode 100644 templates/base.html create mode 100644 templates/generate.html create mode 100644 templates/preview.html create mode 100644 tests/__init__.py create mode 100644 tests/test_app.py create mode 100644 tests/test_forms.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..97833b9 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..98c77d0 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index e69de29..30c25ec 100644 --- a/README.md +++ b/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 + 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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..4534062 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..dd0a837 --- /dev/null +++ b/app/config.py @@ -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 diff --git a/app/forms.py b/app/forms.py new file mode 100644 index 0000000..a28702f --- /dev/null +++ b/app/forms.py @@ -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') diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..82bf7b6 --- /dev/null +++ b/app/static/css/style.css @@ -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; +} diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..fb1009a --- /dev/null +++ b/app/static/js/main.js @@ -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 = ` + + `; + + 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; + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..534e6bb --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,25 @@ + + + + + + {% block title %}Café Bach Email Generator{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ +
+

© 2024 Café Bach - Aidshilfe

+
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/generate.html b/app/templates/generate.html new file mode 100644 index 0000000..0b9ab10 --- /dev/null +++ b/app/templates/generate.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Generate Email - Café Bach Email Generator{% endblock %} + +{% block content %} +
+

Create Email Invitation

+ + + + {% if data %} +
+

Preview

+ +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/preview.html b/app/templates/preview.html new file mode 100644 index 0000000..42cae4b --- /dev/null +++ b/app/templates/preview.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Preview Email - Café Bach Email Generator{% endblock %} + +{% block content %} +
+

Email Preview

+ +
+ +
+ + +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/app/views.py b/app/views.py new file mode 100644 index 0000000..7b3bc7b --- /dev/null +++ b/app/views.py @@ -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') diff --git a/cline.md b/cline.md new file mode 100644 index 0000000..8c292e2 --- /dev/null +++ b/cline.md @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c28f680 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..c89be9c --- /dev/null +++ b/run.py @@ -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) \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..82bf7b6 --- /dev/null +++ b/static/css/style.css @@ -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; +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..fb1009a --- /dev/null +++ b/static/js/main.js @@ -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 = ` + + `; + + 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; + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..534e6bb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,25 @@ + + + + + + {% block title %}Café Bach Email Generator{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ +
+

© 2024 Café Bach - Aidshilfe

+
+ + {% block scripts %}{% endblock %} + + diff --git a/templates/generate.html b/templates/generate.html new file mode 100644 index 0000000..0b9ab10 --- /dev/null +++ b/templates/generate.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Generate Email - Café Bach Email Generator{% endblock %} + +{% block content %} +
+

Create Email Invitation

+ + + + {% if data %} +
+

Preview

+ +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/preview.html b/templates/preview.html new file mode 100644 index 0000000..42cae4b --- /dev/null +++ b/templates/preview.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Preview Email - Café Bach Email Generator{% endblock %} + +{% block content %} +
+

Email Preview

+ +
+ +
+ + +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..69ba1a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Café Bach Email Generator - Tests +""" \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..88f71f2 --- /dev/null +++ b/tests/test_app.py @@ -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 \ No newline at end of file diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 0000000..aaa3ab5 --- /dev/null +++ b/tests/test_forms.py @@ -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' \ No newline at end of file