10 Commits

Author SHA1 Message Date
Ryan Chen
918fc91604 yurt 2025-08-07 18:18:36 -04:00
Ryan Chen
8df728530e docs: update docker compose commands to force rebuild 2025-06-13 17:11:56 -04:00
Ryan Chen
5081785dae feat: add description field and migration support 2025-06-13 17:10:00 -04:00
Ryan Chen
1d30809507 feat: add Docker migration service 2025-06-13 17:08:34 -04:00
Ryan Chen
7c62dc8d08 feat: add description field for pet pictures 2025-06-13 17:07:22 -04:00
Ryan Chen
dd9cc42271 chore: update port to 54321 2025-06-13 17:01:00 -04:00
Ryan Chen
59e905162c feat: switch to Gunicorn for production 2025-06-13 16:58:30 -04:00
Ryan Chen
febb1b67f6 feat: add docker-compose configuration 2025-06-13 16:56:08 -04:00
Ryan Chen
57b1bb3ddd fix: specify package structure in pyproject.toml 2025-06-13 16:54:45 -04:00
Ryan Chen
d76640e546 fix: copy README.md in Dockerfile 2025-06-13 16:54:14 -04:00
33 changed files with 1270 additions and 246 deletions

View File

@@ -16,6 +16,7 @@ RUN pip install uv
COPY pyproject.toml . COPY pyproject.toml .
COPY main.py . COPY main.py .
COPY templates/ templates/ COPY templates/ templates/
COPY README.md .
# Create uploads directory # Create uploads directory
RUN mkdir -p static/uploads RUN mkdir -p static/uploads
@@ -29,9 +30,10 @@ RUN uv venv && \
ENV FLASK_APP=main.py ENV FLASK_APP=main.py
ENV FLASK_ENV=production ENV FLASK_ENV=production
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
ENV GUNICORN_CMD_ARGS="--workers=4 --bind=0.0.0.0:5000 --timeout=120"
# Expose port # Expose port
EXPOSE 5000 EXPOSE 5000
# Run the application # Run the application with Gunicorn
CMD ["python", "main.py"] CMD ["gunicorn", "main:app"]

View File

@@ -10,6 +10,7 @@ A web application to manage pet pictures from subscribers. Built with Flask, SQL
- Modern, responsive UI using Tailwind CSS - Modern, responsive UI using Tailwind CSS
- Click to view full-size images - Click to view full-size images
- Download original images - Download original images
- Add descriptions to pet pictures
## Setup ## Setup
@@ -29,8 +30,46 @@ python main.py
The application will be available at `http://localhost:5000` The application will be available at `http://localhost:5000`
### Database Migration
#### Local Migration
If you have an existing database and want to add the description field:
```bash
python migrate.py
```
#### Docker Migration
To run the migration in Docker:
```bash
docker compose --profile migrate up --build migrate
```
This will safely add the description column to your existing database without affecting existing data.
### Docker Deployment ### Docker Deployment
#### Using Docker Compose (Recommended)
1. Start the application:
```bash
docker compose up --build -d
```
2. Stop the application:
```bash
docker compose down
```
The application will be available at `http://localhost:54321`
#### Using Docker Directly
1. Build the Docker image: 1. Build the Docker image:
```bash ```bash
@@ -41,28 +80,30 @@ docker build -t pet-picture-queue .
```bash ```bash
docker run -d \ docker run -d \
-p 5000:5000 \ -p 54321:5000 \
-v $(pwd)/static/uploads:/app/static/uploads \ -v $(pwd)/static/uploads:/app/static/uploads \
-v $(pwd)/pet_pictures.db:/app/pet_pictures.db \ -v $(pwd)/pet_pictures.db:/app/pet_pictures.db \
--name pet-picture-queue \ --name pet-picture-queue \
pet-picture-queue pet-picture-queue
``` ```
The application will be available at `http://localhost:5000` The application will be available at `http://localhost:54321`
## Usage ## Usage
1. Visit the homepage to see all uploaded pet pictures 1. Visit the homepage to see all uploaded pet pictures
2. Click "Upload New" to add a new pet picture 2. Click "Upload New" to add a new pet picture
3. Fill in the subscriber name and select a picture 3. Fill in the subscriber name and select a picture
4. View uploaded pictures on the homepage 4. Add an optional description for the pet picture
5. Click on any picture to view it in full size 5. View uploaded pictures on the homepage
6. Use the "Download Original" button to download the original file 6. Click on any picture to view it in full size
7. Mark pictures as posted using the "Mark as Posted" button 7. Use the "Download Original" button to download the original file
8. Mark pictures as posted using the "Mark as Posted" button
## File Structure ## File Structure
- `main.py` - Main Flask application - `main.py` - Main Flask application
- `migrate.py` - Database migration script
- `templates/` - Jinja2 templates - `templates/` - Jinja2 templates
- `base.html` - Base template with common layout - `base.html` - Base template with common layout
- `index.html` - Homepage with picture grid - `index.html` - Homepage with picture grid
@@ -71,3 +112,4 @@ The application will be available at `http://localhost:5000`
- `pet_pictures.db` - SQLite database (created automatically) - `pet_pictures.db` - SQLite database (created automatically)
- `Dockerfile` - Docker configuration - `Dockerfile` - Docker configuration
- `.dockerignore` - Docker build exclusions - `.dockerignore` - Docker build exclusions
- `docker-compose.yml` - Docker Compose configuration

111
README_MIGRATION.md Normal file
View File

@@ -0,0 +1,111 @@
# Database Migration Guide
## Session Changes Migration
During this development session, the following database changes were made:
### Changes Applied
1. **Added `likes` column** - INTEGER DEFAULT 0 (for the cookie-based like system)
2. **Ensured `description` column exists** - TEXT (for pet picture descriptions)
3. **Verified `posted` column** - BOOLEAN DEFAULT 0 (for admin posting control)
### Migration Script
Run the migration script to update your database:
```bash
# Make sure you're in the project directory
cd /path/to/pet-picture-queue
# Activate virtual environment (if using one)
source .venv/bin/activate
# Run the migration
python migrate_session_changes.py
```
### What the Migration Does
The `migrate_session_changes.py` script:
-**Idempotent**: Safe to run multiple times
-**Backwards Compatible**: Won't break existing data
-**Verification**: Checks that migration completed successfully
-**Error Handling**: Proper error messages and rollback protection
-**Database Detection**: Automatically finds your database file
### Migration Output
You should see output like:
```
============================================================
Pet Picture Database Migration Script
Session Changes: Adding likes system and ensuring schema consistency
============================================================
Using database: pet_pictures.db
Starting database migration...
• Description column already exists
✓ Added likes column and initialized existing records
• Posted column already exists
✓ Cleaned up NULL values
Final table schema:
id INTEGER NOT NULL
filename TEXT NOT NULL
subscriber_name TEXT NOT NULL
description TEXT NULL
uploaded_at TIMESTAMP NOT NULL
posted BOOLEAN NULL DEFAULT 0
likes INTEGER NULL DEFAULT 0
Migration completed successfully!
Applied 1 migrations
Database contains X pet pictures
Migration completed at: 2025-01-XX...
============================================================
Verifying migration...
✅ Verification successful: All required columns present
🎉 Migration completed successfully!
============================================================
```
### Troubleshooting
**If migration fails:**
1. Check that the database file exists and is accessible
2. Ensure you have write permissions to the database file
3. Make sure no other process is using the database
4. Check the error message for specific issues
**If you need to start fresh:**
```bash
# Backup existing database (optional)
cp pet_pictures.db pet_pictures.db.backup
# Remove database and let app recreate it
rm pet_pictures.db
# Run the app - it will create a new database with correct schema
python main.py
```
### Schema After Migration
The final `pet_pictures` table schema:
| Column | Type | Constraints | Default | Description |
|--------|------|-------------|---------|-------------|
| id | INTEGER | PRIMARY KEY, AUTOINCREMENT | | Unique picture ID |
| filename | TEXT | NOT NULL | | Stored filename |
| subscriber_name | TEXT | NOT NULL | | Name of submitter |
| description | TEXT | | | Optional picture description |
| uploaded_at | TIMESTAMP | NOT NULL | | Upload timestamp |
| posted | BOOLEAN | | 0 | Admin approval status |
| likes | INTEGER | | 0 | Number of likes received |
### Files Created
- `migrate_session_changes.py` - Main migration script
- `README_MIGRATION.md` - This documentation
- `add_likes_column.py` - Individual likes column migration (deprecated, use main script)

45
app/__init__.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Pets of Powerwashing - A Flask application for managing pet picture submissions
"""
from flask import Flask
import os
def create_app():
"""Application factory pattern for Flask app creation"""
app = Flask(__name__)
# Import configuration
from app.config import Config
app.config.from_object(Config)
# Ensure upload directory exists
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# Setup logging
from app.utils.logging_config import setup_logging
setup_logging(app)
# Register error handlers
from app.utils.error_handlers import register_error_handlers
register_error_handlers(app)
# Initialize database
from app.models.database import init_db, close_db
with app.app_context():
init_db()
# Register database close function
app.teardown_appcontext(close_db)
# Register blueprints
from app.routes.main import main_bp
from app.routes.auth import auth_bp
from app.routes.pictures import pictures_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(pictures_bp)
return app

40
app/config.py Normal file
View File

@@ -0,0 +1,40 @@
"""
Configuration settings for the application
"""
import os
class Config:
"""Base configuration class"""
# Flask settings
SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(24)
# Upload settings
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'app/static/uploads'
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# Authentication settings
ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME') or 'admin'
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') or 'password123'
# Database settings
DATABASE_PATH = os.environ.get('DATABASE_PATH') or 'pet_pictures.db'
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
DATABASE_PATH = ':memory:'

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Models package

94
app/models/database.py Normal file
View File

@@ -0,0 +1,94 @@
"""
Database operations and models for pet pictures
"""
import sqlite3
from flask import current_app, g
def get_db():
"""Get database connection"""
if 'db' not in g:
g.db = sqlite3.connect("pet_pictures.db")
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
"""Close database connection"""
db = g.pop('db', None)
if db is not None:
db.close()
def init_db():
"""Initialize database with required tables"""
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS pet_pictures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
subscriber_name TEXT NOT NULL,
description TEXT,
uploaded_at TIMESTAMP NOT NULL,
posted BOOLEAN DEFAULT 0,
likes INTEGER DEFAULT 0
)
"""
)
db.commit()
class PetPicture:
"""Model for pet picture operations"""
@staticmethod
def get_all():
"""Get all pet pictures"""
db = get_db()
return db.execute(
"SELECT * FROM pet_pictures ORDER BY uploaded_at DESC"
).fetchall()
@staticmethod
def get_posted():
"""Get only posted pet pictures"""
db = get_db()
return db.execute(
"SELECT * FROM pet_pictures WHERE posted = 1 ORDER BY uploaded_at DESC"
).fetchall()
@staticmethod
def create(filename, subscriber_name, description, uploaded_at):
"""Create new pet picture record"""
db = get_db()
db.execute(
"INSERT INTO pet_pictures (filename, subscriber_name, description, uploaded_at) VALUES (?, ?, ?, ?)",
(filename, subscriber_name, description, uploaded_at)
)
db.commit()
@staticmethod
def mark_as_posted(picture_id):
"""Mark picture as posted"""
db = get_db()
db.execute("UPDATE pet_pictures SET posted = 1 WHERE id = ?", (picture_id,))
db.commit()
@staticmethod
def update_likes(picture_id, increment=True):
"""Update like count for a picture"""
db = get_db()
if increment:
db.execute("UPDATE pet_pictures SET likes = likes + 1 WHERE id = ?", (picture_id,))
else:
db.execute("UPDATE pet_pictures SET likes = likes - 1 WHERE id = ?", (picture_id,))
db.commit()
@staticmethod
def get_like_count(picture_id):
"""Get current like count for a picture"""
db = get_db()
result = db.execute("SELECT likes FROM pet_pictures WHERE id = ?", (picture_id,)).fetchone()
return result['likes'] if result else 0

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package

33
app/routes/auth.py Normal file
View File

@@ -0,0 +1,33 @@
"""
Authentication routes
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash
from app.utils.auth import check_credentials, login_user, logout_user
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Login page and authentication"""
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if check_credentials(username, password):
login_user()
flash('Successfully logged in!')
return redirect(url_for('main.index'))
else:
flash('Invalid username or password.')
return render_template('login.html')
@auth_bp.route('/logout')
def logout():
"""Logout user and redirect to main page"""
logout_user()
flash('You have been logged out.')
return redirect(url_for('main.index'))

30
app/routes/main.py Normal file
View File

@@ -0,0 +1,30 @@
"""
Main application routes
"""
from flask import Blueprint, render_template, request
from app.models.database import PetPicture
from app.utils.auth import is_authenticated
from app.utils.helpers import get_liked_pictures_from_cookie, add_liked_status_to_pictures
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""Main gallery page - shows all pictures for admins, posted only for public"""
# Get liked pictures from cookie
liked_list = get_liked_pictures_from_cookie(request)
# Get pictures based on authentication status
if is_authenticated():
pictures = PetPicture.get_all()
public_view = False
else:
pictures = PetPicture.get_posted()
public_view = True
# Add liked status to pictures
pictures_with_likes = add_liked_status_to_pictures(pictures, liked_list)
return render_template("index.html", pictures=pictures_with_likes, public_view=public_view)

81
app/routes/pictures.py Normal file
View File

@@ -0,0 +1,81 @@
"""
Picture management routes
"""
import os
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, make_response, jsonify, current_app
from werkzeug.utils import secure_filename
from app.models.database import PetPicture
from app.utils.auth import login_required
from app.utils.helpers import allowed_file, handle_like_action
pictures_bp = Blueprint('pictures', __name__)
@pictures_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload():
"""Upload new pet picture"""
if request.method == 'POST':
if 'picture' not in request.files:
flash('No file selected')
return redirect(request.url)
file = request.files['picture']
subscriber_name = request.form.get('subscriber_name')
description = request.form.get('description', '').strip()
if file.filename == '':
flash('No file selected')
return redirect(request.url)
if not subscriber_name:
flash('Subscriber name is required')
return redirect(request.url)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_')
filename = timestamp + filename
# Ensure upload directory exists
upload_path = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_path, exist_ok=True)
file.save(os.path.join(upload_path, filename))
# Save to database
PetPicture.create(filename, subscriber_name, description, datetime.now())
flash('Picture uploaded successfully!')
return redirect(url_for('main.index'))
flash('Invalid file type')
return redirect(request.url)
return render_template('upload.html')
@pictures_bp.route('/mark_posted/<int:picture_id>', methods=['POST'])
@login_required
def mark_posted(picture_id):
"""Mark picture as posted"""
PetPicture.mark_as_posted(picture_id)
flash('Picture marked as posted!')
return redirect(url_for('main.index'))
@pictures_bp.route('/like/<int:picture_id>', methods=['POST'])
def like_picture(picture_id):
"""Handle like/unlike actions"""
return handle_like_action(picture_id, request)
@pictures_bp.route('/download/<filename>')
def download_file(filename):
"""Download original picture file"""
return send_from_directory(
current_app.config['UPLOAD_FOLDER'], filename, as_attachment=True
)

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

134
app/templates/base.html Normal file
View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Pets of Powerwashing{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
rel="stylesheet"
/>
<style>
.modal {
display: none;
}
.modal.active {
display: flex;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white">
<div class="max-w-6xl mx-auto px-4">
<div class="flex justify-between items-center py-4">
<div class="text-xl font-semibold text-gray-800">
<a href="{{ url_for('main.index') }}">Pets of Powerwashing</a>
</div>
<div class="space-x-4">
<a
href="{{ url_for('main.index') }}"
class="text-gray-600 hover:text-gray-800"
>Gallery</a
>
{% if session.logged_in %}
<a
href="{{ url_for('pictures.upload') }}"
class="text-gray-600 hover:text-gray-800"
>Upload New</a
>
<a
href="{{ url_for('auth.logout') }}"
class="text-gray-600 hover:text-gray-800"
>Logout</a
>
{% else %}
<a
href="{{ url_for('auth.login') }}"
class="text-gray-600 hover:text-gray-800"
>Login</a
>
{% endif %}
</div>
</div>
</div>
</nav>
<main class="max-w-6xl mx-auto px-4 py-8">
{% with messages = get_flashed_messages() %} {% if messages %} {% for
message in messages %}
<div
class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4"
role="alert"
>
{{ message }}
</div>
{% endfor %} {% endif %} {% endwith %} {% block content %}{% endblock %}
</main>
<!-- Modal for fullscreen image view -->
<div id="imageModal" class="modal fixed inset-0 bg-black bg-opacity-75 items-center justify-center z-50" onclick="closeModal()">
<div class="relative max-w-full max-h-full p-4" onclick="event.stopPropagation()">
<button onclick="closeModal()" class="absolute top-4 right-4 text-white text-4xl hover:text-gray-300 z-10">&times;</button>
<img id="modalImage" src="" alt="" class="max-w-full max-h-full object-contain">
<div id="modalInfo" class="absolute bottom-4 left-4 bg-black bg-opacity-50 text-white p-3 rounded">
<h3 id="modalTitle" class="text-lg font-semibold"></h3>
<p id="modalDescription" class="text-sm"></p>
<p id="modalDate" class="text-xs opacity-75"></p>
</div>
</div>
</div>
<script>
function openModal(imageSrc, title, description, date) {
document.getElementById('modalImage').src = imageSrc;
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalDescription').textContent = description;
document.getElementById('modalDate').textContent = date;
document.getElementById('imageModal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('imageModal').classList.remove('active');
document.body.style.overflow = 'auto';
}
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
}
});
// Like functionality
function likePicture(pictureId) {
fetch('/like/' + pictureId, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
const likeBtn = document.getElementById('like-btn-' + pictureId);
const likeCount = document.getElementById('like-count-' + pictureId);
// Update like count
likeCount.textContent = data.like_count;
// Update button appearance and heart
if (data.liked) {
likeBtn.className = 'flex-1 flex items-center justify-center px-4 py-2 rounded transition-colors bg-red-500 text-white hover:bg-red-600';
likeBtn.querySelector('span').textContent = '❤️';
} else {
likeBtn.className = 'flex-1 flex items-center justify-center px-4 py-2 rounded transition-colors bg-gray-200 text-gray-700 hover:bg-gray-300';
likeBtn.querySelector('span').textContent = '🤍';
}
})
.catch(error => {
console.error('Error:', error);
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Page Not Found - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="text-center py-12">
<h1 class="text-6xl font-bold text-gray-400 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-gray-800 mb-4">Page Not Found</h2>
<p class="text-gray-600 mb-6">Sorry, the page you're looking for doesn't exist.</p>
<a href="{{ url_for('main.index') }}" class="bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600 transition-colors">
Back to Gallery
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}File Too Large - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="text-center py-12">
<h1 class="text-6xl font-bold text-gray-400 mb-4">413</h1>
<h2 class="text-2xl font-semibold text-gray-800 mb-4">File Too Large</h2>
<p class="text-gray-600 mb-6">The file you're trying to upload is too large. Please choose a smaller file.</p>
<a href="{{ url_for('pictures.upload') }}" class="bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600 transition-colors">
Try Again
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Server Error - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="text-center py-12">
<h1 class="text-6xl font-bold text-gray-400 mb-4">500</h1>
<h2 class="text-2xl font-semibold text-gray-800 mb-4">Internal Server Error</h2>
<p class="text-gray-600 mb-6">Something went wrong on our end. Please try again later.</p>
<a href="{{ url_for('main.index') }}" class="bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600 transition-colors">
Back to Gallery
</a>
</div>
{% endblock %}

92
app/templates/index.html Normal file
View File

@@ -0,0 +1,92 @@
{% extends "base.html" %} {% block title %}Pet Pictures - Pets of Powerwashing{%
endblock %} {% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for picture in pictures %}
<div
class="bg-white rounded-lg shadow-md overflow-hidden {% if picture.posted and not public_view %}opacity-50{% endif %}"
>
<div class="relative group">
<a
href="javascript:void(0)"
onclick="openModal('{{ url_for('static', filename='uploads/' + picture.filename) }}', 'From: {{ picture.subscriber_name }}', '{{ picture.description or '' }}', 'Uploaded: {{ picture.uploaded_at }}')"
class="block transform transition-transform hover:scale-105"
>
<img
src="{{ url_for('static', filename='uploads/' + picture.filename) }}"
alt="Pet picture from {{ picture.subscriber_name }}"
class="w-full h-64 object-cover transition-transform duration-300"
/>
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300 flex items-center justify-center"
>
<span
class="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
Click to view fullscreen
</span>
</div>
</a>
</div>
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-800">
From: {{ picture.subscriber_name }}
</h3>
{% if picture.description %}
<p class="mt-2 text-gray-600">{{ picture.description }}</p>
{% endif %}
<p class="text-sm text-gray-600">Uploaded: {{ picture.uploaded_at }}</p>
<div class="mt-4 space-y-2">
<div class="flex space-x-2 mb-2">
<button
onclick="likePicture({{ picture.id }})"
id="like-btn-{{ picture.id }}"
class="flex-1 flex items-center justify-center px-4 py-2 rounded transition-colors {% if picture.user_liked %}bg-red-500 text-white hover:bg-red-600{% else %}bg-gray-200 text-gray-700 hover:bg-gray-300{% endif %}"
>
<span class="mr-1">{% if picture.user_liked %}❤️{% else %}🤍{% endif %}</span>
<span id="like-count-{{ picture.id }}">{{ picture.likes or 0 }}</span>
</button>
</div>
<a
href="{{ url_for('pictures.download_file', filename=picture.filename) }}"
class="block w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors text-center"
>
Download Original
</a>
{% if not public_view %}
{% if not picture.posted %}
<form
action="{{ url_for('pictures.mark_posted', picture_id=picture.id) }}"
method="POST"
>
<button
type="submit"
class="w-full bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
>
Mark as Posted
</button>
</form>
{% else %}
<div class="text-center text-green-600 font-semibold">✓ Posted</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-span-full text-center py-12">
{% if public_view %}
<p class="text-gray-600 text-lg">No published pet pictures yet.</p>
<p class="text-gray-500 text-sm mt-2">Pictures will appear here once they are marked as posted by an admin.</p>
{% else %}
<p class="text-gray-600 text-lg">No pet pictures uploaded yet.</p>
<a
href="{{ url_for('pictures.upload') }}"
class="mt-4 inline-block bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 transition-colors"
>
Upload Your First Picture
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}

50
app/templates/login.html Normal file
View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Login - Pets of Powerwashing{% endblock %}
{% block content %}
<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6 text-center">Admin Login</h2>
<form method="POST" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
id="username"
name="username"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter username"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter password"
/>
</div>
<button
type="submit"
class="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Login
</button>
</form>
<div class="mt-6 text-center">
<a href="{{ url_for('main.index') }}" class="text-blue-500 hover:text-blue-600 text-sm">
Back to Gallery
</a>
</div>
</div>
{% endblock %}

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %} {% block title %}Upload Pet Picture - Pet Picture {% extends "base.html" %} {% block title %}Upload Pet Picture - Pets of
Queue{% endblock %} {% block content %} Powerwashing{% endblock %} {% block content %}
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-6"> <div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6">Upload Pet Picture</h2> <h2 class="text-2xl font-bold text-gray-800 mb-6">Upload Pet Picture</h2>
<form <form
action="{{ url_for('upload') }}" action="{{ url_for('pictures.upload') }}"
method="POST" method="POST"
enctype="multipart/form-data" enctype="multipart/form-data"
class="space-y-6" class="space-y-6"
@@ -25,6 +25,19 @@ Queue{% endblock %} {% block content %}
/> />
</div> </div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700"
>Description (Optional)</label
>
<textarea
name="description"
id="description"
rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="Add a description for the pet picture..."
></textarea>
</div>
<div> <div>
<label for="picture" class="block text-sm font-medium text-gray-700" <label for="picture" class="block text-sm font-medium text-gray-700"
>Pet Picture</label >Pet Picture</label

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Utils package

38
app/utils/auth.py Normal file
View File

@@ -0,0 +1,38 @@
"""
Authentication utilities and decorators
"""
from functools import wraps
from flask import session, flash, redirect, url_for, current_app
def login_required(f):
"""Decorator to require login for route access"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'logged_in' not in session:
flash('Please log in to access this page.')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
def check_credentials(username, password):
"""Check if provided credentials are valid"""
return (username == current_app.config['ADMIN_USERNAME'] and
password == current_app.config['ADMIN_PASSWORD'])
def login_user():
"""Log in the user by setting session"""
session['logged_in'] = True
def logout_user():
"""Log out the user by clearing session"""
session.pop('logged_in', None)
def is_authenticated():
"""Check if current user is authenticated"""
return 'logged_in' in session

View File

@@ -0,0 +1,24 @@
"""
Error handlers for the application
"""
from flask import render_template, current_app
def register_error_handlers(app):
"""Register error handlers with the app"""
@app.errorhandler(404)
def not_found_error(error):
current_app.logger.error(f'404 error: {error}')
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
current_app.logger.error(f'500 error: {error}')
return render_template('errors/500.html'), 500
@app.errorhandler(413)
def too_large(error):
current_app.logger.error(f'413 error - File too large: {error}')
return render_template('errors/413.html'), 413

60
app/utils/helpers.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Utility helper functions
"""
from flask import current_app, make_response, jsonify
from app.models.database import PetPicture
def allowed_file(filename):
"""Check if file extension is allowed"""
return ('.' in filename and
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'])
def get_liked_pictures_from_cookie(request):
"""Extract liked picture IDs from cookie"""
liked_pictures = request.cookies.get('liked_pictures', '')
return liked_pictures.split(',') if liked_pictures else []
def add_liked_status_to_pictures(pictures, liked_list):
"""Add user_liked status to picture records"""
pictures_with_likes = []
for picture in pictures:
picture_dict = dict(picture)
picture_dict['user_liked'] = str(picture['id']) in liked_list
pictures_with_likes.append(picture_dict)
return pictures_with_likes
def handle_like_action(picture_id, request):
"""Handle like/unlike action and return JSON response"""
# Get existing likes from cookie
liked_list = get_liked_pictures_from_cookie(request)
picture_id_str = str(picture_id)
if picture_id_str in liked_list:
# Unlike: remove from cookie and decrement count
liked_list.remove(picture_id_str)
PetPicture.update_likes(picture_id, increment=False)
liked = False
else:
# Like: add to cookie and increment count
liked_list.append(picture_id_str)
PetPicture.update_likes(picture_id, increment=True)
liked = True
# Get updated like count
like_count = PetPicture.get_like_count(picture_id)
# Create response with updated cookie
response = make_response(jsonify({
'liked': liked,
'like_count': like_count
}))
# Update cookie (expires in 1 year)
response.set_cookie('liked_pictures', ','.join(liked_list), max_age=365*24*60*60)
return response

View File

@@ -0,0 +1,32 @@
"""
Logging configuration
"""
import logging
import os
from logging.handlers import RotatingFileHandler
def setup_logging(app):
"""Setup application logging"""
if not app.debug and not app.testing:
# Production logging setup
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler(
'logs/pets_powerwashing.log',
maxBytes=10240,
backupCount=10
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Pets of Powerwashing startup')
else:
# Development logging
app.logger.setLevel(logging.DEBUG)

41
docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: "3.8"
services:
web:
build: .
ports:
- "54321:5000"
volumes:
- ./static/uploads:/app/static/uploads
- ./pet_pictures.db:/app/pet_pictures.db
environment:
- FLASK_APP=main.py
- FLASK_ENV=production
- GUNICORN_CMD_ARGS=--workers=4 --bind=0.0.0.0:5000 --timeout=120 --keep-alive=5 --worker-class=sync --worker-connections=1000 --max-requests=1000 --max-requests-jitter=50
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"curl",
"-f",
"http://localhost:5000/",
"--connect-timeout",
"5",
"--retry",
"3",
"--retry-delay",
"5",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
migrate:
build: .
volumes:
- ./pet_pictures.db:/app/pet_pictures.db
command: python migrate.py
profiles:
- migrate

View File

@@ -0,0 +1 @@
2025-08-07 18:12:42,230 INFO: Pets of Powerwashing startup [in /Users/ryanchen/Programs/pet-picture-queue/app/utils/logging_config.py:29]

130
main.py
View File

@@ -1,120 +1,18 @@
#!/usr/bin/env python3
"""
Pets of Powerwashing - Main application entry point
A Flask application for managing pet picture submissions with authentication,
likes system, and public gallery functionality.
"""
import os import os
import sqlite3 from app import create_app
from datetime import datetime
from flask import (
Flask,
render_template,
request,
redirect,
url_for,
flash,
send_from_directory,
)
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.secret_key = os.urandom(24)
# Configure upload folder
UPLOAD_FOLDER = "static/uploads"
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
# Ensure upload directory exists
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
def get_db():
db = sqlite3.connect("pet_pictures.db")
db.row_factory = sqlite3.Row
return db
def init_db():
with get_db() as db:
db.execute(
"""
CREATE TABLE IF NOT EXISTS pet_pictures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
subscriber_name TEXT NOT NULL,
uploaded_at TIMESTAMP NOT NULL,
posted BOOLEAN DEFAULT 0
)
"""
)
db.commit()
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route("/")
def index():
with get_db() as db:
pictures = db.execute(
"SELECT * FROM pet_pictures ORDER BY uploaded_at DESC"
).fetchall()
return render_template("index.html", pictures=pictures)
@app.route("/upload", methods=["GET", "POST"])
def upload():
if request.method == "POST":
if "picture" not in request.files:
flash("No file selected")
return redirect(request.url)
file = request.files["picture"]
subscriber_name = request.form.get("subscriber_name")
if file.filename == "":
flash("No file selected")
return redirect(request.url)
if not subscriber_name:
flash("Subscriber name is required")
return redirect(request.url)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_")
filename = timestamp + filename
file.save(os.path.join(app.config["UPLOAD_FOLDER"], filename))
with get_db() as db:
db.execute(
"INSERT INTO pet_pictures (filename, subscriber_name, uploaded_at) VALUES (?, ?, ?)",
(filename, subscriber_name, datetime.now()),
)
db.commit()
flash("Picture uploaded successfully!")
return redirect(url_for("index"))
flash("Invalid file type")
return redirect(request.url)
return render_template("upload.html")
@app.route("/mark_posted/<int:picture_id>", methods=["POST"])
def mark_posted(picture_id):
with get_db() as db:
db.execute("UPDATE pet_pictures SET posted = 1 WHERE id = ?", (picture_id,))
db.commit()
flash("Picture marked as posted!")
return redirect(url_for("index"))
@app.route("/download/<filename>")
def download_file(filename):
return send_from_directory(
app.config["UPLOAD_FOLDER"], filename, as_attachment=True
)
# Create Flask application using factory pattern
app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
init_db() # Development server configuration
app.run(debug=True) debug_mode = os.environ.get('FLASK_ENV') == 'development'
app.run(debug=debug_mode, host='0.0.0.0', port=5000)

33
migrate.py Normal file
View File

@@ -0,0 +1,33 @@
import sqlite3
import sys
def migrate_database():
try:
# Connect to the database
db = sqlite3.connect("pet_pictures.db")
cursor = db.cursor()
# Check if description column exists
cursor.execute("PRAGMA table_info(pet_pictures)")
columns = [column[1] for column in cursor.fetchall()]
if "description" not in columns:
print("Adding description column to pet_pictures table...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN description TEXT")
db.commit()
print("Migration completed successfully!")
else:
print("Description column already exists. No migration needed.")
except sqlite3.Error as e:
print(f"An error occurred: {e}")
sys.exit(1)
finally:
if db:
db.close()
if __name__ == "__main__":
print("Starting database migration...")
migrate_database()

206
migrate_session_changes.py Executable file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Database migration script for session changes
Handles all database schema changes made during this session:
1. Adding likes column (if not exists)
2. Ensuring description column exists
3. Updating any existing records to have default values
This script is idempotent - can be run multiple times safely.
"""
import sqlite3
import sys
import os
from datetime import datetime
def get_database_path():
"""Get the correct database path"""
if os.path.exists("pet_pictures.db"):
return "pet_pictures.db"
elif os.path.exists("app/pet_pictures.db"):
return "app/pet_pictures.db"
else:
return "pet_pictures.db" # Default path
def check_column_exists(cursor, table_name, column_name):
"""Check if a column exists in a table"""
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [column[1] for column in cursor.fetchall()]
return column_name in columns
def migrate_database():
"""Main migration function"""
db_path = get_database_path()
print(f"Using database: {db_path}")
try:
# Connect to the database
db = sqlite3.connect(db_path)
cursor = db.cursor()
print("Starting database migration...")
# Check if pet_pictures table exists
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='pet_pictures'
""")
if not cursor.fetchone():
print("pet_pictures table doesn't exist. Creating it...")
cursor.execute("""
CREATE TABLE pet_pictures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
subscriber_name TEXT NOT NULL,
description TEXT,
uploaded_at TIMESTAMP NOT NULL,
posted BOOLEAN DEFAULT 0,
likes INTEGER DEFAULT 0
)
""")
db.commit()
print("✓ Created pet_pictures table with all columns")
return
migrations_applied = 0
# Migration 1: Add description column if it doesn't exist
if not check_column_exists(cursor, 'pet_pictures', 'description'):
print("Adding description column...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN description TEXT")
db.commit()
print("✓ Added description column")
migrations_applied += 1
else:
print("• Description column already exists")
# Migration 2: Add likes column if it doesn't exist
if not check_column_exists(cursor, 'pet_pictures', 'likes'):
print("Adding likes column...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN likes INTEGER DEFAULT 0")
# Update existing records to have 0 likes
cursor.execute("UPDATE pet_pictures SET likes = 0 WHERE likes IS NULL")
db.commit()
print("✓ Added likes column and initialized existing records")
migrations_applied += 1
else:
print("• Likes column already exists")
# Migration 3: Ensure posted column exists and has correct type
if not check_column_exists(cursor, 'pet_pictures', 'posted'):
print("Adding posted column...")
cursor.execute("ALTER TABLE pet_pictures ADD COLUMN posted BOOLEAN DEFAULT 0")
db.commit()
print("✓ Added posted column")
migrations_applied += 1
else:
print("• Posted column already exists")
# Migration 4: Clean up any NULL values in critical columns
print("Cleaning up NULL values...")
cursor.execute("""
UPDATE pet_pictures
SET likes = 0
WHERE likes IS NULL
""")
cursor.execute("""
UPDATE pet_pictures
SET posted = 0
WHERE posted IS NULL
""")
cursor.execute("""
UPDATE pet_pictures
SET description = ''
WHERE description IS NULL
""")
db.commit()
print("✓ Cleaned up NULL values")
# Verify final schema
cursor.execute("PRAGMA table_info(pet_pictures)")
columns = cursor.fetchall()
print("\nFinal table schema:")
for column in columns:
print(f" {column[1]} {column[2]} {'NOT NULL' if column[3] else 'NULL'} {f'DEFAULT {column[4]}' if column[4] else ''}")
# Get record count
cursor.execute("SELECT COUNT(*) FROM pet_pictures")
record_count = cursor.fetchone()[0]
print(f"\nMigration completed successfully!")
print(f"Applied {migrations_applied} migrations")
print(f"Database contains {record_count} pet pictures")
# Log migration
print(f"Migration completed at: {datetime.now().isoformat()}")
except sqlite3.Error as e:
print(f"❌ Database error occurred: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ An unexpected error occurred: {e}")
sys.exit(1)
finally:
if 'db' in locals():
db.close()
def verify_migration():
"""Verify that the migration was successful"""
db_path = get_database_path()
try:
db = sqlite3.connect(db_path)
cursor = db.cursor()
# Check all required columns exist
required_columns = ['id', 'filename', 'subscriber_name', 'description', 'uploaded_at', 'posted', 'likes']
cursor.execute("PRAGMA table_info(pet_pictures)")
existing_columns = [column[1] for column in cursor.fetchall()]
missing_columns = set(required_columns) - set(existing_columns)
if missing_columns:
print(f"❌ Verification failed: Missing columns: {missing_columns}")
return False
print("✅ Verification successful: All required columns present")
return True
except sqlite3.Error as e:
print(f"❌ Verification failed with database error: {e}")
return False
finally:
if 'db' in locals():
db.close()
if __name__ == "__main__":
print("=" * 60)
print("Pet Picture Database Migration Script")
print("Session Changes: Adding likes system and ensuring schema consistency")
print("=" * 60)
# Run migration
migrate_database()
print("\n" + "=" * 60)
print("Verifying migration...")
# Verify migration
if verify_migration():
print("🎉 Migration completed successfully!")
else:
print("⚠️ Migration verification failed")
sys.exit(1)
print("=" * 60)

View File

@@ -10,8 +10,12 @@ dependencies = [
"flask>=3.0.0", "flask>=3.0.0",
"werkzeug>=3.0.0", "werkzeug>=3.0.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"gunicorn>=21.2.0",
] ]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]

View File

@@ -1,47 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Pet Picture Queue{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
rel="stylesheet"
/>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-lg">
<div class="max-w-6xl mx-auto px-4">
<div class="flex justify-between items-center py-4">
<div class="text-xl font-semibold text-gray-800">
<a href="{{ url_for('index') }}">Pet Picture Queue</a>
</div>
<div class="space-x-4">
<a
href="{{ url_for('index') }}"
class="text-gray-600 hover:text-gray-800"
>View Pictures</a
>
<a
href="{{ url_for('upload') }}"
class="text-gray-600 hover:text-gray-800"
>Upload New</a
>
</div>
</div>
</div>
</nav>
<main class="max-w-6xl mx-auto px-4 py-8">
{% with messages = get_flashed_messages() %} {% if messages %} {% for
message in messages %}
<div
class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4"
role="alert"
>
{{ message }}
</div>
{% endfor %} {% endif %} {% endwith %} {% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -1,72 +0,0 @@
{% extends "base.html" %} {% block title %}Pet Pictures - Pet Picture Queue{%
endblock %} {% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for picture in pictures %}
<div
class="bg-white rounded-lg shadow-md overflow-hidden {% if picture.posted %}opacity-50{% endif %}"
>
<div class="relative group">
<a
href="{{ url_for('static', filename='uploads/' + picture.filename) }}"
target="_blank"
class="block"
>
<img
src="{{ url_for('static', filename='uploads/' + picture.filename) }}"
alt="Pet picture from {{ picture.subscriber_name }}"
class="w-full h-64 object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300 flex items-center justify-center"
>
<span
class="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
Click to view
</span>
</div>
</a>
</div>
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-800">
From: {{ picture.subscriber_name }}
</h3>
<p class="text-sm text-gray-600">Uploaded: {{ picture.uploaded_at }}</p>
<div class="mt-4 space-y-2">
<a
href="{{ url_for('download_file', filename=picture.filename) }}"
class="block w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors text-center"
>
Download Original
</a>
{% if not picture.posted %}
<form
action="{{ url_for('mark_posted', picture_id=picture.id) }}"
method="POST"
>
<button
type="submit"
class="w-full bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
>
Mark as Posted
</button>
</form>
{% else %}
<div class="text-center text-green-600 font-semibold">✓ Posted</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-span-full text-center py-12">
<p class="text-gray-600 text-lg">No pet pictures uploaded yet.</p>
<a
href="{{ url_for('upload') }}"
class="mt-4 inline-block bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 transition-colors"
>
Upload Your First Picture
</a>
</div>
{% endfor %}
</div>
{% endblock %}