Compare commits
10 Commits
c72cd4aa4e
...
5ef0dca53b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef0dca53b | ||
|
|
cd0c11dec3 | ||
|
|
f7feb18de3 | ||
|
|
43bec07510 | ||
|
|
4b06f03753 | ||
|
|
247d35f46a | ||
|
|
ef3ecd3f8f | ||
|
|
ad0840d4ed | ||
|
|
3551286aca | ||
|
|
85e387f99a |
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
*.pyc
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
Caddyfile
|
||||||
|
deploy.sh
|
||||||
|
.vscode/
|
||||||
|
.ruff_cache/
|
||||||
20
Dockerfile
20
Dockerfile
@@ -1,20 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
# Create a non-root user for security
|
|
||||||
RUN groupadd -g 1000 flaskuser && useradd -u 1000 -g flaskuser flaskuser
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
|
||||||
|
|
||||||
# Copy the rest of the code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Change ownership to our non-root user
|
|
||||||
RUN chown -R flaskuser:flaskuser /app
|
|
||||||
USER flaskuser
|
|
||||||
|
|
||||||
# Run with Gunicorn (replaces uwsgi)
|
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--workers", "4"]
|
|
||||||
84
app.py
84
app.py
@@ -1,6 +1,6 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for
|
from flask import Flask, render_template, request, redirect, url_for
|
||||||
from content.posts import BLOG_POSTS
|
from content.posts import BLOG_POSTS
|
||||||
from content.logic import get_enriched_post, get_comments_for_post, save_comment
|
from flask_logic.logic import get_enriched_post, get_comments_for_post, save_comment
|
||||||
|
|
||||||
# 🌟 IMPORT THE content from separate files.
|
# 🌟 IMPORT THE content from separate files.
|
||||||
from content.posts import BLOG_POSTS
|
from content.posts import BLOG_POSTS
|
||||||
@@ -16,15 +16,6 @@ COMMENT_FILE = 'content/comments.csv'
|
|||||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5 Megabytes
|
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5 Megabytes
|
||||||
POSTS_PER_PAGE = 5 # posts per page limit here
|
POSTS_PER_PAGE = 5 # posts per page limit here
|
||||||
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
from flask import send_from_directory
|
|
||||||
|
|
||||||
@app.route('/content/images/<path:filename>')
|
|
||||||
def custom_static(filename):
|
|
||||||
# This serves files from your private submodule folder
|
|
||||||
return send_from_directory('content/images', filename)
|
|
||||||
"""
|
|
||||||
|
|
||||||
@app.route('/post/<int:post_id>/comment', methods=['POST'])
|
@app.route('/post/<int:post_id>/comment', methods=['POST'])
|
||||||
def post_comment(post_id):
|
def post_comment(post_id):
|
||||||
@@ -43,35 +34,66 @@ def post_comment(post_id):
|
|||||||
# --- Routes ---
|
# --- Routes ---
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_pagination(posts, posts_per_page, page):
|
||||||
|
"""
|
||||||
|
Calculate pagination parameters for blog posts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
posts (list): Full list of blog posts
|
||||||
|
posts_per_page (int): Number of posts per page
|
||||||
|
page (int): Current page number (1-indexed)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Pagination data including:
|
||||||
|
- posts_to_show: Posts for current page
|
||||||
|
- prev_url: Previous page URL or None
|
||||||
|
- next_url: Next page URL or None
|
||||||
|
- current_page: Current page number
|
||||||
|
- total_pages: Total number of pages
|
||||||
|
- total_posts: Total number of posts
|
||||||
|
"""
|
||||||
|
# Sort posts by ID newest first
|
||||||
|
sorted_posts = sorted(posts, key=lambda x: x['id'], reverse=True)
|
||||||
|
|
||||||
|
total_posts = len(sorted_posts)
|
||||||
|
total_pages = math.ceil(total_posts / posts_per_page)
|
||||||
|
|
||||||
|
# Clamp page to valid range
|
||||||
|
page = max(1, min(page, total_pages))
|
||||||
|
|
||||||
|
start = (page - 1) * posts_per_page
|
||||||
|
end = start + posts_per_page
|
||||||
|
|
||||||
|
posts_to_show = sorted_posts[start:end]
|
||||||
|
|
||||||
|
prev_url = url_for('home', page=page - 1) if page > 1 else None
|
||||||
|
next_url = url_for('home', page=page + 1) if end < total_posts else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'posts_to_show': posts_to_show,
|
||||||
|
'prev_url': prev_url,
|
||||||
|
'next_url': next_url,
|
||||||
|
'current_page': page,
|
||||||
|
'total_pages': total_pages,
|
||||||
|
'total_posts': total_posts
|
||||||
|
}
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def home():
|
def home():
|
||||||
|
"""Home page with paginated blog posts."""
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
blog_title = TITLE
|
blog_title = TITLE
|
||||||
|
|
||||||
# 2. Sort posts by ID newest first
|
pagination = calculate_pagination(BLOG_POSTS, POSTS_PER_PAGE, page)
|
||||||
all_posts = sorted(BLOG_POSTS, key=lambda x: x['id'], reverse=True)
|
|
||||||
# Calculate totals
|
|
||||||
total_posts = len(all_posts)
|
|
||||||
total_pages = math.ceil(total_posts / POSTS_PER_PAGE)
|
|
||||||
# 3. Calculate start and end indices
|
|
||||||
start = (page - 1) * POSTS_PER_PAGE
|
|
||||||
end = start + POSTS_PER_PAGE
|
|
||||||
|
|
||||||
# 4. Slice the list for the current page
|
|
||||||
posts_to_show = all_posts[start:end]
|
|
||||||
|
|
||||||
# 5. Determine if there is a next or previous page
|
|
||||||
prev_url = url_for('home', page=page - 1) if page > 1 else None
|
|
||||||
next_url = url_for('home', page=page + 1) if end < len(all_posts) else None
|
|
||||||
|
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
posts=posts_to_show,
|
posts=pagination['posts_to_show'],
|
||||||
prev_url=prev_url,
|
prev_url=pagination['prev_url'],
|
||||||
next_url=next_url,
|
next_url=pagination['next_url'],
|
||||||
current_page=page,
|
current_page=pagination['current_page'],
|
||||||
|
total_pages=pagination['total_pages'],
|
||||||
blog_title=blog_title,
|
blog_title=blog_title,
|
||||||
total_pages=total_pages)
|
total_posts=pagination['total_posts'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
content
2
content
Submodule content updated: 182725bce2...d0e3035242
@@ -1,35 +0,0 @@
|
|||||||
services:
|
|
||||||
flask_app:
|
|
||||||
build: .
|
|
||||||
container_name: flask_blog
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- ./data:/app/data # Persist your CSV files
|
|
||||||
networks:
|
|
||||||
- web_net
|
|
||||||
|
|
||||||
caddy:
|
|
||||||
image: caddy:latest
|
|
||||||
container_name: caddy
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
- "443:443/udp"
|
|
||||||
volumes:
|
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
|
||||||
- caddy_data:/data
|
|
||||||
- caddy_config:/config
|
|
||||||
networks:
|
|
||||||
- web_net
|
|
||||||
depends_on:
|
|
||||||
- flask_app
|
|
||||||
|
|
||||||
networks:
|
|
||||||
web_net:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
caddy_data:
|
|
||||||
caddy_config:
|
|
||||||
93
flask_logic/logic.py
Normal file
93
flask_logic/logic.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import os
|
||||||
|
import csv
|
||||||
|
|
||||||
|
COMMENT_FILE = os.path.join(os.path.dirname(__file__), '../content/comments.csv')
|
||||||
|
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5MB example
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
def save_comment(post_id, author, content):
|
||||||
|
"""Handles the validation and saving of comments to the private CSV."""
|
||||||
|
# 1. Validation logic moved here
|
||||||
|
if not content or len(content) > 1000:
|
||||||
|
return False
|
||||||
|
|
||||||
|
author = (author or "Anonymous").strip()[:50]
|
||||||
|
content = content.strip()
|
||||||
|
|
||||||
|
# 2. Path & Size Checks
|
||||||
|
file_exists = os.path.isfile(COMMENT_FILE)
|
||||||
|
if file_exists and os.path.getsize(COMMENT_FILE) > MAX_FILE_SIZE_BYTES:
|
||||||
|
return False # Or raise a specific error
|
||||||
|
|
||||||
|
# 3. Write to CSV
|
||||||
|
with open(COMMENT_FILE, 'a', newline='', encoding='utf-8') as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=['post_id', 'author', 'content', 'date'])
|
||||||
|
if not file_exists:
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
writer.writerow({
|
||||||
|
'post_id': post_id,
|
||||||
|
'author': author,
|
||||||
|
'content': content,
|
||||||
|
'date': datetime.datetime.now().strftime("%B %d, %Y at %I:%M %p")
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def load_snippet(filename):
|
||||||
|
"""Helper to read HTML snippets from the data folder."""
|
||||||
|
base_path = os.path.join(os.path.dirname(__file__), 'data')
|
||||||
|
file_path = os.path.join(base_path, filename)
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_enriched_post(post_id, BLOG_POSTS):
|
||||||
|
post = next((p for p in BLOG_POSTS if p.get('id') == post_id), None)
|
||||||
|
if not post:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mapping config: keeps logic.py tiny
|
||||||
|
configs = {
|
||||||
|
10: {
|
||||||
|
"template": "components/timeline.html",
|
||||||
|
"timeline_file": "post_10_timeline.html"
|
||||||
|
},
|
||||||
|
8: {
|
||||||
|
"template": "components/christmas_post.html",
|
||||||
|
"timeline": None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if post_id in configs:
|
||||||
|
conf = configs[post_id]
|
||||||
|
post_template = conf.get("template")
|
||||||
|
if post_template:
|
||||||
|
post["template"] = post_template
|
||||||
|
|
||||||
|
# Only load the file if a filename is provided
|
||||||
|
t_file = conf.get("timeline_file")
|
||||||
|
if t_file:
|
||||||
|
post["timeline"] = load_snippet(t_file)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
def get_comments_for_post(post_id):
|
||||||
|
comments = []
|
||||||
|
if not os.path.exists(COMMENT_FILE):
|
||||||
|
return comments
|
||||||
|
|
||||||
|
if os.path.getsize(COMMENT_FILE) > MAX_FILE_SIZE_BYTES:
|
||||||
|
return ["Comment section full."] # Handle error in app.py
|
||||||
|
|
||||||
|
with open(COMMENT_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
if row['post_id'] == str(post_id):
|
||||||
|
comments.append(row)
|
||||||
|
return comments
|
||||||
87
readme.md
Normal file
87
readme.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Flask Blog Templates
|
||||||
|
|
||||||
|
A minimal **Flask template set** for my blog.
|
||||||
|
This project adapts and evolves the original [simple-blog-template](https://github.com/earlbread/simple-blog-template) by [Seunghun Lee](https://github.com/earlbread).
|
||||||
|
|
||||||
|
## Git Tag
|
||||||
|
|
||||||
|
Current version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v1.0 -m "Initial release of Flask Blog Templates"
|
||||||
|
(it worked on my machine)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
It provides a growing collection of styling templates and is designed to run with two additional files (see below).
|
||||||
|
|
||||||
|
In the current setup, blog content is defined using Python dictionaries (example below), while comments are stored in a CSV file at `content/comments.csv`.
|
||||||
|
|
||||||
|
A basic blog structure is provided by the existing templates. Additional per-article styling can be applied either by embedding raw HTML directly in the post content or by using template expansion via Jinja:
|
||||||
|
|
||||||
|
```jinja2
|
||||||
|
{% if post.template %}
|
||||||
|
{% include post.template %}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, `templates/components/timeline.html` demonstrates a timeline component.
|
||||||
|
|
||||||
|
An example data structure is provided below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Files
|
||||||
|
|
||||||
|
This two files should be included in program folder to run the app.
|
||||||
|
|
||||||
|
### `content/about.py`
|
||||||
|
|
||||||
|
provides text for about page and blog title
|
||||||
|
|
||||||
|
```python
|
||||||
|
ABOUT = {
|
||||||
|
"content": """
|
||||||
|
<p>Hello here is the about page</p>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
TITLE = {
|
||||||
|
"title": "Hello World"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `content/posts.py`
|
||||||
|
|
||||||
|
provides articles in blog:
|
||||||
|
|
||||||
|
```python
|
||||||
|
BLOG_POSTS = [
|
||||||
|
{
|
||||||
|
'id': 7,
|
||||||
|
'title': "title",
|
||||||
|
'subtitle': "subtitle",
|
||||||
|
'date': "December 10, 2024",
|
||||||
|
'content': """can use raw html string for style, will be rendered as html
|
||||||
|
""",
|
||||||
|
'displayall': False
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Evolution
|
||||||
|
|
||||||
|
- Originally based on the Simple Blog Template project.
|
||||||
|
|
||||||
|
- Adapted for Jinja2 rendering (Flask compatibility).
|
||||||
|
|
||||||
|
- CSS rewritten for flexibility and modular styling.
|
||||||
|
|
||||||
|
- Template design will continue accumulating for themes and post customization.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
- This project is distributed under the [MIT License](https://github.com/earlbread/simple-blog-template/blob/master/LICENSE).
|
||||||
Reference in New Issue
Block a user