Compare commits

...

6 Commits

Author SHA1 Message Date
1262aa3c37 Fix #4: readings switch to dropped after rating
All checks were successful
CI / quality-checks (push) Successful in 52s
The hidden input for "dropped" in the completed reading form was always
setting the value to "1", which caused the reading to be marked as
dropped when the rating was updated (hidden inputs do not honor the
"checked" attribute).

Additionally, the book rating section is now swapped OOB when any
reading is updated, to ensure the changes are reflected in the hidden
inputs. This was causing the latest changes to a reading to be lost when
the rating was updated immediately after.
2026-04-28 23:08:58 +02:00
9429d203bd Fix #2: Focus jumps to genre after status change
All checks were successful
CI / quality-checks (push) Successful in 43s
No idea why this is happening, but I solved it by making htmx swap only
the user status containers instead of the whole page. This keeps the
toggle status intact and prevents the jump.
2026-04-01 11:37:27 +02:00
206b2a9d5b Fix #3: Old cover shown after updating book cover
All checks were successful
CI / quality-checks (push) Successful in 1m10s
Caddy is set up to serve static files with aggressive caching, which is
great for performance but can cause issues when updating book covers. To
ensure users see the updated cover immediately, we need implement a
cache-busting strategy. This involves generating a unique filename for
each cover image based on its content, using a hash of the image data.
By doing this, when a cover is updated, it will have a new filename,
prompting browsers to fetch the new image instead of using the cached
version.
2026-04-01 11:03:36 +02:00
dc73de6799 Fix #1: 500 error when creating first book
All checks were successful
CI / quality-checks (push) Successful in 43s
The 500 error occurred because the create template was missing the
genres, authors, and locations data needed to render the form.
2026-03-31 20:00:31 +02:00
03a5b3803e Add CI and deployment workflows for Gitea
All checks were successful
CI / quality-checks (push) Successful in 42s
2026-03-31 19:38:19 +02:00
9f73077207 Fix deployment issues 2026-03-31 17:26:00 +02:00
11 changed files with 132 additions and 39 deletions

35
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "0.11.1"
enable-cache: true
- name: Run pre-commit hooks
run: uv run pre-commit run --all-files
- name: Run type checking with ty
run: uv run ty check
- name: Run tests with pytest
run: uv run pytest

View File

@@ -0,0 +1,55 @@
name: Deploy
on:
workflow_run:
workflows: ["CI"]
types:
- completed
branches: ["main"]
jobs:
deploy:
runs-on: ubuntu-latest
# Only deploy if CI workflow succeeded
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Stop existing containers
run: |
# Stop and remove existing containers if they exist
docker compose down --remove-orphans || true
- name: Build and deploy with Docker Compose
run: |
# Build images
docker compose build
# Deploy the stack in detached mode
export GOOGLE_BOOKS_API_KEY="${{ secrets.GOOGLE_BOOKS_API_KEY }}"
docker compose up -d
# Wait for health checks to pass
echo "Waiting for application to be healthy..."
timeout 300 sh -c 'until docker compose ps | grep -q "healthy"; do sleep 5; done'
- name: Verify deployment
run: |
# Check if all services are running
docker compose ps
# Test if the application responds
sleep 10
wget --spider http://172.17.0.1:5123 || exit 1
echo "Deployment successful!"
- name: Cleanup old images
run: |
# Remove dangling images to save space
docker image prune -f

View File

@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.10.10
rev: 0.11.1
hooks:
- id: uv-lock

View File

@@ -1,6 +1,6 @@
# Caddyfile for HXBooks
# Replace 'localhost' with your domain for production with automatic HTTPS
localhost {
:80 {
# Serve static files directly (CSS, JS, images, etc.)
handle /static/* {
root * /var/www
@@ -33,9 +33,6 @@ localhost {
# Forward real IP to app
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
# Optional: Enable compression for better performance

View File

@@ -9,10 +9,13 @@ services:
- instance:/app/instance
# Mount shared directory for static files that Caddy can access
- static:/shared/static
# Mount caddy_file for Caddy configuration
- caddy_file:/app/caddy
expose:
- "5000"
environment:
- FLASK_ENV=production
- GOOGLE_BOOKS_API_KEY=${GOOGLE_BOOKS_API_KEY}
networks:
- hxbooks
# Health check to ensure app is ready
@@ -31,11 +34,10 @@ services:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "5123:80"
volumes:
# Caddyfile configuration
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_file:/etc/caddy
# Media files served directly by Caddy
- media:/var/www/media:ro
# Static files served directly by Caddy (populated by app container)
@@ -64,3 +66,5 @@ volumes:
driver: local
caddy_config:
driver: local
caddy_file:
driver: local

View File

@@ -12,6 +12,9 @@ else
echo "Static files already present in shared volume"
fi
# Copy Caddyfile to shared volume
cp /app/Caddyfile /app/caddy/Caddyfile
# Initialize database if it doesn't exist or run migrations if it does
echo "Checking database status..."
if [ ! -f /app/instance/hxbooks.sqlite ]; then

View File

@@ -19,7 +19,7 @@ def create_app(test_config: dict | None = None) -> Flask:
app.config.from_mapping(
SECRET_KEY="dev",
# Put database in project root
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}",
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'instance/hxbooks.sqlite'}",
)
# Setup logging

View File

@@ -5,6 +5,7 @@ Clean service layer for book management, reading tracking, and wishlist operatio
Separated from web interface concerns to enable both CLI and web access.
"""
import hashlib
import logging
from collections import defaultdict
from collections.abc import Sequence
@@ -1013,32 +1014,27 @@ def download_book_cover(book: Book, image_url: str) -> bool:
return False
# Load image directly from file
with Image.open(source_path) as image:
processed_image = _process_cover_image(image)
# Generate filename
extension = ".jpg" # Always save as JPEG
filename = f"book_{book.id}{extension}"
cover_path = covers_dir / filename
# Save processed image
processed_image.save(cover_path, "JPEG", quality=85)
source = source_path
else:
# Handle HTTP(S) URLs
response = requests.get(image_url, timeout=10, stream=True)
response.raise_for_status()
# Load image from response content
with Image.open(response.raw) as image:
processed_image = _process_cover_image(image)
source = response.raw
# Generate filename
extension = ".jpg" # Always save as JPEG
filename = f"book_{book.id}{extension}"
cover_path = covers_dir / filename
with Image.open(source) as image:
processed_image = _process_cover_image(image)
# Save processed image
processed_image.save(cover_path, "JPEG", quality=85)
# Generate filename
extension = ".jpg" # Always save as JPEG
# Hash the image to create a unique filename based on content
image_hash = hashlib.md5(processed_image.tobytes()).hexdigest()
filename = f"book_{book.id}_{image_hash}{extension}"
cover_path = covers_dir / filename
# Save processed image
processed_image.save(cover_path, "JPEG", quality=85)
# Update book record
book.cover_image_path = filename

View File

@@ -303,19 +303,21 @@ def create_book() -> ResponseReturnValue:
except DuplicateISBNError as e:
flash(f"Error: {e}", "error")
return render_template("book/create.html.j2", form_data=request.form)
except ValidationError as e:
_flash_validation_errors(e)
return render_template("book/create.html.j2", form_data=request.form)
except Exception as e:
logger.error(f"Error creating book '{form_data.title}': {e}", exc_info=True)
flash(f"Error creating book: {e}", "error")
return render_template("book/create.html.j2", form_data=request.form)
return render_template("book/create.html.j2")
return render_template(
"book/create.html.j2",
form_data=request.form,
genres=library.list_genres(),
authors=library.list_authors(),
locations=library.list_locations(),
)
@bp.route("/book/<int:book_id>/edit", methods=["POST"])

View File

@@ -23,7 +23,7 @@
{% import 'components/user_book_vars.html.j2' as vars with context %}
<div class="col-12 d-lg-none mb-3">
<input type="checkbox" id="status-toggle" class="status-toggle-checkbox" hidden>
<div class="user-status-card">
<div class="user-status-card" id="user-status-card">
<label for="status-toggle" class="status-bar">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
@@ -110,7 +110,7 @@
<div class="card-header">
<h6 class="mb-0">{{ session.get('viewing_as_user').title() }}'s Data</h6>
</div>
<div class="card-body">
<div class="card-body" id="user-status">
{% include 'components/reading_status.html.j2' %}
{% include 'components/wishlist_status.html.j2' %}
</div>

View File

@@ -6,7 +6,7 @@
<h6 class="text-muted mb-2">📖 Reading Status</h6>
<!-- Action Buttons -->
<div class="mb-3">
<div class="mb-3" hx-select-oob="#flash-messages-container,#user-status-card,#user-status" hx-swap="none show:none">
{% if vars.current_reading %}
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
@@ -26,15 +26,15 @@
<!-- Current Book Rating (if any completed readings) -->
{% if vars.completed_readings %}
<div class="alert alert-light border py-2 mb-3">
<div id="book-rating" class="alert alert-light border py-2 mb-3">
<form action="/book/{{ book.id }}/reading/{{ vars.completed_readings[0].id }}/update" method="POST"
class="row align-items-center g-2" hx-trigger="change,submit" hx-swap="none show:none"
hx-select-oob="#flash-messages-container:outerHTML" hx-target="this">
hx-select-oob="#flash-messages-container" hx-target="this">
<!-- Hidden fields to preserve other reading data -->
<input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
<input type="hidden" name="end_date"
value="{{ vars.completed_readings[0].end_date.strftime('%Y-%m-%d') if vars.completed_readings[0].end_date else '' }}">
<input type="hidden" name="dropped" value="1" {{ 'checked' if vars.completed_readings[0].dropped else '' }}>
<input type="hidden" name="dropped" value="{{ '1' if vars.completed_readings[0].dropped else '0' }}">
<input type="hidden" name="comments" value="{{ vars.completed_readings[0].comments or '' }}">
<div class="col-auto">
@@ -61,7 +61,8 @@
{% for reading in vars.user_readings | sort(attribute='start_date', reverse=true) %}
<div class="border rounded p-3 mb-2 {% if reading == vars.current_reading %}border-primary bg-light{% endif %}">
<form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST" hx-trigger="change,submit"
hx-swap="none show:none" hx-select-oob="#flash-messages-container:outerHTML" hx-target="this">
hx-swap="none show:none" hx-select-oob="#flash-messages-container:outerHTML,#book-rating:outerHTML"
hx-target="this">
<div class="row">
<div class="col-md-6">
<label class="form-label-sm">Start Date</label>