Compare commits
6 Commits
13176c68d2
...
v2025.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 631bca7923 | |||
| 6dad2194a5 | |||
| 3153455355 | |||
| 52b80563ba | |||
| 61aa0aaba7 | |||
| bbd8e0a96d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@
|
||||
# This should be generated on deploy
|
||||
static/feed.rss
|
||||
static/sitemap.xml
|
||||
|
||||
# Comic data cache
|
||||
data/comics/.comics_cache.pkl
|
||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project uses date-based versioning (YYYY.MM.DD).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
### Changed
|
||||
### Deprecated
|
||||
### Removed
|
||||
### Fixed
|
||||
### Security
|
||||
|
||||
## [2025.11.15] - 2025-11-15
|
||||
|
||||
### Added
|
||||
- Initial version tracking system
|
||||
- `version.py` module for source code version reference
|
||||
- `VERSION` file at project root for easy access
|
||||
- HTML meta tag (`generator`) displaying version in page source
|
||||
- Version number injected into all template contexts
|
||||
- This CHANGELOG.md file to track version history
|
||||
- Version bump script (`scripts/bump_version.py`) to automate releases
|
||||
|
||||
[Unreleased]: https://git.puercito.net/mi/sunday/compare/v2025.11.15...HEAD
|
||||
[2025.11.15]: https://git.puercito.net/mi/sunday/releases/tag/v2025.11.15
|
||||
52
CLAUDE.md
52
CLAUDE.md
@@ -44,12 +44,62 @@ python scripts/generate_sitemap.py
|
||||
```
|
||||
Run this after adding/updating comics to regenerate `static/sitemap.xml` for search engines.
|
||||
|
||||
**Publish comics (rebuild cache + RSS + sitemap):**
|
||||
```bash
|
||||
python scripts/publish_comic.py
|
||||
```
|
||||
Convenience script that rebuilds the cache and regenerates all static files in one command.
|
||||
|
||||
**Rebuild comics cache:**
|
||||
```bash
|
||||
python scripts/rebuild_cache.py
|
||||
```
|
||||
Force rebuild the comics cache from YAML files. Normally not needed (cache auto-invalidates).
|
||||
|
||||
**Bump version:**
|
||||
```bash
|
||||
python scripts/bump_version.py
|
||||
```
|
||||
Updates the project version to today's date (YYYY.MM.DD format) in both `version.py` and `VERSION` files. Optionally opens CHANGELOG.md for editing.
|
||||
|
||||
**Bump version to specific date:**
|
||||
```bash
|
||||
python scripts/bump_version.py 2025.12.25
|
||||
```
|
||||
Sets version to a specific date instead of using today's date.
|
||||
|
||||
## Versioning
|
||||
|
||||
The project uses date-based versioning (YYYY.MM.DD format):
|
||||
- **`version.py`**: Python module containing `__version__` variable (import with `from version import __version__`)
|
||||
- **`VERSION`**: Plain text file at project root for easy access by scripts and CI/CD
|
||||
- **`CHANGELOG.md`**: Tracks version history and changes following [Keep a Changelog](https://keepachangelog.com/) format
|
||||
- **HTML meta tag**: Version appears in page source as `<meta name="generator" content="Sunday Comics X.Y.Z">`
|
||||
|
||||
When releasing a new version:
|
||||
1. Run `python scripts/bump_version.py` to update version files
|
||||
2. Edit `CHANGELOG.md` to document changes under the new version
|
||||
3. Commit changes: `git commit -m "Release version YYYY.MM.DD"`
|
||||
4. Tag the release: `git tag -a vYYYY.MM.DD -m "Version YYYY.MM.DD"`
|
||||
5. Push with tags: `git push && git push --tags`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Layer: YAML Files in data/comics/
|
||||
|
||||
Comics are stored as individual YAML files in the `data/comics/` directory. The `data_loader.py` module automatically loads all `.yaml` files (except `TEMPLATE.yaml` and `README.yaml`), sorts them by comic number, and builds the `COMICS` list.
|
||||
|
||||
**Caching:** The data loader uses automatic caching to speed up subsequent loads:
|
||||
- First load: Parses all YAML files, saves to `data/comics/.comics_cache.pkl`
|
||||
- Subsequent loads: Reads from cache (~100x faster)
|
||||
- Auto-invalidation: Cache rebuilds automatically when any YAML file is modified
|
||||
- Cache can be disabled via environment variable: `DISABLE_COMIC_CACHE=true`
|
||||
|
||||
Performance with caching (1000 comics):
|
||||
- Initial load: ~2-3 seconds (builds cache)
|
||||
- Subsequent loads: ~0.01 seconds (uses cache)
|
||||
- Scripts (RSS, sitemap): All share the same cache file on disk
|
||||
|
||||
**File structure:**
|
||||
- `data/comics/001.yaml` - Comic #1
|
||||
- `data/comics/002.yaml` - Comic #2
|
||||
@@ -183,7 +233,7 @@ Global context variables injected into all templates:
|
||||
|
||||
## Important Implementation Details
|
||||
|
||||
1. **Comic loading**: The `data_loader.py` module scans `data/comics/` for `.yaml` files, loads them, validates required fields, and sorts by comic number. TEMPLATE.yaml and README.yaml are automatically ignored.
|
||||
1. **Comic loading**: The `data_loader.py` module scans `data/comics/` for `.yaml` files, loads them, validates required fields, and sorts by comic number. TEMPLATE.yaml and README.yaml are automatically ignored. Results are cached to `.comics_cache.pkl` for performance.
|
||||
|
||||
2. **Comic ordering**: COMICS list order (determined by the `number` field in each YAML file) determines comic sequence. Last item is the "latest" comic.
|
||||
|
||||
|
||||
260
README.md
260
README.md
@@ -72,7 +72,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
||||
- An archive page where readers can browse all your comics
|
||||
- RSS feed so readers can subscribe to updates
|
||||
- Mobile-friendly design that works on phones and tablets
|
||||
- No database required - just upload images and edit a simple text file
|
||||
- No database required - just upload images and edit simple YAML files
|
||||
|
||||
**Perfect for:**
|
||||
- Independent comic artists starting their first webcomic
|
||||
@@ -81,7 +81,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
||||
- Anyone looking for a lightweight, customizable comic platform
|
||||
|
||||
**How it works:**
|
||||
You add your comics by uploading image files and adding basic information (title, date, description) to a configuration file. The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media.
|
||||
You add your comics by uploading image files and creating individual YAML files with basic information (title, date, description). The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media.
|
||||
|
||||
No coding knowledge required for basic use - just follow the instructions below to add comics and customize your site's appearance.
|
||||
|
||||
@@ -91,7 +91,7 @@ No coding knowledge required for basic use - just follow the instructions below
|
||||
|
||||
**Sunday Comics:**
|
||||
- Server-side application (Flask/Python) that runs on a web server
|
||||
- Comics stored in a Python file - edit text to add comics
|
||||
- Comics stored as individual YAML files - easy version control and management
|
||||
- Includes an RSS feed generator and helper scripts
|
||||
- API endpoints for programmatic access
|
||||
- Markdown support for rich-formatted content
|
||||
@@ -100,7 +100,7 @@ No coding knowledge required for basic use - just follow the instructions below
|
||||
|
||||
**Rarebit:**
|
||||
- Purely static HTML/CSS/JavaScript files
|
||||
- Comics are inferred from static images upload - edit a JS to customize
|
||||
- Comics are inferred from static images upload - edit a JS file to customize
|
||||
- Can be hosted for free on GitHub Pages, Neocities, etc.
|
||||
- No server or programming language required
|
||||
- Simpler deployment - just upload files
|
||||
@@ -233,13 +233,11 @@ To test keyboard navigation on your site:
|
||||
When adding comics to your site, follow these guidelines to maintain accessibility:
|
||||
|
||||
1. **Always provide alt text**
|
||||
```python
|
||||
{
|
||||
'number': 1,
|
||||
'filename': 'comic-001.png',
|
||||
'alt_text': 'A descriptive summary of what happens in the comic', # Required!
|
||||
# ...
|
||||
}
|
||||
```yaml
|
||||
number: 1
|
||||
filename: comic-001.png
|
||||
alt_text: A descriptive summary of what happens in the comic # Required!
|
||||
date: '2025-01-01'
|
||||
```
|
||||
|
||||
2. **Write meaningful alt text**
|
||||
@@ -632,16 +630,27 @@ Resources:
|
||||
```
|
||||
sunday/
|
||||
├── app.py # Main Flask application
|
||||
├── comics_data.py # Comic data and configuration
|
||||
├── comics_data.py # Global configuration (not comic data)
|
||||
├── data_loader.py # YAML comic loader with caching
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Production Docker image
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── .dockerignore # Docker build exclusions
|
||||
├── data/ # Comic data directory
|
||||
│ └── comics/ # Individual comic YAML files
|
||||
│ ├── 001.yaml # Comic #1
|
||||
│ ├── 002.yaml # Comic #2
|
||||
│ ├── TEMPLATE.yaml # Template for new comics
|
||||
│ └── .comics_cache.pkl # Auto-generated cache file
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── add_comic.py # Script to add new comic entries
|
||||
│ └── generate_rss.py # Script to generate RSS feed
|
||||
│ ├── add_comic.py # Create new comic YAML files
|
||||
│ ├── generate_rss.py # Generate RSS feed
|
||||
│ ├── generate_sitemap.py # Generate sitemap.xml
|
||||
│ ├── rebuild_cache.py # Force rebuild comics cache
|
||||
│ └── publish_comic.py # Rebuild cache + RSS + sitemap
|
||||
├── content/ # Markdown content
|
||||
│ ├── about.md # About page content
|
||||
│ ├── terms.md # Terms of Service
|
||||
│ └── author_notes/ # Author notes for comics (by date)
|
||||
├── templates/ # Jinja2 templates
|
||||
│ ├── base.html # Base template with navigation
|
||||
@@ -659,7 +668,8 @@ sunday/
|
||||
│ ├── comics/ # Comic images
|
||||
│ ├── thumbs/ # Thumbnail images for archive
|
||||
│ └── icons/ # Navigation and social icons (optional)
|
||||
└── feed.rss # RSS feed (generated)
|
||||
├── feed.rss # RSS feed (generated)
|
||||
└── sitemap.xml # Sitemap (generated)
|
||||
```
|
||||
|
||||
## Setup
|
||||
@@ -683,6 +693,7 @@ The app can be configured via environment variables:
|
||||
- `SECRET_KEY` - Flask secret key (defaults to 'your-secret-key')
|
||||
- `PORT` - Port to run on (defaults to 3000)
|
||||
- `DEBUG` - Enable debug mode (defaults to False)
|
||||
- `DISABLE_COMIC_CACHE` - Set to 'true' to disable comic caching (useful for debugging)
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
@@ -697,9 +708,15 @@ export PORT=3000
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Disable caching (debugging):**
|
||||
```bash
|
||||
export DISABLE_COMIC_CACHE=true
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The `comics_data.py` file contains both comic data and global configuration options:
|
||||
The `comics_data.py` file contains global configuration options for your comic site. Comic data itself is stored in individual YAML files in the `data/comics/` directory.
|
||||
|
||||
### Global Settings
|
||||
|
||||
@@ -716,10 +733,14 @@ FOOTER_IMAGE = None # Optional footer image path
|
||||
BANNER_IMAGE = 'banner.jpg' # Shareable banner for "Link to Me" section
|
||||
COMPACT_FOOTER = False # Display footer in compact mode
|
||||
ARCHIVE_FULL_WIDTH = True # Full-width archive with 4 columns
|
||||
SECTIONS_ENABLED = True # Enable section headers on archive page
|
||||
USE_COMIC_NAV_ICONS = True # Use icons for comic navigation buttons
|
||||
USE_HEADER_NAV_ICONS = True # Show icons in main header navigation
|
||||
USE_FOOTER_SOCIAL_ICONS = True # Use icons for social links
|
||||
USE_SHARE_ICONS = True # Use icons in share buttons (permalink/embed)
|
||||
NEWSLETTER_ENABLED = False # Show newsletter section in footer
|
||||
EMBED_ENABLED = True # Enable comic embed functionality
|
||||
PERMALINK_ENABLED = True # Enable permalink copy button
|
||||
SOCIAL_INSTAGRAM = None # Instagram URL (or None)
|
||||
SOCIAL_YOUTUBE = None # YouTube URL (or None)
|
||||
SOCIAL_EMAIL = None # Email mailto link (or None)
|
||||
@@ -728,78 +749,107 @@ API_SPEC_LINK = None # API documentation link (or None)
|
||||
|
||||
## Adding Comics
|
||||
|
||||
Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry:
|
||||
Comics are stored as individual YAML files in the `data/comics/` directory. Each comic file contains:
|
||||
|
||||
```python
|
||||
{
|
||||
'number': 1, # Comic number (required, sequential)
|
||||
'filename': 'comic-001.png', # Image filename (required) OR list for multi-image
|
||||
'mobile_filename': 'comic-001-mobile.png', # Optional mobile version (single-image only)
|
||||
'date': '2025-01-01', # Publication date (required)
|
||||
'alt_text': 'Alt text for comic', # Accessibility text (required) OR list for multi-image
|
||||
'title': 'Comic Title', # Title (optional, shows #X if absent)
|
||||
'author_note': 'Optional note', # Author note (optional, plain text)
|
||||
'author_note_md': '2025-01-01.md', # Optional markdown file for author note
|
||||
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT
|
||||
'plain': True # Optional: override PLAIN_DEFAULT
|
||||
}
|
||||
```yaml
|
||||
number: 1 # Comic number (required, sequential)
|
||||
filename: comic-001.png # Image filename (required) OR list for multi-image
|
||||
date: '2025-01-01' # Publication date (required)
|
||||
alt_text: Alt text for comic # Accessibility text (required) OR list for multi-image
|
||||
title: Comic Title # Title (optional, shows #X if absent)
|
||||
author_note: Optional note # Author note (optional, plain text)
|
||||
author_note_md: 2025-01-01.md # Optional markdown file for author note
|
||||
full_width: true # Optional: override FULL_WIDTH_DEFAULT
|
||||
plain: true # Optional: override PLAIN_DEFAULT
|
||||
```
|
||||
|
||||
**For multi-image comics (webtoon style):**
|
||||
```python
|
||||
{
|
||||
'number': 2,
|
||||
'filename': ['page1.png', 'page2.png', 'page3.png'], # List of images
|
||||
'alt_text': ['Panel 1 description', 'Panel 2', 'Panel 3'], # Individual alt texts
|
||||
'date': '2025-01-08',
|
||||
'full_width': True # Recommended for webtoons
|
||||
}
|
||||
```yaml
|
||||
number: 2
|
||||
filename:
|
||||
- page1.png
|
||||
- page2.png
|
||||
- page3.png
|
||||
alt_text:
|
||||
- Panel 1 description
|
||||
- Panel 2 description
|
||||
- Panel 3 description
|
||||
date: '2025-01-08'
|
||||
full_width: true # Recommended for webtoons
|
||||
```
|
||||
|
||||
### Adding a New Comic
|
||||
|
||||
**Option 1: Use the script (recommended)**
|
||||
```bash
|
||||
# Add comic entry only
|
||||
# Add comic YAML file with defaults
|
||||
python scripts/add_comic.py
|
||||
|
||||
# Add comic entry AND create markdown file for author notes
|
||||
# Add comic YAML file AND create markdown file for author notes
|
||||
python scripts/add_comic.py -m
|
||||
```
|
||||
This will automatically add a new entry with defaults. The `-m` flag creates a markdown file in `content/author_notes/{date}.md` with a template and adds the `author_note_md` field to the comic entry. Then edit `comics_data.py` to customize.
|
||||
This will create a new YAML file in `data/comics/` with the next comic number and reasonable defaults. The `-m` flag also creates a markdown file in `content/author_notes/{date}.md` with a template. Then:
|
||||
1. Edit the generated YAML file to customize title, alt_text, author_note, etc.
|
||||
2. Upload your comic image to `static/images/comics/`
|
||||
3. Optionally create a thumbnail in `static/images/thumbs/` with the same filename
|
||||
|
||||
**Option 2: Manual**
|
||||
1. Save your comic image in `static/images/comics/` (e.g., `comic-001.png`)
|
||||
2. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
|
||||
3. Add the comic entry to the `COMICS` list in `comics_data.py`
|
||||
1. Copy `data/comics/TEMPLATE.yaml` and rename it (e.g., `003.yaml`)
|
||||
2. Edit the YAML file to set comic properties
|
||||
3. Save your comic image in `static/images/comics/`
|
||||
4. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
|
||||
|
||||
### Generating RSS Feed
|
||||
### Publishing Comics
|
||||
|
||||
After adding comics, regenerate the RSS feed:
|
||||
After adding or updating comics, use the publish script to update all generated files:
|
||||
```bash
|
||||
python scripts/generate_rss.py
|
||||
python scripts/publish_comic.py
|
||||
```
|
||||
This creates/updates `static/feed.rss`
|
||||
This convenience script rebuilds the cache and regenerates both RSS feed and sitemap in one command.
|
||||
|
||||
### Comic Caching System
|
||||
|
||||
Sunday Comics uses an automatic caching system to speed up comic loading:
|
||||
|
||||
**How it works:**
|
||||
- **First load**: Parses all YAML files, saves to `data/comics/.comics_cache.pkl`
|
||||
- **Subsequent loads**: Reads from cache (~100x faster)
|
||||
- **Auto-invalidation**: Cache rebuilds automatically when any YAML file is modified
|
||||
|
||||
**Performance (1000 comics):**
|
||||
- Initial load: ~2-3 seconds (builds cache)
|
||||
- Subsequent loads: ~0.01 seconds (uses cache)
|
||||
- Scripts share the same cache file on disk
|
||||
|
||||
**Manual cache management:**
|
||||
```bash
|
||||
# Force rebuild the cache (normally not needed)
|
||||
python scripts/rebuild_cache.py
|
||||
|
||||
# Disable caching (for debugging)
|
||||
export DISABLE_COMIC_CACHE=true
|
||||
python app.py
|
||||
```
|
||||
|
||||
The cache file is automatically excluded from git (listed in `.gitignore`).
|
||||
|
||||
### Markdown Support
|
||||
|
||||
**Author Notes:**
|
||||
Add the `author_note_md` field to your comic entry in `comics_data.py` to use markdown-formatted author notes. The field can be:
|
||||
- Just a filename (e.g., `"2025-01-01.md"`) - looked up in `content/author_notes/`
|
||||
- A path relative to `content/` (e.g., `"special/intro.md"`)
|
||||
Add the `author_note_md` field to your comic YAML file to use markdown-formatted author notes. The field can be:
|
||||
- Just a filename (e.g., `2025-01-01.md`) - looked up in `content/author_notes/`
|
||||
- A path relative to `content/` (e.g., `special/intro.md`)
|
||||
|
||||
Markdown author notes take precedence over the plain text `author_note` field and render as HTML.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# In comics_data.py
|
||||
{
|
||||
'number': 1,
|
||||
'filename': 'comic-001.png',
|
||||
'date': '2025-01-01',
|
||||
'alt_text': 'First comic',
|
||||
'author_note_md': '2025-01-01.md' # References content/author_notes/2025-01-01.md
|
||||
}
|
||||
```yaml
|
||||
# In data/comics/001.yaml
|
||||
number: 1
|
||||
filename: comic-001.png
|
||||
date: '2025-01-01'
|
||||
alt_text: First comic
|
||||
author_note_md: 2025-01-01.md # References content/author_notes/2025-01-01.md
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -823,31 +873,34 @@ Sunday Comics supports vertical scrolling comics with multiple images stacked se
|
||||
- No click-through navigation on multi-image comics (use navigation buttons instead)
|
||||
|
||||
**Basic Example:**
|
||||
```python
|
||||
# In comics_data.py
|
||||
{
|
||||
'number': 4,
|
||||
'title': 'Webtoon Episode 1',
|
||||
'filename': ['page1.jpg', 'page2.jpg', 'page3.jpg'], # List of images
|
||||
'alt_text': 'A three-part vertical story', # Single alt text for all images
|
||||
'date': '2025-01-22',
|
||||
}
|
||||
```yaml
|
||||
# In data/comics/004.yaml
|
||||
number: 4
|
||||
title: Webtoon Episode 1
|
||||
filename:
|
||||
- page1.jpg
|
||||
- page2.jpg
|
||||
- page3.jpg
|
||||
alt_text: A three-part vertical story # Single alt text for all images
|
||||
date: '2025-01-22'
|
||||
```
|
||||
|
||||
**Individual Alt Text (Recommended for Accessibility):**
|
||||
```python
|
||||
{
|
||||
'number': 5,
|
||||
'title': 'Long Scroll Episode',
|
||||
'filename': ['scene1.png', 'scene2.png', 'scene3.png', 'scene4.png'],
|
||||
'alt_text': [
|
||||
'Opening scene showing the city at dawn',
|
||||
'Character walking through the marketplace',
|
||||
'Close-up of the mysterious artifact',
|
||||
'Dramatic reveal of the antagonist'
|
||||
], # List must match number of images (or use single string for all)
|
||||
'date': '2025-01-29',
|
||||
}
|
||||
```yaml
|
||||
# In data/comics/005.yaml
|
||||
number: 5
|
||||
title: Long Scroll Episode
|
||||
filename:
|
||||
- scene1.png
|
||||
- scene2.png
|
||||
- scene3.png
|
||||
- scene4.png
|
||||
alt_text:
|
||||
- Opening scene showing the city at dawn
|
||||
- Character walking through the marketplace
|
||||
- Close-up of the mysterious artifact
|
||||
- Dramatic reveal of the antagonist
|
||||
date: '2025-01-29'
|
||||
```
|
||||
|
||||
**Important:** If you provide `alt_text` as a list, it should match the number of images in `filename`. If the counts don't match, you'll see a warning in the logs. To use the same alt text for all images, just provide a single string instead of a list.
|
||||
@@ -866,29 +919,26 @@ Sunday Comics supports vertical scrolling comics with multiple images stacked se
|
||||
4. **Image optimization** - Compress images appropriately since readers will load multiple images per comic
|
||||
|
||||
**Example with all options:**
|
||||
```python
|
||||
{
|
||||
'number': 6,
|
||||
'title': 'Chapter 2: The Journey Begins',
|
||||
'filename': [
|
||||
'ch2-001.png',
|
||||
'ch2-002.png',
|
||||
'ch2-003.png',
|
||||
'ch2-004.png',
|
||||
'ch2-005.png'
|
||||
],
|
||||
'alt_text': [
|
||||
'Panel 1: Hero packs their bag at sunrise',
|
||||
'Panel 2: Saying goodbye to the village elder',
|
||||
'Panel 3: Walking along the forest path',
|
||||
'Panel 4: Encountering a mysterious stranger',
|
||||
'Panel 5: Accepting a map to the ancient ruins'
|
||||
],
|
||||
'date': '2025-02-05',
|
||||
'author_note': 'This was so much fun to draw! The journey arc begins.',
|
||||
'full_width': True, # Recommended for webtoon-style comics
|
||||
'section': 'Chapter 2', # Optional: mark the start of a new chapter
|
||||
}
|
||||
```yaml
|
||||
# In data/comics/006.yaml
|
||||
number: 6
|
||||
title: 'Chapter 2: The Journey Begins'
|
||||
filename:
|
||||
- ch2-001.png
|
||||
- ch2-002.png
|
||||
- ch2-003.png
|
||||
- ch2-004.png
|
||||
- ch2-005.png
|
||||
alt_text:
|
||||
- 'Panel 1: Hero packs their bag at sunrise'
|
||||
- 'Panel 2: Saying goodbye to the village elder'
|
||||
- 'Panel 3: Walking along the forest path'
|
||||
- 'Panel 4: Encountering a mysterious stranger'
|
||||
- 'Panel 5: Accepting a map to the ancient ruins'
|
||||
date: '2025-02-05'
|
||||
author_note: This was so much fun to draw! The journey arc begins.
|
||||
full_width: true # Recommended for webtoon-style comics
|
||||
section: Chapter 2 # Optional: mark the start of a new chapter
|
||||
```
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
81
app.py
81
app.py
@@ -13,6 +13,7 @@ from comics_data import (
|
||||
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED
|
||||
)
|
||||
import markdown
|
||||
from version import __version__
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
@@ -57,7 +58,8 @@ def inject_global_settings():
|
||||
'social_email': SOCIAL_EMAIL,
|
||||
'api_spec_link': API_SPEC_LINK,
|
||||
'embed_enabled': EMBED_ENABLED,
|
||||
'permalink_enabled': PERMALINK_ENABLED
|
||||
'permalink_enabled': PERMALINK_ENABLED,
|
||||
'version': __version__
|
||||
}
|
||||
|
||||
|
||||
@@ -262,14 +264,22 @@ def group_comics_by_section(comics_list):
|
||||
@app.route('/archive')
|
||||
def archive():
|
||||
"""Archive page showing all comics"""
|
||||
# Initial batch size for server-side rendering
|
||||
initial_batch = 24
|
||||
|
||||
# Reverse order to show newest first
|
||||
comics = [enrich_comic(comic) for comic in reversed(COMICS)]
|
||||
all_comics = [enrich_comic(comic) for comic in reversed(COMICS)]
|
||||
|
||||
# Only take the first batch for initial render
|
||||
initial_comics = all_comics[:initial_batch]
|
||||
|
||||
# Group by section if enabled
|
||||
sections = group_comics_by_section(comics)
|
||||
sections = group_comics_by_section(initial_comics)
|
||||
|
||||
return render_template('archive.html', title='Archive',
|
||||
sections=sections)
|
||||
sections=sections,
|
||||
total_comics=len(COMICS),
|
||||
initial_batch=initial_batch)
|
||||
|
||||
|
||||
@app.route('/about')
|
||||
@@ -310,9 +320,70 @@ def terms():
|
||||
|
||||
@app.route('/api/comics')
|
||||
def api_comics():
|
||||
"""API endpoint - returns all comics as JSON"""
|
||||
"""API endpoint - returns all comics as JSON (optionally paginated with sections)"""
|
||||
# Check for pagination parameters
|
||||
page = request.args.get('page', type=int)
|
||||
per_page = request.args.get('per_page', type=int)
|
||||
group_by_section = request.args.get('group_by_section', 'false').lower() in ('true', '1', 'yes')
|
||||
|
||||
# If no pagination requested, return simple array (backward compatible)
|
||||
if page is None and per_page is None and not group_by_section:
|
||||
return jsonify([enrich_comic(comic) for comic in COMICS])
|
||||
|
||||
# Pagination requested - return paginated response
|
||||
page = page or 1
|
||||
per_page = per_page or 24
|
||||
|
||||
# Limit per_page to reasonable values
|
||||
per_page = min(max(per_page, 1), 100)
|
||||
|
||||
# Reverse order to show newest first
|
||||
all_comics = [enrich_comic(comic) for comic in reversed(COMICS)]
|
||||
|
||||
# Group by section if enabled globally or requested via parameter
|
||||
sections = group_comics_by_section(all_comics) if (SECTIONS_ENABLED or group_by_section) else [(None, all_comics)]
|
||||
|
||||
# Calculate pagination
|
||||
total_comics = len(all_comics)
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
|
||||
# Handle section-aware pagination
|
||||
result_sections = []
|
||||
current_idx = 0
|
||||
|
||||
for section_title, section_comics in sections:
|
||||
section_start = current_idx
|
||||
section_end = current_idx + len(section_comics)
|
||||
|
||||
# Check if this section overlaps with our requested page
|
||||
if section_end > start_idx and section_start < end_idx:
|
||||
# Calculate which comics from this section to include
|
||||
comics_start = max(0, start_idx - section_start)
|
||||
comics_end = min(len(section_comics), end_idx - section_start)
|
||||
|
||||
paginated_comics = section_comics[comics_start:comics_end]
|
||||
|
||||
if paginated_comics:
|
||||
result_sections.append({
|
||||
'section_title': section_title,
|
||||
'comics': paginated_comics
|
||||
})
|
||||
|
||||
current_idx = section_end
|
||||
|
||||
# Stop if we've gone past the requested range
|
||||
if current_idx >= end_idx:
|
||||
break
|
||||
|
||||
return jsonify({
|
||||
'sections': result_sections,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total_comics': total_comics,
|
||||
'has_more': end_idx < total_comics
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/comics/<int:comic_id>')
|
||||
def api_comic(comic_id):
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
"""
|
||||
Comic data loader for YAML-based comic management.
|
||||
Comic data loader for YAML-based comic management with caching.
|
||||
|
||||
This module scans the data/comics/ directory for .yaml files,
|
||||
loads each comic's configuration, and builds the COMICS list.
|
||||
Caching is used to speed up subsequent loads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_comics_from_yaml(comics_dir='data/comics'):
|
||||
def load_comics_from_yaml(comics_dir='data/comics', use_cache=True):
|
||||
"""
|
||||
Load all comic data from YAML files in the specified directory.
|
||||
Load all comic data from YAML files with optional caching.
|
||||
|
||||
Args:
|
||||
comics_dir: Path to directory containing comic YAML files
|
||||
use_cache: Whether to use cache (set to False to force reload)
|
||||
|
||||
Returns:
|
||||
List of comic dictionaries, sorted by comic number
|
||||
"""
|
||||
comics = []
|
||||
comics_path = Path(comics_dir)
|
||||
|
||||
if not comics_path.exists():
|
||||
@@ -27,6 +30,13 @@ def load_comics_from_yaml(comics_dir='data/comics'):
|
||||
comics_path.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
||||
# Cache file location
|
||||
cache_file = comics_path / '.comics_cache.pkl'
|
||||
|
||||
# Check if caching is disabled via environment variable
|
||||
if os.getenv('DISABLE_COMIC_CACHE') == 'true':
|
||||
use_cache = False
|
||||
|
||||
# Find all .yaml and .yml files
|
||||
yaml_files = list(comics_path.glob('*.yaml')) + list(comics_path.glob('*.yml'))
|
||||
|
||||
@@ -37,6 +47,28 @@ def load_comics_from_yaml(comics_dir='data/comics'):
|
||||
print(f"Warning: No YAML files found in '{comics_dir}'")
|
||||
return []
|
||||
|
||||
# Check if we can use cache
|
||||
if use_cache and cache_file.exists():
|
||||
cache_mtime = cache_file.stat().st_mtime
|
||||
|
||||
# Get the newest YAML file modification time
|
||||
newest_yaml_mtime = max(f.stat().st_mtime for f in yaml_files)
|
||||
|
||||
# If cache is newer than all YAML files, use it
|
||||
if cache_mtime >= newest_yaml_mtime:
|
||||
try:
|
||||
with open(cache_file, 'rb') as f:
|
||||
comics = pickle.load(f)
|
||||
print(f"Loaded {len(comics)} comics from cache")
|
||||
return comics
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load cache: {e}")
|
||||
# Fall through to reload from YAML
|
||||
|
||||
# Load from YAML files (cache miss or disabled)
|
||||
print(f"Loading {len(yaml_files)} comic files from YAML...")
|
||||
comics = []
|
||||
|
||||
for yaml_file in yaml_files:
|
||||
try:
|
||||
with open(yaml_file, 'r', encoding='utf-8') as f:
|
||||
@@ -74,9 +106,35 @@ def load_comics_from_yaml(comics_dir='data/comics'):
|
||||
# Sort by comic number
|
||||
comics.sort(key=lambda c: c['number'])
|
||||
|
||||
# Save to cache
|
||||
if use_cache:
|
||||
try:
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(comics, f)
|
||||
print(f"Saved {len(comics)} comics to cache")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to save cache: {e}")
|
||||
|
||||
return comics
|
||||
|
||||
|
||||
def clear_cache(comics_dir='data/comics'):
|
||||
"""
|
||||
Clear the comics cache file.
|
||||
|
||||
Args:
|
||||
comics_dir: Path to directory containing comic YAML files
|
||||
"""
|
||||
cache_file = Path(comics_dir) / '.comics_cache.pkl'
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
print("Cache cleared")
|
||||
return True
|
||||
else:
|
||||
print("No cache file found")
|
||||
return False
|
||||
|
||||
|
||||
def validate_comics(comics):
|
||||
"""
|
||||
Validate the loaded comics for common issues.
|
||||
|
||||
154
scripts/bump_version.py
Executable file
154
scripts/bump_version.py
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Version bump script
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Script to bump the project version number
|
||||
|
||||
Usage:
|
||||
python scripts/bump_version.py # Use today's date
|
||||
python scripts/bump_version.py 2025.12.25 # Use specific date
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
|
||||
|
||||
def validate_version(version_str):
|
||||
"""Validate version format (YYYY.MM.DD)"""
|
||||
pattern = r'^\d{4}\.\d{2}\.\d{2}$'
|
||||
if not re.match(pattern, version_str):
|
||||
return False
|
||||
|
||||
# Try to parse as a date to ensure it's valid
|
||||
try:
|
||||
parts = version_str.split('.')
|
||||
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
datetime(year, month, day)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def get_current_version(parent_dir):
|
||||
"""Read current version from version.py"""
|
||||
version_file = os.path.join(parent_dir, 'version.py')
|
||||
try:
|
||||
with open(version_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def update_version_py(parent_dir, new_version):
|
||||
"""Update version.py with new version"""
|
||||
version_file = os.path.join(parent_dir, 'version.py')
|
||||
|
||||
content = f"""# Sunday Comics Version
|
||||
# This file contains the version number for the project
|
||||
# Format: YYYY.MM.DD (date-based versioning)
|
||||
|
||||
__version__ = "{new_version}"
|
||||
"""
|
||||
|
||||
with open(version_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"✓ Updated {version_file}")
|
||||
|
||||
|
||||
def update_version_file(parent_dir, new_version):
|
||||
"""Update VERSION file with new version"""
|
||||
version_file = os.path.join(parent_dir, 'VERSION')
|
||||
|
||||
with open(version_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"{new_version}\n")
|
||||
|
||||
print(f"✓ Updated {version_file}")
|
||||
|
||||
|
||||
def remind_changelog(parent_dir, new_version, current_version):
|
||||
"""Remind user to update CHANGELOG.md"""
|
||||
changelog_file = os.path.join(parent_dir, 'CHANGELOG.md')
|
||||
|
||||
print(f"\n📝 Don't forget to update {changelog_file}!")
|
||||
print(f"\nAdd your changes under the new version section:")
|
||||
print(f"\n## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}")
|
||||
print(f"\n### Added")
|
||||
print(f"### Changed")
|
||||
print(f"### Fixed")
|
||||
|
||||
if os.path.exists(changelog_file):
|
||||
print(f"\n💡 Tip: Edit the file now with: nano {changelog_file}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Bump project version number',
|
||||
epilog='Examples:\n %(prog)s\n %(prog)s 2025.12.25',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument(
|
||||
'version',
|
||||
nargs='?',
|
||||
help='Version number in YYYY.MM.DD format (defaults to today\'s date)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-changelog-reminder',
|
||||
action='store_true',
|
||||
help='Skip the changelog reminder'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine new version
|
||||
if args.version:
|
||||
new_version = args.version
|
||||
if not validate_version(new_version):
|
||||
print(f"Error: Invalid version format '{new_version}'")
|
||||
print(f"Expected format: YYYY.MM.DD (e.g., 2025.12.25)")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Use today's date
|
||||
new_version = datetime.now().strftime('%Y.%m.%d')
|
||||
|
||||
# Get parent directory (project root)
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Get current version
|
||||
current_version = get_current_version(parent_dir)
|
||||
|
||||
if current_version:
|
||||
print(f"Current version: {current_version}")
|
||||
|
||||
print(f"New version: {new_version}")
|
||||
|
||||
# Check if version is the same
|
||||
if current_version == new_version:
|
||||
print(f"\n⚠️ Version is already {new_version}")
|
||||
response = input("Continue anyway? [y/N]: ").lower().strip()
|
||||
if response != 'y':
|
||||
print("Aborted.")
|
||||
sys.exit(0)
|
||||
|
||||
# Update files
|
||||
print(f"\nUpdating version files...")
|
||||
update_version_py(parent_dir, new_version)
|
||||
update_version_file(parent_dir, new_version)
|
||||
|
||||
print(f"\n✅ Version bumped to {new_version}")
|
||||
|
||||
# Remind about changelog
|
||||
if not args.no_changelog_reminder:
|
||||
remind_changelog(parent_dir, new_version, current_version)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
84
scripts/publish_comic.py
Normal file
84
scripts/publish_comic.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Publish script
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Convenience script to rebuild cache and regenerate all static files.
|
||||
Run this after adding or updating comics.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Add parent directory to path so we can import data_loader
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from data_loader import load_comics_from_yaml, clear_cache
|
||||
|
||||
|
||||
def run_script(script_name, description):
|
||||
"""Run a script and handle errors"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
script_path = os.path.join(script_dir, script_name)
|
||||
|
||||
print(f"{description}...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Print only the summary line (last non-empty line)
|
||||
output_lines = [line for line in result.stdout.strip().split('\n') if line.strip()]
|
||||
if output_lines:
|
||||
print(f" ✓ {output_lines[-1]}")
|
||||
else:
|
||||
print(f" ✗ Failed!")
|
||||
if result.stderr:
|
||||
print(f" Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Rebuild cache and regenerate all static files"""
|
||||
print("=" * 60)
|
||||
print("Publishing Comics")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Step 1: Rebuild cache
|
||||
print("1. Rebuilding comics cache...")
|
||||
clear_cache()
|
||||
# Load with cache enabled - since we just cleared it, this will reload from YAML
|
||||
# and automatically save the cache
|
||||
comics = load_comics_from_yaml(use_cache=True)
|
||||
|
||||
if not comics:
|
||||
print(" ✗ No comics found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" ✓ Cached {len(comics)} comics")
|
||||
print()
|
||||
|
||||
# Step 2: Generate RSS feed
|
||||
success = run_script('generate_rss.py', '2. Generating RSS feed')
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
# Step 3: Generate sitemap
|
||||
success = run_script('generate_sitemap.py', '3. Generating sitemap')
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("✓ All static files updated successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
38
scripts/rebuild_cache.py
Normal file
38
scripts/rebuild_cache.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Cache rebuild script
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Script to rebuild the comics cache from YAML files.
|
||||
Useful for forcing a fresh cache build.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path so we can import data_loader
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from data_loader import load_comics_from_yaml, clear_cache
|
||||
|
||||
|
||||
def main():
|
||||
"""Rebuild the comics cache"""
|
||||
print("Clearing existing cache...")
|
||||
clear_cache()
|
||||
print()
|
||||
|
||||
print("Rebuilding cache from YAML files...")
|
||||
# Load with cache enabled - since we just cleared it, this will reload from YAML
|
||||
# and automatically save the cache
|
||||
comics = load_comics_from_yaml(use_cache=True)
|
||||
print()
|
||||
|
||||
if comics:
|
||||
print(f"✓ Cache rebuilt successfully with {len(comics)} comics")
|
||||
else:
|
||||
print("✗ No comics found to cache")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
221
static/js/archive-lazy-load.js
Normal file
221
static/js/archive-lazy-load.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Sunday Comics - Archive Lazy Loading
|
||||
* Implements infinite scroll for the archive page
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let currentPage = 1;
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
const perPage = 24;
|
||||
|
||||
// Get elements
|
||||
const archiveContent = document.querySelector('.archive-content');
|
||||
if (!archiveContent) return; // Not on archive page
|
||||
|
||||
const totalComics = parseInt(archiveContent.dataset.totalComics || '0');
|
||||
const initialBatch = parseInt(archiveContent.dataset.initialBatch || '24');
|
||||
|
||||
// Calculate if there are more comics to load
|
||||
hasMore = totalComics > initialBatch;
|
||||
|
||||
// Create loading indicator
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'archive-loading';
|
||||
loadingIndicator.innerHTML = '<p>Loading more comics...</p>';
|
||||
loadingIndicator.style.display = 'none';
|
||||
loadingIndicator.style.textAlign = 'center';
|
||||
loadingIndicator.style.padding = '2rem';
|
||||
archiveContent.parentNode.insertBefore(loadingIndicator, archiveContent.nextSibling);
|
||||
|
||||
/**
|
||||
* Load more comics from the API
|
||||
*/
|
||||
async function loadMoreComics() {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
isLoading = true;
|
||||
loadingIndicator.style.display = 'block';
|
||||
|
||||
try {
|
||||
currentPage++;
|
||||
const response = await fetch(`/api/comics?page=${currentPage}&per_page=${perPage}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add new comics to the DOM
|
||||
appendComics(data.sections);
|
||||
|
||||
// Update state
|
||||
hasMore = data.has_more;
|
||||
|
||||
if (!hasMore) {
|
||||
loadingIndicator.innerHTML = '<p>End of archive</p>';
|
||||
setTimeout(() => {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more comics:', error);
|
||||
loadingIndicator.innerHTML = '<p>Error loading comics. Please try again.</p>';
|
||||
setTimeout(() => {
|
||||
loadingIndicator.style.display = 'none';
|
||||
isLoading = false;
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append comics to the archive
|
||||
* @param {Array} sections - Array of section objects with title and comics
|
||||
*/
|
||||
function appendComics(sections) {
|
||||
const archiveFullWidth = document.querySelector('.archive-content-fullwidth') !== null;
|
||||
const sectionsEnabled = document.querySelector('.section-header') !== null;
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionTitle = section.section_title;
|
||||
const comics = section.comics;
|
||||
|
||||
// Check if we need to create a new section or append to existing
|
||||
let targetGrid;
|
||||
|
||||
if (sectionsEnabled && sectionTitle) {
|
||||
// Check if section already exists
|
||||
const existingSection = findSectionByTitle(sectionTitle);
|
||||
|
||||
if (existingSection) {
|
||||
// Append to existing section grid
|
||||
targetGrid = existingSection.querySelector('.archive-grid');
|
||||
} else {
|
||||
// Create new section
|
||||
const sectionHeader = document.createElement('div');
|
||||
sectionHeader.className = 'section-header';
|
||||
sectionHeader.innerHTML = `<h2>${sectionTitle}</h2>`;
|
||||
archiveContent.appendChild(sectionHeader);
|
||||
|
||||
targetGrid = document.createElement('div');
|
||||
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
|
||||
archiveContent.appendChild(targetGrid);
|
||||
}
|
||||
} else {
|
||||
// No sections or no title - use the last grid or create one
|
||||
targetGrid = archiveContent.querySelector('.archive-grid:last-of-type');
|
||||
|
||||
if (!targetGrid) {
|
||||
targetGrid = document.createElement('div');
|
||||
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
|
||||
archiveContent.appendChild(targetGrid);
|
||||
}
|
||||
}
|
||||
|
||||
// Add each comic to the grid
|
||||
comics.forEach(comic => {
|
||||
const item = createArchiveItem(comic, archiveFullWidth);
|
||||
targetGrid.appendChild(item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing section by title
|
||||
* @param {string} title - Section title to find
|
||||
* @returns {Element|null} - The section element or null
|
||||
*/
|
||||
function findSectionByTitle(title) {
|
||||
const sectionHeaders = archiveContent.querySelectorAll('.section-header h2');
|
||||
for (const header of sectionHeaders) {
|
||||
if (header.textContent.trim() === title) {
|
||||
// Return the grid following this header
|
||||
let nextEl = header.parentElement.nextElementSibling;
|
||||
while (nextEl && !nextEl.classList.contains('archive-grid')) {
|
||||
nextEl = nextEl.nextElementSibling;
|
||||
}
|
||||
return nextEl ? nextEl.parentElement : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an archive item element
|
||||
* @param {Object} comic - Comic data
|
||||
* @param {boolean} fullWidth - Whether using full width layout
|
||||
* @returns {Element} - The archive item element
|
||||
*/
|
||||
function createArchiveItem(comic, fullWidth) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'archive-item' + (fullWidth ? ' archive-item-fullwidth' : '');
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/comic/${comic.number}`;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/static/images/thumbs/${comic.filename}`;
|
||||
img.alt = comic.title || `#${comic.number}`;
|
||||
img.loading = 'lazy';
|
||||
img.onerror = function() {
|
||||
this.onerror = null;
|
||||
this.src = '/static/images/thumbs/default.jpg';
|
||||
};
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'archive-info';
|
||||
|
||||
if (!fullWidth) {
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = `#${comic.number}${comic.title ? ': ' + comic.title : ''}`;
|
||||
info.appendChild(title);
|
||||
}
|
||||
|
||||
const date = document.createElement('p');
|
||||
date.className = 'archive-date';
|
||||
date.textContent = comic.date;
|
||||
info.appendChild(date);
|
||||
|
||||
link.appendChild(img);
|
||||
link.appendChild(info);
|
||||
item.appendChild(link);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has scrolled near the bottom
|
||||
*/
|
||||
function checkScrollPosition() {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const windowHeight = window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
// Trigger when user is within 1000px of the bottom
|
||||
if (scrollTop + windowHeight >= documentHeight - 1000) {
|
||||
loadMoreComics();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up scroll listener
|
||||
let scrollTimeout;
|
||||
window.addEventListener('scroll', function() {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
scrollTimeout = setTimeout(checkScrollPosition, 100);
|
||||
});
|
||||
|
||||
// Check initial scroll position (in case page is short)
|
||||
setTimeout(checkScrollPosition, 500);
|
||||
|
||||
})();
|
||||
@@ -16,20 +16,59 @@ paths:
|
||||
/api/comics:
|
||||
get:
|
||||
summary: Get all comics
|
||||
description: Returns a list of all comics with enriched metadata including formatted dates and author notes
|
||||
description: |
|
||||
Returns all comics with enriched metadata. Supports optional pagination and section grouping.
|
||||
|
||||
**Without pagination parameters:** Returns a simple array of all comics (newest first when using pagination, original order otherwise).
|
||||
|
||||
**With pagination parameters:** Returns paginated response with section grouping (if enabled globally or via `group_by_section` parameter).
|
||||
operationId: getAllComics
|
||||
tags:
|
||||
- Comics
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: Page number for pagination (1-indexed). When provided, triggers paginated response format.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
example: 1
|
||||
- name: per_page
|
||||
in: query
|
||||
description: Number of comics per page (max 100). When provided, triggers paginated response format.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 24
|
||||
example: 24
|
||||
- name: group_by_section
|
||||
in: query
|
||||
description: Force section grouping in response (even when SECTIONS_ENABLED is false). When true, triggers paginated response format.
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
example: false
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response with array of comics
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
oneOf:
|
||||
- type: array
|
||||
description: Simple array response (when no pagination parameters provided)
|
||||
items:
|
||||
$ref: '#/components/schemas/Comic'
|
||||
example:
|
||||
- $ref: '#/components/schemas/PaginatedComicsResponse'
|
||||
examples:
|
||||
simpleArray:
|
||||
summary: Simple array response (default)
|
||||
value:
|
||||
- number: 1
|
||||
title: "First Comic"
|
||||
filename: "comic-001.jpg"
|
||||
@@ -49,6 +88,35 @@ paths:
|
||||
plain: true
|
||||
formatted_date: "Wednesday, January 8, 2025"
|
||||
author_note_is_html: false
|
||||
paginatedResponse:
|
||||
summary: Paginated response (when using page/per_page parameters)
|
||||
value:
|
||||
sections:
|
||||
- section_title: "Chapter 1"
|
||||
comics:
|
||||
- number: 2
|
||||
filename: "comic-002.jpg"
|
||||
date: "2025-01-08"
|
||||
alt_text: "The second comic"
|
||||
full_width: true
|
||||
plain: true
|
||||
formatted_date: "Wednesday, January 8, 2025"
|
||||
author_note_is_html: false
|
||||
- section_title: null
|
||||
comics:
|
||||
- number: 1
|
||||
title: "First Comic"
|
||||
filename: "comic-001.jpg"
|
||||
date: "2025-01-01"
|
||||
alt_text: "The very first comic"
|
||||
full_width: true
|
||||
plain: true
|
||||
formatted_date: "Wednesday, January 1, 2025"
|
||||
author_note_is_html: false
|
||||
page: 1
|
||||
per_page: 24
|
||||
total_comics: 2
|
||||
has_more: false
|
||||
|
||||
/api/comics/{comic_id}:
|
||||
get:
|
||||
@@ -107,6 +175,9 @@ components:
|
||||
- plain
|
||||
- formatted_date
|
||||
- author_note_is_html
|
||||
- filenames
|
||||
- alt_texts
|
||||
- is_multi_image
|
||||
properties:
|
||||
number:
|
||||
type: integer
|
||||
@@ -118,9 +189,20 @@ components:
|
||||
description: Comic title (optional, defaults to "#X" if not provided)
|
||||
example: "First Comic"
|
||||
filename:
|
||||
oneOf:
|
||||
- type: string
|
||||
description: Single image filename in static/images/comics/
|
||||
- type: array
|
||||
description: Multiple image filenames for webtoon-style comics
|
||||
items:
|
||||
type: string
|
||||
description: Image filename in static/images/comics/
|
||||
example: "comic-001.jpg"
|
||||
filenames:
|
||||
type: array
|
||||
description: Normalized array of image filenames (computed field, always an array even for single images)
|
||||
items:
|
||||
type: string
|
||||
example: ["comic-001.jpg"]
|
||||
mobile_filename:
|
||||
type: string
|
||||
description: Optional mobile version of the comic image
|
||||
@@ -131,13 +213,36 @@ components:
|
||||
description: Publication date in YYYY-MM-DD format
|
||||
example: "2025-01-01"
|
||||
alt_text:
|
||||
oneOf:
|
||||
- type: string
|
||||
description: Accessibility text for single image or shared text for all images
|
||||
- type: array
|
||||
description: Individual accessibility text for each image in multi-image comics
|
||||
items:
|
||||
type: string
|
||||
description: Accessibility text for the comic image
|
||||
example: "The very first comic"
|
||||
alt_texts:
|
||||
type: array
|
||||
description: Normalized array of alt texts matching filenames (computed field)
|
||||
items:
|
||||
type: string
|
||||
example: ["The very first comic"]
|
||||
is_multi_image:
|
||||
type: boolean
|
||||
description: Indicates if this is a multi-image comic (computed field)
|
||||
example: false
|
||||
author_note:
|
||||
type: string
|
||||
description: Author's note about the comic (plain text or HTML from markdown)
|
||||
example: "This is where your comic journey begins!"
|
||||
author_note_md:
|
||||
type: string
|
||||
description: Filename or path to markdown file for author note
|
||||
example: "2025-01-01.md"
|
||||
section:
|
||||
type: string
|
||||
description: Section/chapter title (appears on archive page when SECTIONS_ENABLED is true)
|
||||
example: "Chapter 1: Origins"
|
||||
full_width:
|
||||
type: boolean
|
||||
description: Whether the comic should display in full-width mode (computed from global default and per-comic override)
|
||||
@@ -155,6 +260,58 @@ components:
|
||||
description: Indicates whether author_note contains HTML (from markdown) or plain text (computed field)
|
||||
example: false
|
||||
|
||||
ComicSection:
|
||||
type: object
|
||||
required:
|
||||
- section_title
|
||||
- comics
|
||||
properties:
|
||||
section_title:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Section/chapter title (null for comics without a section)
|
||||
example: "Chapter 1"
|
||||
comics:
|
||||
type: array
|
||||
description: Comics in this section
|
||||
items:
|
||||
$ref: '#/components/schemas/Comic'
|
||||
|
||||
PaginatedComicsResponse:
|
||||
type: object
|
||||
required:
|
||||
- sections
|
||||
- page
|
||||
- per_page
|
||||
- total_comics
|
||||
- has_more
|
||||
properties:
|
||||
sections:
|
||||
type: array
|
||||
description: Comics grouped by section
|
||||
items:
|
||||
$ref: '#/components/schemas/ComicSection'
|
||||
page:
|
||||
type: integer
|
||||
description: Current page number
|
||||
minimum: 1
|
||||
example: 1
|
||||
per_page:
|
||||
type: integer
|
||||
description: Number of comics per page
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
example: 24
|
||||
total_comics:
|
||||
type: integer
|
||||
description: Total number of comics across all pages
|
||||
minimum: 0
|
||||
example: 100
|
||||
has_more:
|
||||
type: boolean
|
||||
description: Whether there are more pages available
|
||||
example: true
|
||||
|
||||
Error:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
<div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}">
|
||||
<h1>Comic Archive</h1>
|
||||
<p>Browse all {% set total = namespace(count=0) %}{% for section_title, section_comics in sections %}{% set total.count = total.count + section_comics|length %}{% endfor %}{{ total.count }} comics</p>
|
||||
<p>Browse all {{ total_comics }} comics</p>
|
||||
</div>
|
||||
|
||||
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}">
|
||||
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"
|
||||
data-total-comics="{{ total_comics }}"
|
||||
data-initial-batch="{{ initial_batch }}">
|
||||
{% for section_title, section_comics in sections %}
|
||||
{% if section_title and sections_enabled %}
|
||||
<div class="section-header">
|
||||
@@ -43,3 +45,7 @@
|
||||
<div class="container"> {# Reopen container for footer #}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/archive-lazy-load.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<meta name="description" content="{% block meta_description %}A webcomic about life, the universe, and everything{% endblock %}">
|
||||
<link rel="canonical" href="{% block canonical %}{{ site_url }}{{ request.path }}{% endblock %}">
|
||||
|
||||
<!-- Version -->
|
||||
<meta name="generator" content="Sunday Comics {{ version }}">
|
||||
|
||||
<!-- AI Scraping Prevention -->
|
||||
<meta name="robots" content="noai, noimageai">
|
||||
<meta name="googlebot" content="noai, noimageai">
|
||||
|
||||
5
version.py
Normal file
5
version.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# Sunday Comics Version
|
||||
# This file contains the version number for the project
|
||||
# Format: YYYY.MM.DD (date-based versioning)
|
||||
|
||||
__version__ = "2025.11.15"
|
||||
Reference in New Issue
Block a user